Report mobile devices in "hosts online" (#47222)
This commit is contained in:
@@ -0,0 +1 @@
|
||||
- Enabled tracking of mobile devices for the "hosts online" chart, and added default filtering to that chart that excludes mobile platforms.
|
||||
@@ -199,6 +199,30 @@ describe("ChartCard", () => {
|
||||
screen.queryByText(/Data collection is disabled/i)
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("excludes mobile platforms by default and shows the Filtered badge", async () => {
|
||||
let requestedPlatforms: string | null = null;
|
||||
mockServer.use(
|
||||
http.get(baseUrl("/charts/:metric"), ({ params, request }) => {
|
||||
requestedPlatforms = new URL(request.url).searchParams.get("platforms");
|
||||
return HttpResponse.json(
|
||||
generateMockChartResponse(params.metric as string, 30)
|
||||
);
|
||||
})
|
||||
);
|
||||
const render = createCustomRenderer({ withBackendMock: true });
|
||||
render(<ChartCard />);
|
||||
|
||||
// The default platform filter is the four non-mobile platforms, which both
|
||||
// excludes iOS/iPadOS/Android and surfaces the "Filtered" badge on load.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Filtered")).toBeInTheDocument();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(requestedPlatforms).toBe("darwin,windows,linux,chrome");
|
||||
});
|
||||
expect(requestedPlatforms).not.toMatch(/ios|ipados|android/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildInitialChartFilters", () => {
|
||||
|
||||
@@ -48,9 +48,17 @@ const baseClass = "chart-card";
|
||||
// configurable ranges we'll add UI and request-param plumbing for this.
|
||||
const CHART_DAYS = 30;
|
||||
|
||||
// Mobile platforms (iOS, iPadOS, Android) are excluded from the chart by
|
||||
// default by seeding the platform filter with only the non-mobile platforms.
|
||||
// This is intentionally hard-coded to this chart for now rather than a general
|
||||
// per-chart "default filters" config. Because the platform filter is
|
||||
// inclusion-based, a non-empty default both excludes mobile and makes the
|
||||
// "Filtered" badge appear on load. Users can opt mobile back in via the filter.
|
||||
const DEFAULT_CHART_PLATFORMS = ["darwin", "windows", "linux", "chrome"];
|
||||
|
||||
const DEFAULT_CHART_FILTERS: IChartFilterState = {
|
||||
labelIDs: [],
|
||||
platforms: [],
|
||||
platforms: DEFAULT_CHART_PLATFORMS,
|
||||
hostFilterMode: "none",
|
||||
selectedHosts: [],
|
||||
softwareFilters: [...ALL_CVE_SOFTWARE_CATEGORY_VALUES],
|
||||
@@ -238,7 +246,9 @@ const ChartCard = ({
|
||||
given hour.
|
||||
<br />
|
||||
<br />
|
||||
Currently, only macOS, Windows, Linux, and ChromeOS are supported.
|
||||
iOS, iPadOS, and Android hosts are excluded by default; include them
|
||||
in the filter settings. Locked iOS and iPadOS hosts count as online as
|
||||
long as they have power and an internet connection.
|
||||
</>
|
||||
),
|
||||
tooltipFormatter: ({ value }: { value: number }) =>
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import { PLATFORM_OPTIONS } from "./ChartFilterModal";
|
||||
|
||||
describe("ChartFilterModal PLATFORM_OPTIONS", () => {
|
||||
it("offers mobile platforms (iOS, iPadOS, Android) alongside desktop", () => {
|
||||
const values = PLATFORM_OPTIONS.map((o) => o.value);
|
||||
expect(values).toEqual([
|
||||
"darwin",
|
||||
"windows",
|
||||
"linux",
|
||||
"chrome",
|
||||
"ios",
|
||||
"ipados",
|
||||
"android",
|
||||
]);
|
||||
});
|
||||
|
||||
it("labels the mobile platforms for display", () => {
|
||||
const labelFor = (value: string) =>
|
||||
PLATFORM_OPTIONS.find((o) => o.value === value)?.label;
|
||||
expect(labelFor("ios")).toBe("iOS");
|
||||
expect(labelFor("ipados")).toBe("iPadOS");
|
||||
expect(labelFor("android")).toBe("Android");
|
||||
});
|
||||
});
|
||||
@@ -31,11 +31,16 @@ const baseClass = "chart-filter-modal";
|
||||
|
||||
export type ChartFilterTab = "hosts" | "software";
|
||||
|
||||
const PLATFORM_OPTIONS = [
|
||||
// Exported for testing. Mobile platforms (ios/ipados/android) are selectable
|
||||
// here; the chart excludes them by default via ChartCard's DEFAULT_CHART_PLATFORMS.
|
||||
export const PLATFORM_OPTIONS = [
|
||||
{ label: "macOS", value: "darwin" },
|
||||
{ label: "Windows", value: "windows" },
|
||||
{ label: "Linux", value: "linux" },
|
||||
{ label: "ChromeOS", value: "chrome" },
|
||||
{ label: "iOS", value: "ios" },
|
||||
{ label: "iPadOS", value: "ipados" },
|
||||
{ label: "Android", value: "android" },
|
||||
];
|
||||
|
||||
type HostFilterMode = "none" | "include" | "exclude";
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { default } from "./ChartFilterModal";
|
||||
export { default, PLATFORM_OPTIONS } from "./ChartFilterModal";
|
||||
export type { IChartFilterState, ChartFilterTab } from "./ChartFilterModal";
|
||||
|
||||
@@ -71,11 +71,13 @@ type Dataset interface {
|
||||
// method. It is satisfied by the chart internal Datastore, keeping dataset
|
||||
// implementations decoupled from internals.
|
||||
type DatasetStore interface {
|
||||
// FindOnlineHostIDs returns host IDs that are "online right now" per the
|
||||
// product's standard online predicate (host_seen_times.seen_time within
|
||||
// the host's own check-in interval). MDM-only mobile devices (iOS,
|
||||
// iPadOS, Android) are excluded by design — they don't have
|
||||
// host_seen_times rows. Used by datasets like uptime.
|
||||
// FindOnlineHostIDs returns host IDs that are "online right now" using a
|
||||
// platform-specific predicate. Non-mobile (osquery) hosts use the product's
|
||||
// standard online predicate (host_seen_times.seen_time within the host's own
|
||||
// check-in interval). Mobile hosts (iOS, iPadOS, Android), which only check
|
||||
// in via MDM, use their MDM activity signal (nano_enrollments.last_seen_at,
|
||||
// falling back to detail_updated_at) within a fixed mobile online window.
|
||||
// Used by datasets like uptime.
|
||||
FindOnlineHostIDs(ctx context.Context, now time.Time, disabledFleetIDs []uint) ([]uint, error)
|
||||
|
||||
// AffectedHostIDsByCVE returns host IDs grouped by CVE, scoped to the given
|
||||
|
||||
@@ -23,6 +23,20 @@ import (
|
||||
// bounded context must not depend on server/fleet — arch test enforced.
|
||||
const onlineIntervalBufferSeconds = 60
|
||||
|
||||
// mobileOnlineWindowSeconds is the window within which a mobile
|
||||
// (iOS/iPadOS/Android) host's most recent MDM activity signal must fall for it
|
||||
// to count as online. Mobile MDM devices have no osquery check-in interval
|
||||
// (distributed_interval/config_tls_refresh are 0), so instead of a per-host
|
||||
// interval we anchor the window to the iOS/iPadOS refetch cadence (1 hour; see
|
||||
// ListIOSAndIPadOSToRefetch) plus the same grace buffer used for osquery hosts.
|
||||
const mobileOnlineWindowSeconds = 3600 + onlineIntervalBufferSeconds
|
||||
|
||||
// neverTimestamp mirrors server.NeverTimestamp, the sentinel written to
|
||||
// detail_updated_at before a host's first full detail refetch. Duplicated
|
||||
// rather than imported because the chart bounded context must not depend on the
|
||||
// server package — arch test enforced.
|
||||
const neverTimestamp = "2000-01-01 00:00:00"
|
||||
|
||||
// Datastore is the MySQL implementation of the chart datastore.
|
||||
type Datastore struct {
|
||||
primary *sqlx.DB
|
||||
@@ -72,24 +86,56 @@ func (ds *Datastore) GetHostIDsForFilter(ctx context.Context, hostFilter *types.
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// FindOnlineHostIDs returns host IDs that are "online" at `now` per the same
|
||||
// per-host predicate used by the hosts list status=online filter
|
||||
// (filterHostsByStatus in server/datastore/mysql/hosts.go): the host has a
|
||||
// host_seen_times row whose seen_time falls within the host's own check-in
|
||||
// interval (LEAST of distributed_interval and config_tls_refresh) plus the
|
||||
// OnlineIntervalBuffer grace period.
|
||||
// FindOnlineHostIDs returns host IDs that are "online" at `now`, using a
|
||||
// platform-specific predicate:
|
||||
//
|
||||
// Because host_seen_times is updated only by osquery check-ins, MDM-only
|
||||
// mobile devices (iOS, iPadOS, Android) are currently excluded by design.
|
||||
// - Non-mobile (osquery-capable) hosts use the same predicate as the hosts
|
||||
// list status=online filter (filterHostsByStatus in
|
||||
// server/datastore/mysql/hosts.go): a host_seen_times row whose seen_time
|
||||
// falls within the host's own check-in interval (LEAST of
|
||||
// distributed_interval and config_tls_refresh) plus the OnlineIntervalBuffer
|
||||
// grace period.
|
||||
// - Mobile hosts (iOS, iPadOS, Android) have no osquery check-in interval, so
|
||||
// they use their MDM activity signal — the most recent of
|
||||
// nano_enrollments.last_seen_at (bumped on every MDM check-in, and only
|
||||
// considered for enabled enrollments since last_seen_at is also bumped when
|
||||
// an enrollment is disabled on checkout) and
|
||||
// host_seen_times.seen_time, falling back to detail_updated_at (the
|
||||
// neverTimestamp sentinel treated as null) — within mobileOnlineWindowSeconds
|
||||
// of `now`. There is deliberately no created_at fallback: a freshly enrolled
|
||||
// device that never checked in is not "online".
|
||||
func (ds *Datastore) FindOnlineHostIDs(ctx context.Context, now time.Time, disabledFleetIDs []uint) ([]uint, error) {
|
||||
query := fmt.Sprintf(`
|
||||
SELECT h.id
|
||||
FROM hosts h
|
||||
JOIN host_seen_times hst ON h.id = hst.host_id
|
||||
WHERE DATE_ADD(hst.seen_time,
|
||||
INTERVAL LEAST(h.distributed_interval, h.config_tls_refresh) + %d SECOND
|
||||
) > ?`, onlineIntervalBufferSeconds)
|
||||
args := []any{now.UTC()}
|
||||
LEFT JOIN host_seen_times hst ON h.id = hst.host_id
|
||||
LEFT JOIN nano_enrollments ne ON ne.id = h.uuid
|
||||
AND ne.enabled = 1
|
||||
AND ne.type IN ('Device', 'User Enrollment (Device)')
|
||||
WHERE (
|
||||
(
|
||||
h.platform NOT IN ('ios', 'ipados', 'android')
|
||||
AND hst.seen_time IS NOT NULL
|
||||
AND DATE_ADD(hst.seen_time,
|
||||
INTERVAL LEAST(h.distributed_interval, h.config_tls_refresh) + %d SECOND
|
||||
) > ?
|
||||
)
|
||||
OR
|
||||
(
|
||||
h.platform IN ('ios', 'ipados', 'android')
|
||||
AND DATE_ADD(
|
||||
COALESCE(
|
||||
GREATEST(
|
||||
COALESCE(hst.seen_time, ne.last_seen_at),
|
||||
COALESCE(ne.last_seen_at, hst.seen_time)
|
||||
),
|
||||
NULLIF(h.detail_updated_at, ?)
|
||||
),
|
||||
INTERVAL %d SECOND
|
||||
) > ?
|
||||
)
|
||||
)`, onlineIntervalBufferSeconds, mobileOnlineWindowSeconds)
|
||||
args := []any{now.UTC(), neverTimestamp, now.UTC()}
|
||||
|
||||
if len(disabledFleetIDs) > 0 {
|
||||
query += ` AND (h.team_id IS NULL OR h.team_id NOT IN (?))`
|
||||
|
||||
@@ -9,11 +9,12 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestFindOnlineHostIDs covers the per-host online predicate and the
|
||||
// TestFindOnlineHostIDs covers the platform-specific online predicate and the
|
||||
// disabledFleetIDs filter: NULL team_id hosts are always retained, hosts in
|
||||
// disabled fleets are excluded, hosts whose seen_time falls outside their own
|
||||
// check-in interval are excluded, and hosts without a host_seen_times row at
|
||||
// all (mobile devices) are excluded.
|
||||
// disabled fleets are excluded, non-mobile hosts whose seen_time falls outside
|
||||
// their own check-in interval are excluded, and mobile hosts are evaluated via
|
||||
// their MDM activity signal (nano_enrollments.last_seen_at / detail_updated_at)
|
||||
// rather than host_seen_times.
|
||||
func TestFindOnlineHostIDs(t *testing.T) {
|
||||
tdb := testutils.SetupTestDB(t, "chart_mysql")
|
||||
ds := NewDatastore(tdb.Conns(), tdb.Logger)
|
||||
@@ -27,7 +28,14 @@ func TestFindOnlineHostIDs(t *testing.T) {
|
||||
{"MultipleDisabledFleets", testFindOnlineMultipleDisabled},
|
||||
{"NullTeamHostsAlwaysIncluded", testFindOnlineNullTeamRetained},
|
||||
{"OfflineHostsExcluded", testFindOnlineOfflineExcluded},
|
||||
{"MobileHostsWithoutSeenTimeExcluded", testFindOnlineMobileExcluded},
|
||||
{"NonMobileWithoutSeenTimeExcluded", testFindOnlineNonMobileNoSeenTimeExcluded},
|
||||
{"AppleMobileOnlineViaNanoLastSeen", testFindOnlineAppleMobileOnline},
|
||||
{"AppleMobileOfflineWhenNanoStale", testFindOnlineAppleMobileStale},
|
||||
{"AndroidOnlineViaDetailUpdatedAt", testFindOnlineAndroidOnline},
|
||||
{"AndroidOfflineWhenDetailNever", testFindOnlineAndroidNever},
|
||||
{"MobileNeverCheckedInExcluded", testFindOnlineMobileNeverCheckedIn},
|
||||
{"MobileDisabledEnrollmentExcluded", testFindOnlineMobileDisabledEnrollment},
|
||||
{"MobileDisabledFleetExcluded", testFindOnlineMobileDisabledFleet},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
@@ -37,16 +45,32 @@ func TestFindOnlineHostIDs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// hostSeed describes one row to insert into hosts (and optionally host_seen_times).
|
||||
// distributedInterval is in seconds; when 0, the table defaults apply (which means
|
||||
// the effective online window collapses to fleet.OnlineIntervalBuffer alone).
|
||||
// When omitSeenTime is true, no host_seen_times row is inserted — simulating an
|
||||
// MDM-only mobile device.
|
||||
// hostSeed describes one row to insert into hosts (and optionally
|
||||
// host_seen_times / nano_enrollments).
|
||||
//
|
||||
// distributedInterval is in seconds; when 0, the table defaults apply (which
|
||||
// means the effective online window collapses to fleet.OnlineIntervalBuffer
|
||||
// alone). When omitSeenTime is true, no host_seen_times row is inserted —
|
||||
// simulating an MDM-only mobile device.
|
||||
//
|
||||
// platform defaults to "" (treated as a non-mobile/osquery host). Set it to
|
||||
// "ios", "ipados", or "android" to exercise the mobile predicate. For mobile
|
||||
// hosts, nanoLastSeen seeds a nano_enrollments row (the Apple MDM check-in
|
||||
// signal) and detailUpdatedAt overrides hosts.detail_updated_at (the Android
|
||||
// status-report signal); leave either zero to omit it.
|
||||
//
|
||||
// nanoDisabled seeds the nano_enrollments row with enabled = 0 (simulating a
|
||||
// device that checked out, which also bumps last_seen_at). The default is an
|
||||
// enabled enrollment.
|
||||
type hostSeed struct {
|
||||
teamID uint // 0 means NULL
|
||||
seenTime time.Time
|
||||
distributedInterval int
|
||||
omitSeenTime bool
|
||||
platform string
|
||||
nanoLastSeen time.Time
|
||||
nanoDisabled bool
|
||||
detailUpdatedAt time.Time
|
||||
}
|
||||
|
||||
// seedHosts inserts a host per entry and returns the auto-assigned host ids in
|
||||
@@ -74,13 +98,26 @@ func seedHosts(t *testing.T, tdb *testutils.TestDB, entries []hostSeed) []uint {
|
||||
if e.teamID != 0 {
|
||||
teamArg = e.teamID
|
||||
}
|
||||
// detail_updated_at is set to the sentinel so it never spuriously
|
||||
// makes a host look freshly active to anyone reading from hosts.
|
||||
uuid := "uuid-" + itoa(uint(i+1))
|
||||
// detail_updated_at defaults to the sentinel so it never spuriously
|
||||
// makes a host look freshly active; a non-zero detailUpdatedAt (the
|
||||
// Android status-report signal) overrides it.
|
||||
var detailArg any = neverTimestamp
|
||||
if !e.detailUpdatedAt.IsZero() {
|
||||
detailArg = e.detailUpdatedAt
|
||||
}
|
||||
// created_at must be a valid timestamp. Mobile seeds omit seenTime, so
|
||||
// fall back to a recent value — which also exercises that the mobile
|
||||
// predicate does NOT treat a recent created_at as an online signal.
|
||||
createdArg := e.seenTime
|
||||
if createdArg.IsZero() {
|
||||
createdArg = time.Now().UTC()
|
||||
}
|
||||
res, err := tdb.DB.ExecContext(ctx, `
|
||||
INSERT INTO hosts (osquery_host_id, node_key, uuid, hostname, detail_updated_at, created_at, team_id, distributed_interval, config_tls_refresh)
|
||||
VALUES (?, ?, ?, ?, '2000-01-01 00:00:00', ?, ?, ?, ?)
|
||||
`, "ohid-"+itoa(uint(i+1)), "nk-"+itoa(uint(i+1)), "uuid-"+itoa(uint(i+1)),
|
||||
"host-"+itoa(uint(i+1)), e.seenTime, teamArg, e.distributedInterval, e.distributedInterval)
|
||||
INSERT INTO hosts (osquery_host_id, node_key, uuid, hostname, platform, detail_updated_at, created_at, team_id, distributed_interval, config_tls_refresh)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "ohid-"+itoa(uint(i+1)), "nk-"+itoa(uint(i+1)), uuid,
|
||||
"host-"+itoa(uint(i+1)), e.platform, detailArg, createdArg, teamArg, e.distributedInterval, e.distributedInterval)
|
||||
require.NoError(t, err)
|
||||
raw, err := res.LastInsertId()
|
||||
require.NoError(t, err)
|
||||
@@ -91,6 +128,23 @@ func seedHosts(t *testing.T, tdb *testutils.TestDB, entries []hostSeed) []uint {
|
||||
hostID, e.seenTime)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
// Seed the Apple MDM check-in signal (nano_enrollments.last_seen_at),
|
||||
// joined to the host by uuid. nano_enrollments requires a nano_devices
|
||||
// row via FK, so insert that first.
|
||||
if !e.nanoLastSeen.IsZero() {
|
||||
_, err = tdb.DB.ExecContext(ctx,
|
||||
`INSERT INTO nano_devices (id, authenticate) VALUES (?, ?)`, uuid, "auth")
|
||||
require.NoError(t, err)
|
||||
enabled := 1
|
||||
if e.nanoDisabled {
|
||||
enabled = 0
|
||||
}
|
||||
_, err = tdb.DB.ExecContext(ctx, `
|
||||
INSERT INTO nano_enrollments (id, device_id, type, topic, push_magic, token_hex, last_seen_at, enabled)
|
||||
VALUES (?, ?, 'Device', 'topic', 'magic', 'hex', ?, ?)`,
|
||||
uuid, uuid, e.nanoLastSeen, enabled)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
ids = append(ids, hostID)
|
||||
}
|
||||
return ids
|
||||
@@ -192,18 +246,132 @@ func testFindOnlineOfflineExcluded(t *testing.T, tdb *testutils.TestDB, ds *Data
|
||||
assert.ElementsMatch(t, []uint{ids[0]}, got)
|
||||
}
|
||||
|
||||
func testFindOnlineMobileExcluded(t *testing.T, tdb *testutils.TestDB, ds *Datastore) {
|
||||
func testFindOnlineNonMobileNoSeenTimeExcluded(t *testing.T, tdb *testutils.TestDB, ds *Datastore) {
|
||||
ctx := t.Context()
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
// Host 0 is an osquery host, online. Host 1 has no host_seen_times row —
|
||||
// representing an MDM-only mobile device — and must be excluded by the
|
||||
// INNER JOIN regardless of how recently it might have checked in via MDM.
|
||||
// Host 0 is an osquery host, online. Host 1 is a non-mobile (darwin) host
|
||||
// with no host_seen_times row — the non-mobile branch requires a seen_time,
|
||||
// so it's excluded. The mobile branch (which would consult MDM signals)
|
||||
// must not rescue a non-mobile platform.
|
||||
ids := seedHosts(t, tdb, []hostSeed{
|
||||
{teamID: 1, seenTime: onlineSeen(now), distributedInterval: defaultInterval}, // 0: osquery online
|
||||
{teamID: 1, seenTime: now, omitSeenTime: true}, // 1: mobile (no hst row)
|
||||
{teamID: 1, seenTime: onlineSeen(now), distributedInterval: defaultInterval, platform: "darwin"}, // 0: osquery online
|
||||
{teamID: 1, platform: "darwin", omitSeenTime: true, nanoLastSeen: now}, // 1: no hst row, has nano signal but not mobile
|
||||
})
|
||||
|
||||
got, err := ds.FindOnlineHostIDs(ctx, now, nil)
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []uint{ids[0]}, got)
|
||||
}
|
||||
|
||||
// mobileRecent / mobileStale are activity-signal timestamps relative to the
|
||||
// mobile online window (mobileOnlineWindowSeconds ≈ 61 minutes).
|
||||
func mobileRecent(now time.Time) time.Time { return now.Add(-5 * time.Minute) }
|
||||
func mobileStale(now time.Time) time.Time { return now.Add(-2 * time.Hour) }
|
||||
|
||||
func testFindOnlineAppleMobileOnline(t *testing.T, tdb *testutils.TestDB, ds *Datastore) {
|
||||
ctx := t.Context()
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
// iOS and iPadOS hosts with a recent nano_enrollments.last_seen_at and no
|
||||
// host_seen_times row are online via the MDM signal.
|
||||
ids := seedHosts(t, tdb, []hostSeed{
|
||||
{teamID: 1, platform: "ios", omitSeenTime: true, nanoLastSeen: mobileRecent(now)}, // 0: online
|
||||
{teamID: 1, platform: "ipados", omitSeenTime: true, nanoLastSeen: mobileRecent(now)}, // 1: online
|
||||
})
|
||||
|
||||
got, err := ds.FindOnlineHostIDs(ctx, now, nil)
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []uint{ids[0], ids[1]}, got)
|
||||
}
|
||||
|
||||
func testFindOnlineAppleMobileStale(t *testing.T, tdb *testutils.TestDB, ds *Datastore) {
|
||||
ctx := t.Context()
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
// iOS host whose last MDM check-in is older than the mobile window is offline.
|
||||
ids := seedHosts(t, tdb, []hostSeed{
|
||||
{teamID: 1, platform: "ios", omitSeenTime: true, nanoLastSeen: mobileRecent(now)}, // 0: online
|
||||
{teamID: 1, platform: "ios", omitSeenTime: true, nanoLastSeen: mobileStale(now)}, // 1: offline (stale)
|
||||
})
|
||||
|
||||
got, err := ds.FindOnlineHostIDs(ctx, now, nil)
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []uint{ids[0]}, got)
|
||||
}
|
||||
|
||||
func testFindOnlineAndroidOnline(t *testing.T, tdb *testutils.TestDB, ds *Datastore) {
|
||||
ctx := t.Context()
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
// Android has no nano_enrollments row; its signal is detail_updated_at
|
||||
// (written on status reports). A recent value within the window is online;
|
||||
// a stale one is offline.
|
||||
ids := seedHosts(t, tdb, []hostSeed{
|
||||
{teamID: 1, platform: "android", omitSeenTime: true, detailUpdatedAt: mobileRecent(now)}, // 0: online
|
||||
{teamID: 1, platform: "android", omitSeenTime: true, detailUpdatedAt: mobileStale(now)}, // 1: offline
|
||||
})
|
||||
|
||||
got, err := ds.FindOnlineHostIDs(ctx, now, nil)
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []uint{ids[0]}, got)
|
||||
}
|
||||
|
||||
func testFindOnlineAndroidNever(t *testing.T, tdb *testutils.TestDB, ds *Datastore) {
|
||||
ctx := t.Context()
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
// Android host whose detail_updated_at is still the NeverTimestamp sentinel
|
||||
// (no status report yet) and has no nano signal is offline — NULLIF drops
|
||||
// the sentinel and there is no created_at fallback.
|
||||
ids := seedHosts(t, tdb, []hostSeed{
|
||||
{teamID: 1, platform: "android", omitSeenTime: true, detailUpdatedAt: mobileRecent(now)}, // 0: online
|
||||
{teamID: 1, platform: "android", omitSeenTime: true}, // 1: sentinel detail, offline
|
||||
})
|
||||
|
||||
got, err := ds.FindOnlineHostIDs(ctx, now, nil)
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []uint{ids[0]}, got)
|
||||
}
|
||||
|
||||
func testFindOnlineMobileNeverCheckedIn(t *testing.T, tdb *testutils.TestDB, ds *Datastore) {
|
||||
ctx := t.Context()
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
// A freshly enrolled iOS host with no MDM signal at all (sentinel
|
||||
// detail_updated_at, no nano row, no seen_time) must NOT be online — the
|
||||
// mobile predicate deliberately has no created_at fallback.
|
||||
seedHosts(t, tdb, []hostSeed{
|
||||
{teamID: 1, platform: "ios", omitSeenTime: true}, // 0: never checked in
|
||||
})
|
||||
|
||||
got, err := ds.FindOnlineHostIDs(ctx, now, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, got)
|
||||
}
|
||||
|
||||
func testFindOnlineMobileDisabledEnrollment(t *testing.T, tdb *testutils.TestDB, ds *Datastore) {
|
||||
ctx := t.Context()
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
// Disabling an enrollment (e.g. on checkout) sets nano_enrollments.enabled = 0
|
||||
// AND bumps last_seen_at to CURRENT_TIMESTAMP. The predicate only joins
|
||||
// enabled enrollments, so a device that just checked out must NOT count as
|
||||
// online even though its last_seen_at is recent.
|
||||
ids := seedHosts(t, tdb, []hostSeed{
|
||||
{teamID: 1, platform: "ios", omitSeenTime: true, nanoLastSeen: mobileRecent(now)}, // 0: online (enabled)
|
||||
{teamID: 1, platform: "ios", omitSeenTime: true, nanoLastSeen: mobileRecent(now), nanoDisabled: true}, // 1: offline (disabled, last_seen_at bumped on checkout)
|
||||
})
|
||||
|
||||
got, err := ds.FindOnlineHostIDs(ctx, now, nil)
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []uint{ids[0]}, got)
|
||||
}
|
||||
|
||||
func testFindOnlineMobileDisabledFleet(t *testing.T, tdb *testutils.TestDB, ds *Datastore) {
|
||||
ctx := t.Context()
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
// The disabled-fleet exclusion applies to mobile hosts too: the iOS host in
|
||||
// fleet 1 is dropped while the NULL-team iOS host is retained.
|
||||
ids := seedHosts(t, tdb, []hostSeed{
|
||||
{teamID: 1, platform: "ios", omitSeenTime: true, nanoLastSeen: mobileRecent(now)}, // 0: excluded (disabled fleet)
|
||||
{teamID: 0, platform: "ios", omitSeenTime: true, nanoLastSeen: mobileRecent(now)}, // 1: kept (NULL team)
|
||||
})
|
||||
|
||||
got, err := ds.FindOnlineHostIDs(ctx, now, []uint{1})
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []uint{ids[1]}, got)
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ func (tdb *TestDB) Conns() *common_mysql.DBConnections {
|
||||
func (tdb *TestDB) TruncateTables(t *testing.T) {
|
||||
t.Helper()
|
||||
mysql_testing_utils.TruncateTables(t, tdb.DB, tdb.Logger, nil,
|
||||
"host_scd_data", "hosts", "host_seen_times", "nano_enrollments", "teams",
|
||||
"host_scd_data", "hosts", "host_seen_times", "nano_devices", "nano_enrollments", "teams",
|
||||
"software", "software_cve", "cve_meta", "operating_system_vulnerabilities")
|
||||
}
|
||||
|
||||
|
||||
@@ -48,13 +48,15 @@ type CVEChartFilter struct {
|
||||
|
||||
// Datastore is the internal datastore interface for the chart bounded context.
|
||||
type Datastore interface {
|
||||
// FindOnlineHostIDs returns host IDs that are "online right now" per the
|
||||
// product's standard online predicate: host_seen_times.seen_time falls
|
||||
// within the host's own check-in interval (LEAST of distributed_interval
|
||||
// and config_tls_refresh, plus a 60-second grace period that mirrors
|
||||
// fleet.OnlineIntervalBuffer). Hosts without a host_seen_times row —
|
||||
// iOS, iPadOS, and Android devices, which only check in via MDM — are
|
||||
// excluded. Used by datasets like uptime.
|
||||
// FindOnlineHostIDs returns host IDs that are "online right now" using a
|
||||
// platform-specific predicate. Non-mobile (osquery) hosts use the product's
|
||||
// standard online predicate: host_seen_times.seen_time within the host's own
|
||||
// check-in interval (LEAST of distributed_interval and config_tls_refresh,
|
||||
// plus a 60-second grace period that mirrors fleet.OnlineIntervalBuffer).
|
||||
// Mobile hosts (iOS, iPadOS, Android), which only check in via MDM, use
|
||||
// their MDM activity signal (nano_enrollments.last_seen_at, falling back to
|
||||
// detail_updated_at) within a fixed mobile online window. Used by datasets
|
||||
// like uptime.
|
||||
FindOnlineHostIDs(ctx context.Context, now time.Time, disabledFleetIDs []uint) ([]uint, error)
|
||||
|
||||
// AffectedHostIDsByCVE returns host IDs grouped by CVE, scoped to the given
|
||||
|
||||
Reference in New Issue
Block a user