Compare commits

...

12 Commits

Author SHA1 Message Date
Oliver Kieselbach 977c5fb447 fix(web): headroom + symmetric insets for network-timeline marker labels
A left-edge "Clock -3h 0m" label collided with the HTML legend above the
band (first live fleet session d0c5b672). Marker-label headroom grows to
48 units so long labels stay inside the SVG, and the drawing content is
now inset 64 units on BOTH sides - the left inset mirrors the existing
right one, giving edge labels room and a calmer, symmetric frame.

Verified against the live session carrying the first system_clock_changed
event (backfilled step -> marker only, no rebase, as designed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 20:14:29 +02:00
Oliver Kieselbach 27fdd19568 feat(agent): optional Delivery Optimization GroupId from network fingerprint
EnableDoGroupIdAutoSet (ConfigVersion 40, default off): the agent seeds the
DOGroupId policy value with a deterministic GUID derived from the default
gateway's IPv4 + SendARP MAC, RealmJoin-compatible byte layout so mixed
fleets peer together. Foreign DOGroupId/DOGroupIdSource policies always win
(ownership tracked via StartupEventGate fingerprint); no random-GUID
fallback. New do_group_id_auto_set event, portal toggle, admin report row,
manifest/catalog sync, OKF doc. Gateway heuristic deduplicated into
NetworkInterfaceLocator.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 20:09:45 +02:00
Oliver Kieselbach 72f7adcb3d feat(web): clock-era axis + sleep segments in network timeline
Consume the two new agent events:

- system_clock_changed: live-observed steps (timeDeltaMs authoritative,
  >=30s; sub-30s NTP nudges ignored) split the stream into clock eras and
  rebase every later timestamp by the cumulative delta, so the axis shows
  true elapsed time. Backfilled steps (pre-agent, replayed from the event
  log) are marked but never rebased. Each step gets a slanted "Clock +1h"
  lifecycle marker with old->new time and reason in the tooltip.
- system_sleep_episode: episodes (emitted retroactively at wake) carve an
  "Asleep" segment out of the active network instead of attributing the
  quiet period to it; tooltip carries kind (Modern Standby/sleep), wake
  source and AC/battery. Episode boundaries get the same era correction
  as the event that carried them.

Stats row gains "Asleep total" and "Clock changes"; the event list
includes both new types (now titled "Network & system events").

Layout: drawing content stops 64 units short of the right viewBox edge
(CONTENT_W), so right-edge lifecycle labels slant up-right like all
others instead of flipping left.

7 new vitest cases pin era rebasing (live/backfilled/nudge), sleep
overlay incl. era-corrected boundaries, and hotspot confidence.

Live verification against fleet data pending - the emitting agent was
released minutes ago; no session carries the events yet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 19:57:19 +02:00
Oliver Kieselbach 06cf1c4c12 feat(agent): system timeline watcher — clock steps + sleep episodes as ground truth
Two enrollment blind spots become timeline ground truth from the System event
log via one EventLogWatcher (WindowsUpdateTracker pattern, 24h backfill with
RecordId watermark recovering pre-agent records):

- system_clock_changed (Kernel-General 1): oldTime/newTime/signed timeDeltaMs,
  reason and the setting process. 2s floor suppresses w32time 1ms micro-slews;
  steps >= 5min are Warning + immediate upload. Payload instants are the
  authoritative source (event timestamps can be clamped server-side).
- system_sleep_episode (Power-Troubleshooter 1 classic sleep/hibernate,
  Kernel-Power 507 Modern Standby; 506 deliberately unconsumed): kind,
  enteredAt/exitedAt, durationSeconds, wake source / AC state. 60s real-sleep
  floor filters Modern Standby screen-off scenarios.

ConfigVersion 39 (Collectors.EnableSystemTimelineWatcher +
SystemEventBackfillLookbackMinutes). Both types are non-activity on agent and
backend (idle/stall clocks unaffected; a sleeping device stays Stalled).

Consumers: TimeJumpBadge gains the ground-truth cause "clock-set"
(classifyTimeJump matches backward display steps against recorded clock sets);
EventTimeline badge blocks for both events plus the previously bare
power_state_change; SessionInfoCard keeps the wall-clock duration and adds a
standby note; TimeAttributionCalculator builds SleepSpans (RebootSpan clone,
AttributionVersion 3) persisted as SleepSeconds/SleepSpansJson and rendered in
TimeAttributionLane; ANALYZE-DEV-012 (info, single) fires once per session for
episodes >= 5min - deliberately the only new rule, clock findings stay with
DEV-008 to avoid duplicate-finding flood.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 19:41:56 +02:00
Oliver Kieselbach e612c69178 feat(web): feature-form default for the grant command + docs link
The generated PowerShell command now uses the readable -Features form
(first catalog feature as the example) instead of raw permission strings;
a note below lists all available values incl. the All meta-feature and
how to fall back to -Permissions via Copy permissions. The section links
to the Optional Graph Permissions customer docs (via DOCS_URL registry),
and the "What this script does" bullet reflects the Cloud Shell
ambient-identity sign-in. Script help no longer claims the admin UI
generates the -Permissions form.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 19:27:51 +02:00
Oliver Kieselbach 829a841d9b feat(web): slanted inline labels for network-timeline lifecycle markers
The gray lifecycle dots (Reboot / Desktop / Completed) were hard to hit
and mute without hovering. Their label is now written directly at the dot,
slanted up-right (flipping up-left near the right edge so it stays inside
the drawing), with the band shifted down to make headroom. The whole
marker group (line, dot, label) shares an enlarged invisible hit target,
and a label is suppressed when its neighbor sits too close - the tooltip
still carries the details.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 19:09:03 +02:00
Oliver Kieselbach 10eedded13 feat(web): confidence tier for network-timeline hotspot badge
An SSID matching the typical iPhone hotspot name (".. iPhone") is strong
evidence on its own — such networks now get a plain "Hotspot" badge, while
subnet-only evidence keeps the hedged "Hotspot?". Tooltip and footnote
explain the distinction; detection also fires on iPhone-named SSIDs
outside the Apple hotspot subnet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 19:00:56 +02:00
Oliver Kieselbach a9f6ccd86d feat(web): power + network status chips on the progress portal
Taskbar-style chips in the progress page status header showing the
device's last-reported power state (AC/battery with percent, low-battery
warning) and network state (WiFi with SSID tooltip + signal tiers,
Ethernet, offline). Derived purely client-side by folding the event
stream the portal already downloads (power_state_check/change,
network_interface_info, network_state_change, wifi_signal_info) - no
backend, API, or auth change. Chips render only while InProgress and
disappear gracefully for sessions from agents without these events;
devices without a battery show no power chip (Windows-tray parity).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 18:56:04 +02:00
Oliver Kieselbach 7ba67df1a1 Agent instructions 2026-08-21 18:39:35 +02:00
Oliver Kieselbach e214e8dc1a feat(scripts): -Features All meta-value grants every optional permission at once
All is a script-side convenience that expands to every GraphFeatureCatalog
feature (works with -Revoke too). The catalog sync test now pins All in the
ValidateSet while keeping it out of the catalog comparison, and the admin UI
mentions the shorthand next to the generated grant command.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 18:32:48 +02:00
Oliver Kieselbach f8d0ccd2f5 feat(web): per-session network timeline page + device-details deep link
New /sessions/network-timeline?id=&tenantId= page visualizing every network
the device was on during enrollment: one colored segment per network
(Ethernet / WiFi SSIDs, fixed-order validated palette), hatched offline
gaps, ESP phase context lane, lifecycle + connectivity-check markers with
hover/tap tooltips, stats row and the raw network event list.

Segment model derives from network_interface_info + network_state_change
(before/after), enriches SSIDs from wifi_signal_info, infers missing
post-reconnect SSIDs via matching gateway, and flags probable smartphone
hotspots from vendor-default subnets (Apple 172.20.10.x, Windows
192.168.137.x, legacy Android 192.168.43.x) - always as a heuristic badge.
Axis is honest: linear per segment, minimum width for short segments,
boundary ticks show real times. Mobile scrolls horizontally.

Entry point: "Network timeline" deep link in DeviceDetailsCard's Network
section via new networkTimelineUrl() builder. Route registered in the SWA
config (exact routes before the /sessions/* wildcard, incl. the index.txt
RSC route) and excluded from LegacyPathRedirect's /sessions/{id} rewrite
via the new static-siblings set (test pinned).

Planned follow-up once the agent emits them: clock-change events (rebase
axis into clock eras) and standby/Modern-Standby events (render sleep as
its own segment kind).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 18:26:49 +02:00
Oliver Kieselbach c9701301ea feat(scripts): Cloud Shell ambient-identity sign-in for Grant-AutopilotMonitorAddOn
Try Connect-MgGraph -Identity first (Azure Cloud Shell issues a delegated
token for the signed-in admin, avoiding the device-code flow that
Conditional Access commonly blocks), probe Graph readability, and fall
back to interactive sign-in with explicit scopes otherwise. The
AppRoleAssignment.ReadWrite.All scope guard now applies only to the
interactive path, since ambient-identity tokens carry a fixed scope set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 18:24:31 +02:00
77 changed files with 5159 additions and 103 deletions
+113
View File
@@ -0,0 +1,113 @@
---
type: Concept
title: DO GroupId Auto-Set — Delivery Optimization peering group from a network fingerprint
description: EnableDoGroupIdAutoSet (ConfigVersion 40) makes the agent set the DOGroupId policy value to a deterministic GUID derived from the default gateway's IPv4 address and MAC (RealmJoin-compatible byte layout), so devices on the same local network form one DO peering group during enrollment; foreign DOGroupId/DOGroupIdSource policies always win.
resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/DoGroupIdService.cs
tags:
- agent
- delivery-optimization
- network
- tenant-configuration
timestamp: 2026-08-21T00:00:00+02:00
---
# DO GroupId Auto-Set
## Schema
### The problem
With Delivery Optimization download mode Group (2), peering is scoped by GroupId. When no
GroupId is configured, Windows derives it from AD site → authenticated domain SID → Entra
tenant ID — for a cloud-native tenant that means **one tenant-wide group**, so "peers on
the same network" degrades to "peers anywhere in the tenant" and DO's cloud service has to
match them by public IP. A GroupId derived from the local network makes the group exactly
"devices behind the same gateway" — including branch offices behind NAT. RealmJoin ships
this as its network-fingerprint GroupId; our agent is on the device even earlier (during
ESP, when the big app downloads actually happen), so it can seed the same value for
Autopilot enrollments.
### The setting
`TenantConfiguration.EnableDoGroupIdAutoSet` (`bool?`, default null→false), served as
`AgentConfigResponse.EnableDoGroupIdAutoSet` — **ConfigVersion 40**. Portal: Settings →
Agent → Settings, toggle "Set Delivery Optimization Group ID", stored via the usual
tenant-config roundtrip (`TableConfigRepository` Store+Map). Also shown in the admin
tenant-config report. `RemoteConfigMerger` maps the bool 1:1; it survives the offline
config cache like every plain feature flag.
The agent does NOT configure `DODownloadMode` — the fingerprint GroupId only has an
effect when the tenant already deploys download mode Group (2) via Intune/GPO.
### The fingerprint
`DoGroupIdService.ComputeGroupId(ipv4Bytes, macBytes)` builds a 16-byte GUID:
```
{101,48,6} + {0,0,0} + {ip[1], 0, ip[2], ip[3]} + mac[0..5] → new Guid(byte[16])
```
Gateway discovery reuses the shared `NetworkInterfaceLocator` heuristic (Up, not
Loopback/Tunnel, non-0.0.0.0 gateway; IPv4 gateway required), the gateway MAC comes from
`ArpNativeMethods` (`iphlpapi!SendARP`). The byte layout — including the IP shuffle and
the zero padding — is **RealmJoin-compatible and must never change**: devices managed by
RealmJoin on the same network compute the identical GUID and peer with ours. The registry
value is written as `Guid.ToString()` (braceless lowercase), identical to RealmJoin's
`SetValue(name, Guid)` string form. Deliberate divergence from RealmJoin: **no
random-GUID fallback** — a random per-device ID would isolate the device in a one-member
group, which is worse than the OS default grouping, so no gateway/no MAC means skip.
### Ownership + conflict rules (never fight a foreign policy)
Target: `HKLM\SOFTWARE\Policies\Microsoft\Windows\DeliveryOptimization\DOGroupId`
(64-bit registry view — the Policies store is WOW64-redirected; key paths reused from
`DoThrottlePolicyReader`). Only this GPO-store value is written; the legacy pre-1607
`GroupId` value name is intentionally not.
Skip conditions (Info event with `skipReason`, nothing written):
| skipReason | Meaning |
|---|---|
| `no_ipv4_gateway` | No active interface with an IPv4 default gateway |
| `arp_failed` | Gateway MAC could not be resolved via SendARP |
| `group_id_source_policy` | `DOGroupIdSource` configured in GPO or MDM store (dynamic group source wins) |
| `mdm_policy_present` | `DOGroupId` managed via the Policy CSP store (Intune owns it) |
| `foreign_value_present` | Existing GPO value neither matches the computed GUID nor the one we last wrote |
Ownership is tracked through the `StartupEventGate` fingerprint for the
`do_group_id_auto_set` key: `written:<guid>` records the GUID we last wrote, so after a
gateway change the agent recognizes its own stale value and updates it — while a value
written by anyone else is never overwritten (`DecideAction`: empty→write, equal→no-op,
ours→update, else→skip; unparseable existing values count as foreign). Every write is
verified by readback.
### The event
One `do_group_id_auto_set` event per outcome change (Source `StartupEnvironmentProbes`,
gated on the fingerprint — unchanged reruns across reboots stay silent; errors never
latch, so each failing start keeps its retry trail). Severity: Info for
success/skip, Warning for errors. Data: `groupId`, `previousValue`, `gatewayIp`,
`gatewayMac`, `written`, `skipped`, `skipReason`, `success`, `error`.
## Examples
First boot on a network with gateway `192.168.178.1` / MAC `00-11-22-33-44-55`:
```
do_group_id_auto_set (Info): Delivery Optimization GroupId set to
00063065-0000-00a8-b201-001122334455 (gateway 192.168.178.1)
```
Tenant already deploys a DO GroupId via Intune:
```
do_group_id_auto_set (Info): Delivery Optimization GroupId not set: mdm_policy_present
```
## Citations
* Service (fingerprint, ownership, write+readback): `../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/DoGroupIdService.cs`
* ARP interop: `../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/ArpNativeMethods.cs`
* Probe wiring + event + fingerprint dedup: `../../src/Agent/AutopilotMonitor.Agent.V2.Core/Runtime/StartupEnvironmentProbes.cs`
* Backend serve + ConfigVersion: `../../src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs`
* DO policy stores (key paths, Registry64 rationale): `../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/DoThrottlePolicyReader.cs`
+2
View File
@@ -9,5 +9,7 @@ How the on-device agent works:
* [Autopilot ZTD Diagnostics](autopilot-ztd-diagnostics.md) - Windows' own diagnostic surfaces for the profile-download flow: ModernDeployment event IDs (807/815/164/...), the Diagnostics\Autopilot registry key, deployment-service endpoints, and the known-issue error-code map.
* [MDM Reboot Coalescing](mdm-reboot-coalescing.md) - attributing the mid-ESP coalesced reboot ("second sign-in") to the device-assigned policy URIs that forced it: aggregated 2800 observations (neutral, flush-time watermark) + ANALYZE-ESP-005 gated on an actually-observed reboot.
* [Power-State Watcher](power-state-watcher.md) - live AC↔battery transitions and 50/30/15% downward threshold crossings via a WMI push subscription (no polling): power_state_change semantics (latch/debounce/cap, never armed without a battery) and the single-condition analyze rules ANALYZE-DEV-009/-010 on top.
* [DO GroupId Auto-Set](do-group-id-auto-set.md) - the optional Delivery Optimization peering-group seed: deterministic GUID from default gateway IPv4 + MAC (RealmJoin-compatible), written to the DOGroupId policy value with readback, ownership-tracked so foreign Intune/GPO values are never overwritten; skip reasons and the do_group_id_auto_set event.
* [System Timeline Watcher](system-timeline-watcher.md) - clock steps (system_clock_changed: oldTime/newTime/timeDeltaMs/process, micro-slew floor) and completed sleep episodes (system_sleep_episode: sleep/hibernate/Modern Standby with authoritative enteredAt/exitedAt) from one System-channel EventLogWatcher with pre-agent backfill; feeds the web clock-set jump cause, the standby duration note, time-attribution SleepSpans and ANALYZE-DEV-012.
Reading order for newcomers: overview → decision engine → persistence.
+110
View File
@@ -0,0 +1,110 @@
---
type: Concept
title: System Timeline Watcher — clock steps and sleep episodes from the System event log
description: How the agent turns two enrollment blind spots into timeline ground truth via one EventLogWatcher on the System channel — system_clock_changed (Kernel-General 1, oldTime/newTime/timeDeltaMs/process) and system_sleep_episode (Power-Troubleshooter 1 classic sleep/hibernate, Kernel-Power 507 Modern Standby) — including backfill of pre-agent records, the payload-times-are-authoritative rule, SleepSpans in time attribution, the web clock-set jump cause, and ANALYZE-DEV-012.
resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/SystemTimelineTracker.cs
tags:
- agent
- eventlog
- time
- clock-skew
- standby
- collector
- time-attribution
- analyze-rules
timestamp: 2026-08-21T00:00:00+02:00
---
# System Timeline Watcher
Two things silently distort every session timeline and its wall-clock duration:
1. **The OS clock gets stepped** — classically w32time correcting a wrong BIOS clock early
in OOBE, but also tzutil/MDM actions. Before this watcher the jump was only *inferred*
(TimeJumpBadge, `clock_skew`/DEV-008); Windows records the ground truth.
2. **The device sleeps mid-enrollment** — the timeline shows an unexplained gap, the
duration looks inflated, and nothing says "the device was simply asleep for 56 minutes".
`SystemTimelineTracker` (hosted by `SystemTimelineWatcherHost`, config flag
`Collectors.EnableSystemTimelineWatcher`, ConfigVersion 39) subscribes to the **System**
channel with one provider-qualified XPath and forwards both facts as events.
# Sources and events
| Source (System channel) | Emitted event | Key payload |
|---|---|---|
| `Microsoft-Windows-Kernel-General` EventID 1 | `system_clock_changed` | `oldTime`, `newTime` (ISO-8601), signed `timeDeltaMs`, `reason`/`reasonText` (1=`application_set`, 2=`hardware_clock_sync`), `processName`/`processId` |
| `Microsoft-Windows-Power-Troubleshooter` EventID 1 | `system_sleep_episode` | `kind`=`sleep`/`hibernate` (EffectiveState 5 ⇒ hibernate), `enteredAt`=SleepTime, `exitedAt`=WakeTime, `durationSeconds`, `wakeSourceType`/`wakeSourceText` |
| `Microsoft-Windows-Kernel-Power` EventID 507 | `system_sleep_episode` | `kind`=`modern_standby`, `exitedAt`=TimeCreated, `enteredAt`=exit − DurationInUs, `durationSeconds` (scenario), `sleepDurationSeconds` (real sleep), `reason`, `onAcPower`, `batteryRemainingCapacityOnExit` |
**506 is deliberately not consumed.** 507 carries the full episode; pairing 506/507 would
need an orphan-tolerant state machine across agent restarts for zero information gain — an
episode without a 507 never completed and would not be emitted under any design.
# Thresholds (constants, not knobs)
- `MinClockDeltaMs = 2000` — w32time logs Kernel-General 1 for routine **1 ms micro-slews**
(verified live, several per hour); only genuine steps are emitted. Suppressed records are
still claimed by the dedup watermark.
- `ClockDeltaWarningMs = 5 min` — larger steps are Warning + immediate upload (can break
token/cert validation windows; matches the web TimeJumpBadge threshold).
- `MinSleepDurationSeconds = 60` — **Modern Standby only**: every screen-off scenario logs a
506/507 pair, including seconds-long ones with `SleepDurationInUs 0` (`SleepEntered=false`);
the single duration gate suppresses both. Classic Power-Troubleshooter episodes have no
floor — they only exist for genuinely completed S3/S4 transitions.
# Payload times are authoritative
The decisive clock correction happens exactly when the device clock is untrustworthy, and
the backend clamps implausible event timestamps (±168 h/24 h) to server time. Every semantic
instant therefore travels as an explicit ISO-8601 payload field (`oldTime`/`newTime`,
`enteredAt`/`exitedAt`); consumers must never read the event's own `Timestamp` for these.
The event `Timestamp` is set to the record's TimeCreated (WindowsUpdateTracker backfill
convention) so rows land where the wake/step happened.
# Backfill and dedup
Template: `WindowsUpdateTracker`. `LoadWatermark → arm watcher → backfill` with lookback
`SystemEventBackfillLookbackMinutes` (default 1440 = 24 h; 0 disables). The System .evtx
persists across OOBE reboots, so the pre-agent w32time correction and pre-agent standby
episodes are recovered with `backfilled=true`. Cross-restart dedup via RecordId watermark
(`system-timeline-watermark.json` in the state directory) plus an intra-run `HashSet` —
deliberately **not** a high-water mark, which would drop exactly the older, never-emitted
pre-agent records (the live watcher arms before the backfill scan).
# Non-activity classification (both sides)
Both event types are environment observation, not enrollment progress:
`SignalActivityClassifier.NonActivityEventTypes` (agent — must not reset idle/stall clocks)
and `EventIngestProcessor.IsPeriodicOrStallEvent` (backend — a device that slept through its
stall must stay Stalled).
# Consumers
- **Web timeline** (`EventTimeline.tsx`): badge blocks for both events; the TimeJumpBadge
gains the ground-truth cause `clock-set` — `classifyTimeJump` receives the session's
`system_clock_changed` deltas and matches a backward display step against a backward clock
set within max(60 s, 20 %) (`lib/timeProvenance.ts`).
- **Duration story** (`SessionInfoCard` via `useSessionDerivedData.standbySeconds`): the
wall-clock duration deliberately keeps the pause (the enrollment really took that long);
a `· 🌙 56m standby` note explains it. Episodes are dedup'd on `enteredAt`.
- **Time attribution** (`TimeAttributionCalculator.BuildSleepSpans`, AttributionVersion 3):
`SleepSpan`s mirror `RebootSpan`s — cross-cutting annotations clipped to the observation
windows (a WhiteGlove-pause episode contributes only its in-window flanks), never a slice
of the wall-clock partition. Persisted as `SleepSeconds`/`SleepSpansJson`; rendered as the
standby chip in `TimeAttributionLane`.
- **ANALYZE-DEV-012** (info, `trigger: single`, `on_event:system_sleep_episode`): fires once
per session for episodes ≥ 5 min. Deliberately the ONLY new rule: clock issues already
have DEV-008 (`clock_skew`) — a second clock rule would double-report the same action item
(user decision 2026-08-21); clock steps stay timeline ground truth with badges instead.
# Citations
- `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/SystemTimelineTracker.cs`
- `src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/SystemTimelineWatcherHost.cs`
- `src/Backend/AutopilotMonitor.Functions/Helpers/TimeAttributionCalculator.cs` (`BuildSleepSpans`)
- `src/Web/autopilot-monitor-web/lib/timeProvenance.ts` (`clock-set`, `findExplainingClockChange`)
- `rules/analyze/ANALYZE-DEV-012.json`
- Live payload evidence (2026-08-21, Win11 26220): Kernel-General 1 with `TimeDeltaInMs=1`
micro-slews; Kernel-Power 507 with `SleepDurationInUs=2324095807`; Power-Troubleshooter 1
with `SleepTime`/`WakeTime` across a hibernate.
+2
View File
@@ -31,6 +31,8 @@ https://docs.autopilotmonitor.com (separate repository).
* [Registry App-State Second Pillar](agent/registry-app-state-pillar.md) - IME's authoritative per-app state read straight from the registry (Win32Apps EnforcementStateMessage, EspTrackingWin32Apps, StatusServiceReports) via a snapshot-and-diff watcher; registry/log reconciliation doubles as the built-in IME log-pattern drift alarm.
* [WDP Scenario Gates & Completion Backstop](agent/wdp-scenario-gates.md) - How the agent gates Autopilot/ESP-only machinery off on Windows Device Preparation sessions (deterministic ExecutionContext marker, fail-toward-Classic), which noise it suppresses (ZTD probe, esp_config unknown, ModernDeployment 100/417/418/1005, starved sweep), and the WDP completion model: High-confidence seed, desktop-based arm D, and the 30-min device_prep_completion backstop.
* [Power-State Watcher](agent/power-state-watcher.md) - Live AC↔battery transitions and 50/30/15% downward battery-threshold crossings during enrollment via a WMI Win32_PowerManagementEvent push subscription (zero polling, never armed on battery-less devices): the power_state_change event (transition field, per-level lifetime latch, 5s debounce, emission cap) and the analyze rules ANALYZE-DEV-009 (battery <15%, high) / ANALYZE-DEV-010 (AC→battery, warning) with on_event interim triggers.
* [System Timeline Watcher](agent/system-timeline-watcher.md) - Ground truth for two timeline blind spots from one System-channel EventLogWatcher with pre-agent backfill: system_clock_changed (Kernel-General 1 — oldTime/newTime/signed timeDeltaMs/reason/setting process; 2 s micro-slew floor, ≥5 min Warning+immediate) and system_sleep_episode (Power-Troubleshooter 1 sleep/hibernate + Kernel-Power 507 Modern Standby; authoritative enteredAt/exitedAt payload instants, 60 s Modern-Standby floor). Consumed by the TimeJumpBadge clock-set cause, the SessionInfoCard standby note, time-attribution SleepSpans (AttributionVersion 3) and ANALYZE-DEV-012.
* [DO GroupId Auto-Set](agent/do-group-id-auto-set.md) - EnableDoGroupIdAutoSet (ConfigVersion 40): the agent seeds the Delivery Optimization DOGroupId policy with a deterministic network-fingerprint GUID (default gateway IPv4 + SendARP MAC, RealmJoin-compatible byte layout) so same-network devices form one DO peering group during ESP; ownership-tracked via the StartupEventGate fingerprint, foreign DOGroupId/DOGroupIdSource policies always win, no random-GUID fallback.
* [ESP Policy-Provider Stall Detection](agent/esp-policy-provider-stall.md) - The EnrollmentStatusTracking CSP's no-timeout Setup/Apps wait is keyed to the Intune-registered "Sidecar" provider by NAME: co-management's "ConfigMgr" parks the user ESP at "Apps (Identifying)" for days even with TrackingPoliciesCreated=1 (issue #106); the always-on two-arm esp_policy_provider_stalled tripwire (provider_incomplete | sidecar_provider_missing) and the SOFTWARE\Microsoft\Windows\Autopilot gather-rule allowlist entry that surface it.
# Rules
+4
View File
@@ -2,6 +2,10 @@
## 2026-08-21
* **New**: `agent/do-group-id-auto-set.md` — optional DO peering-group seed (`EnableDoGroupIdAutoSet`, default off, ConfigVersion 40): `DoGroupIdService` derives a deterministic GUID from the default gateway's IPv4 + SendARP MAC in RealmJoin's exact byte layout (cross-product peering) and writes `DOGroupId` in the 64-bit GPO Policies store with readback verification. Conservative by design: no random-GUID fallback (skip on missing gateway/MAC), `DOGroupIdSource`/MDM-managed values/foreign GPO values always win (skip reasons in the `do_group_id_auto_set` event), own stale values are recognized via the `written:<guid>` StartupEventGate fingerprint and updated after a gateway change. Gateway heuristic extracted into the shared `NetworkInterfaceLocator` (dedup of NetworkChangeDetector + DeviceInfoCollector copies).
* **New**: `agent/system-timeline-watcher.md` — clock-step and sleep-episode ground truth from the System event log: one `EventLogWatcher` (Kernel-General 1, Power-Troubleshooter 1, Kernel-Power 507; 506 deliberately unconsumed) emits `system_clock_changed` (oldTime/newTime/signed timeDeltaMs/reason/process; 2 s micro-slew floor, ≥5 min = Warning+immediate) and `system_sleep_episode` (kind sleep/hibernate/modern_standby, authoritative `enteredAt`/`exitedAt` payload instants, 60 s Modern-Standby floor). WindowsUpdateTracker-style backfill (24 h default, `system-timeline-watermark.json`) recovers pre-agent records; ConfigVersion 39 (`Collectors.EnableSystemTimelineWatcher`). Consumers: TimeJumpBadge cause `clock-set` (ground truth beats inference), SessionInfoCard standby note next to the unchanged wall-clock duration, `SleepSpan`s in time attribution (AttributionVersion 3, RebootSpan clone), and ANALYZE-DEV-012 (info, single, ≥5 min episode; deliberately the only new rule — clock issues stay with DEV-008).
* **New**: `agent/power-state-watcher.md` — live AC/battery observation during enrollment: `PowerStateWatcherHost` subscribes to the WMI push `Win32_PowerManagementEvent EventCode 10` (no polling; re-probes `GetSystemPowerStatus` per push, 5 s trailing-edge debounce, never armed on battery-less devices, `Collectors.EnablePowerStateWatcher` kill-switch, ConfigVersion 38). The pure `PowerStateTransitionTracker` emits `power_state_change` with `transition` = ac_to_battery (Warning, immediate) / battery_to_ac (Info) / threshold_crossed at 50/30/15% (each level latched once per lifetime, lowest-crossed-only on jumps, baseline-below-level covered, emission cap 20). Classified non-activity on both sides (SignalActivityClassifier + IsPeriodicOrStallEvent). New analyze rules ANALYZE-DEV-009 (thresholdPercent=15, high) and ANALYZE-DEV-010 (transition=ac_to_battery, warning), both with `on_event:power_state_change` interim triggers and deliberately single-required-condition (event_data conditions have no same-instance join).
* **Update**: `rules/rule-id-namespace.md` — the export→paste round-trip is no longer lossy: the JSON editors now map rule-shaped exports into the form (`gatherRuleToForm` unflattens the nested gather `parameters` object, analyze `jsonToForm` maps `evaluateOn` onto the eval checkboxes; `tags` and the effective `markSessionAsFailed` carry into the created rule). Both editors still accept the serialized-form shape; edit-mode pastes merge, with an explicit `parameters`/`evaluateOn` winning over stale flat fields.
+49
View File
@@ -0,0 +1,49 @@
{
"$schema": "../schema/analyze-rule.schema.json",
"ruleId": "ANALYZE-DEV-012",
"title": "Device Entered Standby During Enrollment",
"description": "Detects that the device completed a sleep, hibernate or Modern Standby episode of at least 5 minutes while the enrollment was running (system_sleep_episode event from the agent's system timeline watcher, recorded retroactively at wake from the Windows event log — pre-agent episodes are backfilled). Uses a single required condition on the episode's durationSeconds so the match is pinned to one event instance and the interpolated duration is the matched value. Short Modern Standby screen-off scenarios (under 60 s of real sleep) never emit the event, and this rule adds a 5-minute floor so brief lid-close moments do not fire it.",
"severity": "info",
"category": "device",
"version": "1.0.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed",
"on_event:system_sleep_episode"
],
"trigger": "single",
"baseConfidence": 90,
"confidenceThreshold": 60,
"conditions": [
{
"signal": "slept_during_enrollment",
"source": "event_data",
"eventType": "system_sleep_episode",
"dataField": "durationSeconds",
"operator": "gte",
"value": "300",
"required": true
}
],
"explanation": "The device was asleep for {{durationSeconds}} seconds during the enrollment window. Nothing progresses while the device sleeps — downloads pause, the IME stops processing and the ESP simply waits — so the wall-clock enrollment duration includes this pause and can look much slower than the enrollment actually was. The matched system_sleep_episode event in the session timeline carries the exact enter/exit times, the episode kind (sleep, hibernate or Modern Standby) and, for Modern Standby, whether the device was on AC power. A long unexplained gap in the timeline right before that event is this pause, not missing telemetry.",
"remediation": [
{
"title": "Keep the device awake during enrollment",
"steps": [
"Check the matched system_sleep_episode event in the timeline for the episode bounds and kind — the enrollment duration minus the episode duration is the real active time.",
"If the device slept on battery, connect AC power during setup; Windows sleeps far more aggressively on battery.",
"Instruct users/technicians to keep the lid open and the device powered until the enrollment finishes.",
"For recurring cases, review the power plan applied during OOBE/ESP (e.g. via provisioning or Intune policy) — consider disabling sleep on AC for the enrollment window."
]
}
],
"markSessionAsFailedDefault": false,
"tags": [
"device",
"power",
"standby",
"sleep",
"environment"
]
}
+48
View File
@@ -1678,6 +1678,54 @@
"environment"
]
},
{
"ruleId": "ANALYZE-DEV-012",
"title": "Device Entered Standby During Enrollment",
"description": "Detects that the device completed a sleep, hibernate or Modern Standby episode of at least 5 minutes while the enrollment was running (system_sleep_episode event from the agent's system timeline watcher, recorded retroactively at wake from the Windows event log — pre-agent episodes are backfilled). Uses a single required condition on the episode's durationSeconds so the match is pinned to one event instance and the interpolated duration is the matched value. Short Modern Standby screen-off scenarios (under 60 s of real sleep) never emit the event, and this rule adds a 5-minute floor so brief lid-close moments do not fire it.",
"severity": "info",
"category": "device",
"version": "1.0.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed",
"on_event:system_sleep_episode"
],
"trigger": "single",
"baseConfidence": 90,
"confidenceThreshold": 60,
"conditions": [
{
"signal": "slept_during_enrollment",
"source": "event_data",
"eventType": "system_sleep_episode",
"dataField": "durationSeconds",
"operator": "gte",
"value": "300",
"required": true
}
],
"explanation": "The device was asleep for {{durationSeconds}} seconds during the enrollment window. Nothing progresses while the device sleeps — downloads pause, the IME stops processing and the ESP simply waits — so the wall-clock enrollment duration includes this pause and can look much slower than the enrollment actually was. The matched system_sleep_episode event in the session timeline carries the exact enter/exit times, the episode kind (sleep, hibernate or Modern Standby) and, for Modern Standby, whether the device was on AC power. A long unexplained gap in the timeline right before that event is this pause, not missing telemetry.",
"remediation": [
{
"title": "Keep the device awake during enrollment",
"steps": [
"Check the matched system_sleep_episode event in the timeline for the episode bounds and kind — the enrollment duration minus the episode duration is the real active time.",
"If the device slept on battery, connect AC power during setup; Windows sleeps far more aggressively on battery.",
"Instruct users/technicians to keep the lid open and the device powered until the enrollment finishes.",
"For recurring cases, review the power plan applied during OOBE/ESP (e.g. via provisioning or Intune policy) — consider disabling sleep on AC for the enrollment window."
]
}
],
"markSessionAsFailedDefault": false,
"tags": [
"device",
"power",
"standby",
"sleep",
"environment"
]
},
{
"ruleId": "ANALYZE-ENRL-001",
"title": "Enrollment Failed",
@@ -28,10 +28,13 @@
(validates Windows 365 Cloud PCs against the tenant's Cloud PC inventory --
Cloud PCs are never Autopilot-registered, so this enables agent monitoring
of Cloud PC first-connect enrollment)
- All -> every optional permission above, in one run
(also works with -Revoke to remove all optional grants at once)
.PARAMETER Permissions
Microsoft Graph application permission names to grant directly. Use when the admin UI
rendered a copy-paste command for you, or when you have a specific list in mind.
Microsoft Graph application permission names to grant directly. Use when you copied the
permission list from the admin UI ("Copy permissions"), or when you have a specific list
in mind.
.PARAMETER TenantId
Tenant ID (GUID) or verified domain. Optional -- defaults to the signed-in context.
@@ -47,12 +50,16 @@
.\Grant-AutopilotMonitorAddOn.ps1 -ClientId "<your-client-id>" -Features ScriptDisplayNames
.EXAMPLE
# Same effect, using the explicit-permission form (what the admin UI generates):
# Same effect, using the explicit-permission form:
.\Grant-AutopilotMonitorAddOn.ps1 `
-ClientId "<your-client-id>" `
-Permissions "DeviceManagementScripts.Read.All" `
-TenantId "contoso.onmicrosoft.com"
.EXAMPLE
# Grant every optional capability in one run:
.\Grant-AutopilotMonitorAddOn.ps1 -ClientId "<your-client-id>" -Features All
.EXAMPLE
# Verify current state only, no changes:
.\Grant-AutopilotMonitorAddOn.ps1 -ClientId "<your-client-id>" -Features ScriptDisplayNames -VerifyOnly
@@ -65,6 +72,12 @@
Requires:
- Microsoft.Graph.Authentication PowerShell module (auto-installs if missing).
- Sign-in as Global Administrator OR Privileged Role Administrator OR Cloud Application Administrator.
Sign-in behaviour:
In Azure Cloud Shell the script signs in via the ambient identity endpoint (a delegated
token for the already-signed-in user) - no device-code prompt, which Conditional Access
policies commonly block. Outside Cloud Shell it falls back to a normal interactive
Microsoft Graph sign-in. -TenantId only applies to the interactive fallback.
- The Autopilot Monitor app must be admin-consented in your tenant first
(its service principal must exist before grants can be added).
@@ -80,7 +93,7 @@ param(
[string]$ClientId,
[Parameter(Mandatory = $true, ParameterSetName = 'ByFeatures')]
[ValidateSet('ScriptDisplayNames', 'W365CloudPcValidation')]
[ValidateSet('All', 'ScriptDisplayNames', 'W365CloudPcValidation')]
[string[]]$Features,
[Parameter(Mandatory = $true, ParameterSetName = 'ByPermissions')]
@@ -106,7 +119,11 @@ $FeatureCatalog = @{
}
# Translate Features -> Permissions when caller used the high-level form.
# 'All' is a script-side meta-value: it expands to every catalog feature.
if ($PSCmdlet.ParameterSetName -eq 'ByFeatures') {
if ($Features -contains 'All') {
$Features = @($FeatureCatalog.Keys)
}
$Permissions = @()
foreach ($f in $Features) {
$Permissions += $FeatureCatalog[$f]
@@ -133,7 +150,25 @@ if ($TenantId) { $connectParams['TenantId'] = $TenantId }
Write-Host ""
Write-Host "[Auth] Connecting to Microsoft Graph..." -ForegroundColor Cyan
Connect-MgGraph @connectParams | Out-Null
# Preferred path: ambient identity. In Azure Cloud Shell the local identity endpoint issues a
# delegated Graph token for the signed-in user, which avoids the device-code flow that
# Conditional Access policies commonly block. Outside Azure this throws and we fall back to
# an interactive sign-in with explicit scopes.
$identityConnected = $false
try {
Connect-MgGraph -Identity -NoWelcome -ErrorAction Stop | Out-Null
# An ambient identity can exist yet hold no Graph permissions (e.g. a plain VM managed
# identity) - probe with a read before committing to this session.
Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/servicePrincipals?`$select=id&`$top=1" -ErrorAction Stop | Out-Null
$identityConnected = $true
Write-Host "[Auth] Connected via ambient identity (Azure Cloud Shell / managed identity)." -ForegroundColor Green
}
catch {
if (Get-MgContext) { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null }
Write-Host "[Auth] No usable ambient identity - falling back to interactive sign-in..." -ForegroundColor Yellow
Connect-MgGraph @connectParams | Out-Null
}
$ctx = Get-MgContext
if (-not $ctx) {
@@ -144,7 +179,10 @@ Write-Host "[Auth] Signed in as : $($ctx.Account)"
Write-Host "[Auth] Tenant : $($ctx.TenantId)"
Write-Host "[Auth] Granted scopes: $($ctx.Scopes -join ', ')"
if (-not ($ctx.Scopes -contains 'AppRoleAssignment.ReadWrite.All')) {
# Scope check only applies to the interactive path: an ambient-identity (Cloud Shell) token
# carries a fixed scope set (incl. Directory.AccessAsUser.All) instead of the requested scopes,
# and its effective rights follow the signed-in user's directory role.
if (-not $identityConnected -and -not ($ctx.Scopes -contains 'AppRoleAssignment.ReadWrite.All')) {
Write-Warning "Scope 'AppRoleAssignment.ReadWrite.All' was NOT granted. The signed-in user likely lacks the required role (Global Admin / Privileged Role Admin / Cloud App Admin)."
if (-not $VerifyOnly) { return }
}
@@ -37,6 +37,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
nameof(AgentConfigResponse.EnrollmentSummaryLaunchRetrySeconds),
nameof(AgentConfigResponse.NtpServer),
nameof(AgentConfigResponse.EnableTimezoneAutoSet),
nameof(AgentConfigResponse.EnableDoGroupIdAutoSet),
nameof(AgentConfigResponse.DiagnosticsUploadEnabled),
nameof(AgentConfigResponse.DiagnosticsUploadMode),
nameof(AgentConfigResponse.DiagnosticsLogPaths),
@@ -43,6 +43,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
EnrollmentSummaryLaunchRetrySeconds = 300,
NtpServer = "pool.ntp.org",
EnableTimezoneAutoSet = true,
EnableDoGroupIdAutoSet = true,
DiagnosticsUploadEnabled = true,
DiagnosticsUploadMode = "OnFailure",
DiagnosticsLogPaths = new List<DiagnosticsLogPath>
@@ -83,6 +84,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
Assert.Equal(300, agent.EnrollmentSummaryLaunchRetrySeconds);
Assert.Equal("pool.ntp.org", agent.NtpServer);
Assert.True(agent.EnableTimezoneAutoSet);
Assert.True(agent.EnableDoGroupIdAutoSet);
Assert.True(agent.DiagnosticsUploadEnabled);
Assert.Equal("OnFailure", agent.DiagnosticsUploadMode);
Assert.Single(agent.DiagnosticsLogPaths);
@@ -0,0 +1,429 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.Shared;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
{
/// <summary>
/// SystemTimelineTracker — clock steps (Kernel-General 1) and completed sleep episodes
/// (Power-Troubleshooter 1 / Kernel-Power 507) from the System channel. Drives the primitive
/// <see cref="SystemTimelineTracker.ProcessEvent"/> test seam (no real EventRecord, which is
/// abstract + Windows-only), mirroring the WindowsUpdateTracker test pattern.
/// </summary>
public sealed class SystemTimelineTrackerTests : IDisposable
{
private static readonly DateTime At = new DateTime(2026, 8, 21, 10, 0, 0, DateTimeKind.Utc);
private readonly TempDirectory _tmp = new TempDirectory();
private readonly FakeSignalIngressSink _sink;
private readonly SystemTimelineTracker _tracker;
private long _nextRecordId = 1;
public SystemTimelineTrackerTests()
{
_sink = new FakeSignalIngressSink();
var post = new InformationalEventPost(_sink, new VirtualClock(At));
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
_tracker = new SystemTimelineTracker(
sessionId: "sess-stl",
tenantId: "tenant-stl",
post: post,
logger: logger,
backfillEnabled: false,
stateDirectory: null); // in-memory dedup for most tests
}
public void Dispose() => _tmp.Dispose();
// -------------------------------------------------------------------
// Emission helpers
// -------------------------------------------------------------------
private void EmitClockChange(
DateTime? oldTime, DateTime? newTime, string? rawDeltaMs = null,
int reason = 1, long? recordId = null, bool isBackfill = false, DateTime? timeCreatedUtc = null)
{
var data = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (oldTime.HasValue) data["OldTime"] = oldTime.Value.ToString("o");
if (newTime.HasValue) data["NewTime"] = newTime.Value.ToString("o");
if (rawDeltaMs != null) data["TimeDeltaInMs"] = rawDeltaMs;
data["Reason"] = reason.ToString();
data["ProcessName"] = @"\Device\HarddiskVolume3\Windows\System32\svchost.exe";
data["ProcessID"] = "6892";
_tracker.ProcessEvent(
providerName: SystemTimelineTracker.ProviderKernelGeneral,
eventId: SystemTimelineTracker.EventId_ClockChange,
recordId: recordId ?? _nextRecordId++,
timeCreatedUtc: timeCreatedUtc ?? At,
eventData: data,
isBackfill: isBackfill);
}
private void EmitClassicResume(
DateTime sleepTime, DateTime wakeTime, string? effectiveState = "1",
string? wakeSourceText = null, long? recordId = null, bool isBackfill = false, DateTime? timeCreatedUtc = null)
{
var data = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["SleepTime"] = sleepTime.ToString("o"),
["WakeTime"] = wakeTime.ToString("o"),
["WakeSourceType"] = "0",
};
if (effectiveState != null) data["EffectiveState"] = effectiveState;
if (wakeSourceText != null) data["WakeSourceText"] = wakeSourceText;
_tracker.ProcessEvent(
providerName: SystemTimelineTracker.ProviderPowerTroubleshooter,
eventId: SystemTimelineTracker.EventId_ClassicResume,
recordId: recordId ?? _nextRecordId++,
timeCreatedUtc: timeCreatedUtc ?? wakeTime,
eventData: data,
isBackfill: isBackfill);
}
private void EmitModernStandbyExit(
long sleepDurationUs, long durationUs, string powerStateAc = "true",
long? recordId = null, bool isBackfill = false, DateTime? timeCreatedUtc = null)
{
var data = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["SleepDurationInUs"] = sleepDurationUs.ToString(),
["DurationInUs"] = durationUs.ToString(),
["Reason"] = "32",
["PowerStateAc"] = powerStateAc,
["BatteryRemainingCapacityOnExit"] = "34180",
};
_tracker.ProcessEvent(
providerName: SystemTimelineTracker.ProviderKernelPower,
eventId: SystemTimelineTracker.EventId_ModernStandbyExit,
recordId: recordId ?? _nextRecordId++,
timeCreatedUtc: timeCreatedUtc ?? At,
eventData: data,
isBackfill: isBackfill);
}
private IReadOnlyList<FakeSignalIngressSink.PostedSignal> ByType(string eventType) =>
_sink.Posted.Where(p =>
p.Kind == DecisionSignalKind.InformationalEvent
&& p.Payload != null
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
&& et == eventType).ToList();
private static IReadOnlyDictionary<string, object> Data(FakeSignalIngressSink.PostedSignal s) =>
(IReadOnlyDictionary<string, object>)s.TypedPayload!;
// -------------------------------------------------------------------
// system_clock_changed
// -------------------------------------------------------------------
[Fact]
public void ClockStep_AboveThreshold_EmitsInfo_WithSignedDeltaAndNormalizedTimes()
{
var oldTime = At;
var newTime = At.AddSeconds(10);
EmitClockChange(oldTime, newTime, reason: 1);
var s = Assert.Single(ByType(Constants.EventTypes.SystemClockChanged));
Assert.Equal("Info", s.Payload![SignalPayloadKeys.Severity]);
Assert.Equal("false", s.Payload![SignalPayloadKeys.ImmediateUpload]);
var data = Data(s);
Assert.Equal(10_000L, data["timeDeltaMs"]);
Assert.Equal("application_set", data["reasonText"]);
Assert.Equal(1, data["reason"]);
Assert.Equal(oldTime.ToString("o"), data["oldTime"]);
Assert.Equal(newTime.ToString("o"), data["newTime"]);
Assert.Equal(6892, data["processId"]);
Assert.Contains("forward", (string)s.Payload![SignalPayloadKeys.Message]);
Assert.Contains("svchost.exe", (string)s.Payload![SignalPayloadKeys.Message]);
}
[Theory]
[InlineData(1)]
[InlineData(1999)]
public void ClockMicroStep_BelowThreshold_Suppressed(int deltaMs)
{
EmitClockChange(At, At.AddMilliseconds(deltaMs));
Assert.Empty(ByType(Constants.EventTypes.SystemClockChanged));
}
[Fact]
public void ClockStep_Backward_EmitsNegativeDelta()
{
EmitClockChange(oldTime: At, newTime: At.AddSeconds(-3));
var s = Assert.Single(ByType(Constants.EventTypes.SystemClockChanged));
Assert.Equal(-3000L, Data(s)["timeDeltaMs"]);
Assert.Contains("backward", (string)s.Payload![SignalPayloadKeys.Message]);
}
[Fact]
public void ClockStep_AtLeastFiveMinutes_WarningAndImmediateUpload()
{
EmitClockChange(At, At.AddMinutes(5), reason: 2);
var s = Assert.Single(ByType(Constants.EventTypes.SystemClockChanged));
Assert.Equal("Warning", s.Payload![SignalPayloadKeys.Severity]);
Assert.Equal("true", s.Payload![SignalPayloadKeys.ImmediateUpload]);
Assert.Equal("hardware_clock_sync", Data(s)["reasonText"]);
}
[Fact]
public void ClockStep_NoUsableTimesOrDelta_Skipped()
{
EmitClockChange(oldTime: null, newTime: null, rawDeltaMs: "not-a-number");
Assert.Empty(ByType(Constants.EventTypes.SystemClockChanged));
}
[Fact]
public void ClockStep_FallsBackToTimeDeltaInMs_WhenTimesUnparseable()
{
// Only the raw delta parses — the direction information of NewTime/OldTime is gone,
// but the magnitude still gates and emits.
EmitClockChange(oldTime: null, newTime: null, rawDeltaMs: "7200000");
var s = Assert.Single(ByType(Constants.EventTypes.SystemClockChanged));
Assert.Equal(7_200_000L, Data(s)["timeDeltaMs"]);
}
// -------------------------------------------------------------------
// system_sleep_episode — classic (Power-Troubleshooter 1)
// -------------------------------------------------------------------
[Fact]
public void ClassicResume_EffectiveState5_EmitsHibernateEpisode()
{
var sleepAt = At.AddHours(-2);
EmitClassicResume(sleepAt, At, effectiveState: "5", wakeSourceText: "Power Button");
var s = Assert.Single(ByType(Constants.EventTypes.SystemSleepEpisode));
Assert.Equal("Info", s.Payload![SignalPayloadKeys.Severity]);
var data = Data(s);
Assert.Equal("hibernate", data["kind"]);
Assert.Equal(7200L, data["durationSeconds"]);
Assert.Equal(sleepAt.ToString("o"), data["enteredAt"]);
Assert.Equal(At.ToString("o"), data["exitedAt"]);
Assert.Equal("Power Button", data["wakeSourceText"]);
Assert.Contains("hibernate", (string)s.Payload![SignalPayloadKeys.Message]);
}
[Fact]
public void ClassicResume_NonHibernateState_EmitsSleepKind()
{
EmitClassicResume(At.AddMinutes(-30), At, effectiveState: "4");
Assert.Equal("sleep", Data(Assert.Single(ByType(Constants.EventTypes.SystemSleepEpisode)))["kind"]);
}
[Fact]
public void ClassicResume_ShortEpisode_StillEmits()
{
// No duration floor for classic sleep: the event only exists for genuinely completed
// S3/S4 transitions, so a 30 s real sleep is still signal.
EmitClassicResume(At.AddSeconds(-30), At);
var s = Assert.Single(ByType(Constants.EventTypes.SystemSleepEpisode));
Assert.Equal(30L, Data(s)["durationSeconds"]);
}
// -------------------------------------------------------------------
// system_sleep_episode — Modern Standby (Kernel-Power 507)
// -------------------------------------------------------------------
[Fact]
public void ModernStandbyExit_EmitsEpisode_WithDerivedEnterTime()
{
// Values from a live 507 on this dev machine: 38 min scenario, ~38 min actual sleep.
EmitModernStandbyExit(sleepDurationUs: 2_324_095_807, durationUs: 2_330_201_652);
var s = Assert.Single(ByType(Constants.EventTypes.SystemSleepEpisode));
var data = Data(s);
Assert.Equal("modern_standby", data["kind"]);
Assert.Equal(2330L, data["durationSeconds"]);
Assert.Equal(2324L, data["sleepDurationSeconds"]);
Assert.Equal(true, data["onAcPower"]);
Assert.Equal(34180L, data["batteryRemainingCapacityOnExit"]);
Assert.Equal(At.ToString("o"), data["exitedAt"]);
Assert.Equal(At.AddMilliseconds(-2_330_201.652).ToString("o"), data["enteredAt"]);
Assert.Contains("Modern Standby", (string)s.Payload![SignalPayloadKeys.Message]);
}
[Fact]
public void ModernStandbyExit_SleepBelowFloor_Suppressed()
{
EmitModernStandbyExit(sleepDurationUs: 30_000_000, durationUs: 90_000_000); // 30 s sleep
Assert.Empty(ByType(Constants.EventTypes.SystemSleepEpisode));
}
[Fact]
public void ModernStandbyExit_ScreenOffWithoutSleep_Suppressed()
{
// SleepEntered=false scenarios carry SleepDurationInUs 0 — the single duration gate
// suppresses them without a separate branch.
EmitModernStandbyExit(sleepDurationUs: 0, durationUs: 6_106_696);
Assert.Empty(ByType(Constants.EventTypes.SystemSleepEpisode));
}
[Fact]
public void ModernStandbyExit_ScenarioShorterThanSleep_ClampsToSleepDuration()
{
EmitModernStandbyExit(sleepDurationUs: 120_000_000, durationUs: 0);
var data = Data(Assert.Single(ByType(Constants.EventTypes.SystemSleepEpisode)));
Assert.Equal(120L, data["durationSeconds"]);
Assert.Equal(120L, data["sleepDurationSeconds"]);
}
// -------------------------------------------------------------------
// Backfill semantics
// -------------------------------------------------------------------
[Fact]
public void BackfilledEvent_UsesEventTimeForTimelineTimestamp_AndFlagsData()
{
// A pre-agent standby episode (record created before the agent started) must land on
// the timeline where the device actually woke, not at agent start.
var wakeTime = At.AddHours(-3);
EmitClassicResume(wakeTime.AddHours(-1), wakeTime, isBackfill: true, timeCreatedUtc: wakeTime);
var s = Assert.Single(ByType(Constants.EventTypes.SystemSleepEpisode));
Assert.Equal(wakeTime, s.OccurredAtUtc);
Assert.Equal(true, Data(s)["backfilled"]);
}
// -------------------------------------------------------------------
// Dedup / watermark
// -------------------------------------------------------------------
[Fact]
public void SameRecordId_ProcessedTwice_EmitsOnce()
{
EmitClockChange(At, At.AddMinutes(1), recordId: 42);
EmitClockChange(At, At.AddMinutes(1), recordId: 42);
Assert.Single(ByType(Constants.EventTypes.SystemClockChanged));
}
[Fact]
public void BackfillEventWithLowerRecordId_AfterLiveEvent_IsStillEmitted()
{
// The live watcher is armed BEFORE backfill runs, so a live record with a higher
// RecordId can be processed first. An older, never-emitted backfill record must NOT be
// suppressed — pre-agent episodes are the whole point (high-water-mark trap).
EmitClockChange(At, At.AddMinutes(1), recordId: 100); // live, high
EmitClockChange(At, At.AddMinutes(2), recordId: 50, isBackfill: true); // backfill, older
Assert.Equal(2, ByType(Constants.EventTypes.SystemClockChanged).Count);
}
[Fact]
public void Watermark_PersistsAcrossTrackerInstances()
{
var post = new InformationalEventPost(_sink, new VirtualClock(At));
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
var clockData = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["OldTime"] = At.ToString("o"),
["NewTime"] = At.AddMinutes(10).ToString("o"),
["Reason"] = "1",
};
var first = new SystemTimelineTracker("s", "t", post, logger, backfillEnabled: false, stateDirectory: _tmp.Path);
first.ProcessEvent(SystemTimelineTracker.ProviderKernelGeneral, SystemTimelineTracker.EventId_ClockChange,
recordId: 500, timeCreatedUtc: At, eventData: clockData, isBackfill: false);
// A fresh tracker (agent restart) must load the watermark and skip the already-emitted
// record when the backfill re-reads it.
var second = new SystemTimelineTracker("s", "t", post, logger, backfillEnabled: false, stateDirectory: _tmp.Path);
second.LoadWatermark();
second.ProcessEvent(SystemTimelineTracker.ProviderKernelGeneral, SystemTimelineTracker.EventId_ClockChange,
recordId: 500, timeCreatedUtc: At, eventData: clockData, isBackfill: true);
Assert.Single(ByType(Constants.EventTypes.SystemClockChanged));
}
[Fact]
public void UnknownProviderOrEventId_Ignored()
{
_tracker.ProcessEvent("Microsoft-Windows-Kernel-Power", eventId: 566, recordId: _nextRecordId++,
timeCreatedUtc: At, eventData: new Dictionary<string, string>(), isBackfill: false);
_tracker.ProcessEvent("Some-Other-Provider", eventId: 1, recordId: _nextRecordId++,
timeCreatedUtc: At, eventData: new Dictionary<string, string>(), isBackfill: false);
Assert.Empty(_sink.Posted);
}
// -------------------------------------------------------------------
// XPath + parsing
// -------------------------------------------------------------------
[Fact]
public void BuildXPath_ContainsAllThreeProviderIdPairs_AndNo506()
{
var xpath = SystemTimelineTracker.BuildXPath();
Assert.Contains("Provider[@Name='Microsoft-Windows-Kernel-General'] and EventID=1", xpath);
Assert.Contains("Provider[@Name='Microsoft-Windows-Power-Troubleshooter'] and EventID=1", xpath);
Assert.Contains("Provider[@Name='Microsoft-Windows-Kernel-Power'] and EventID=507", xpath);
Assert.DoesNotContain("506", xpath);
}
[Fact]
public void BuildBackfillXPath_AppendsTimediffClause()
{
var xpath = SystemTimelineTracker.BuildBackfillXPath(86_400_000);
Assert.Contains("TimeCreated[timediff(@SystemTime) <= 86400000]", xpath);
Assert.Contains("EventID=507", xpath);
}
[Fact]
public void ParseEventData_RealKernelGeneralPayload_YieldsAuthoritativeFields()
{
// Verbatim (values shortened) from a live Kernel-General 1 record on a 26220 dev machine.
const string xml =
"<Event xmlns='http://schemas.microsoft.com/win/2004/08/events/event'>" +
"<System><Provider Name='Microsoft-Windows-Kernel-General'/><EventID>1</EventID></System>" +
"<EventData>" +
"<Data Name='NewTime'>2026-08-21T09:40:02.1987028Z</Data>" +
"<Data Name='OldTime'>2026-08-21T09:40:02.1973028Z</Data>" +
"<Data Name='TimeDeltaInMs'>1</Data>" +
"<Data Name='Reason'>1</Data>" +
"<Data Name='ProcessName'>\\Device\\HarddiskVolume3\\Windows\\System32\\svchost.exe</Data>" +
"<Data Name='ProcessID'>6892</Data>" +
"</EventData></Event>";
var data = SystemTimelineTracker.ParseEventData(xml);
Assert.Equal("1", data["TimeDeltaInMs"]);
Assert.Equal("2026-08-21T09:40:02.1987028Z", data["newtime"]); // case-insensitive keys
Assert.NotNull(SystemTimelineTracker.TryParseUtc(data["OldTime"]));
}
[Fact]
public void FormatDuration_CompactForms()
{
Assert.Equal("2h 05m", SystemTimelineTracker.FormatDuration(TimeSpan.FromMinutes(125)));
Assert.Equal("56m 12s", SystemTimelineTracker.FormatDuration(TimeSpan.FromSeconds(56 * 60 + 12)));
Assert.Equal("45s", SystemTimelineTracker.FormatDuration(TimeSpan.FromSeconds(45)));
}
[Fact]
public void ProcessLeafName_StripsDevicePath()
{
Assert.Equal("svchost.exe",
SystemTimelineTracker.ProcessLeafName(@"\Device\HarddiskVolume3\Windows\System32\svchost.exe"));
Assert.Null(SystemTimelineTracker.ProcessLeafName(null));
Assert.Null(SystemTimelineTracker.ProcessLeafName(@"\Device\HarddiskVolume3\"));
}
}
}
@@ -0,0 +1,126 @@
using System;
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Transport
{
public class DoGroupIdServiceTests
{
// -------------------------------------------------------------------
// ComputeGroupId — RealmJoin byte-layout compatibility
// -------------------------------------------------------------------
[Fact]
public void ComputeGroupId_MatchesHandComputedVector()
{
// Layout: {101,48,6} + {0,0,0} + {ip[1],0,ip[2],ip[3]} + mac — through new Guid(byte[])
// the first three groups print little-endian, the last eight bytes verbatim.
var guid = DoGroupIdService.ComputeGroupId(
new byte[] { 192, 168, 178, 1 },
new byte[] { 0x00, 0x11, 0x22, 0x33, 0x44, 0x55 });
Assert.Equal("00063065-0000-00a8-b201-001122334455", guid.ToString());
}
[Theory]
[InlineData(new byte[] { 10, 0, 0, 1 }, new byte[] { 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF })]
[InlineData(new byte[] { 192, 168, 1, 254 }, new byte[] { 0x00, 0x50, 0x56, 0x01, 0x02, 0x03 })]
[InlineData(new byte[] { 172, 16, 254, 3 }, new byte[] { 0xF0, 0x9F, 0xC2, 0x00, 0x00, 0x01 })]
public void ComputeGroupId_MatchesRealmJoinReferenceImplementation(byte[] ip, byte[] mac)
{
Assert.Equal(RealmJoinReferenceGuid(ip, mac), DoGroupIdService.ComputeGroupId(ip, mac));
}
/// <summary>Verbatim port of RealmJoin's ConvertToGuid — the compatibility contract.</summary>
private static Guid RealmJoinReferenceGuid(byte[] ipBytes, byte[] address)
{
byte[] prefix = { 101, 48, 6 };
var stuff = new byte[4];
stuff[0] = ipBytes[1];
stuff[2] = ipBytes[2];
stuff[3] = ipBytes[3];
return new Guid(prefix
.Concat(new byte[16 - prefix.Length - stuff.Length - address.Length])
.Concat(stuff)
.Concat(address)
.ToArray());
}
[Fact]
public void ComputeGroupId_RejectsNonIpv4AndBadMacLengths()
{
var ip4 = new byte[] { 10, 0, 0, 1 };
var mac = new byte[] { 1, 2, 3, 4, 5, 6 };
Assert.Throws<ArgumentException>(() => DoGroupIdService.ComputeGroupId(new byte[16], mac));
Assert.Throws<ArgumentException>(() => DoGroupIdService.ComputeGroupId(null!, mac));
Assert.Throws<ArgumentException>(() => DoGroupIdService.ComputeGroupId(ip4, new byte[8]));
Assert.Throws<ArgumentException>(() => DoGroupIdService.ComputeGroupId(ip4, null!));
}
[Fact]
public void ComputeGroupId_RegistryFormat_IsBracelessLowercase()
{
// RealmJoin writes SetValue(name, Guid) which stringifies via Guid.ToString() ("d"
// format) — our written value must be the identical string form.
var text = DoGroupIdService.ComputeGroupId(
new byte[] { 10, 1, 2, 3 }, new byte[] { 0xAB, 0xCD, 0xEF, 0x01, 0x02, 0x03 }).ToString();
Assert.DoesNotContain("{", text);
Assert.Equal(text.ToLowerInvariant(), text);
Assert.Equal(36, text.Length);
}
// -------------------------------------------------------------------
// DecideAction — ownership matrix
// -------------------------------------------------------------------
private const string Computed = "00063065-0000-00a8-b201-001122334455";
private const string Other = "11111111-2222-3333-4444-555555555555";
[Theory]
[InlineData(null)]
[InlineData("")]
public void DecideAction_NoExistingValue_Writes(string? existing)
{
Assert.Equal(DoGroupIdAction.Write, DoGroupIdService.DecideAction(existing, Computed, null));
}
[Fact]
public void DecideAction_ExistingEqualsComputed_IsNoOp()
{
Assert.Equal(DoGroupIdAction.NoOp, DoGroupIdService.DecideAction(Computed, Computed, null));
}
[Fact]
public void DecideAction_ExistingEqualsComputed_ToleratesBracesAndCase()
{
// A value written by another tool in "{...}"/uppercase form is still semantically ours.
var braced = "{" + Computed.ToUpperInvariant() + "}";
Assert.Equal(DoGroupIdAction.NoOp, DoGroupIdService.DecideAction(braced, Computed, null));
}
[Fact]
public void DecideAction_ExistingIsOurStaleValue_WritesUpdate()
{
// Gateway changed: the registry still holds the GUID we wrote for the old network.
Assert.Equal(DoGroupIdAction.Write, DoGroupIdService.DecideAction(Other, Computed, Other));
}
[Fact]
public void DecideAction_ForeignValue_Skips()
{
Assert.Equal(DoGroupIdAction.SkipForeign, DoGroupIdService.DecideAction(Other, Computed, null));
Assert.Equal(DoGroupIdAction.SkipForeign, DoGroupIdService.DecideAction(Other, Computed, Computed));
}
[Fact]
public void DecideAction_NonGuidGarbageExisting_SkipsAsForeign()
{
// Never overwrite a value we can't even parse — conservative by design.
Assert.Equal(DoGroupIdAction.SkipForeign, DoGroupIdService.DecideAction("not-a-guid", Computed, null));
}
}
}
@@ -0,0 +1,57 @@
#nullable enable
using System;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.DecisionCore.Engine;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
{
/// <summary>
/// SystemTimelineWatcherHost — thin ICollectorHost wrapper over SystemTimelineTracker.
/// Real event-log arming stays untested by convention (ConsoleBypassWatcher precedent);
/// these tests pin the lifecycle contract only.
/// </summary>
public sealed class SystemTimelineWatcherHostTests : IDisposable
{
private readonly TempDirectory _tmp = new TempDirectory();
public void Dispose() => _tmp.Dispose();
private SystemTimelineWatcherHost CreateHost() => new SystemTimelineWatcherHost(
sessionId: "sess-stl",
tenantId: "tenant-stl",
logger: new AgentLogger(_tmp.Path, AgentLogLevel.Info),
ingress: new FakeSignalIngressSink(),
clock: new VirtualClock(new DateTime(2026, 8, 21, 10, 0, 0, DateTimeKind.Utc)),
backfillLookbackMinutes: 0,
stateDirectory: _tmp.Path);
[Fact]
public void Stop_BeforeStart_DoesNotThrow()
{
using (var host = CreateHost())
{
host.Stop();
}
}
[Fact]
public void Dispose_Twice_DoesNotThrow()
{
var host = CreateHost();
host.Dispose();
host.Dispose();
}
[Fact]
public void Name_IsStable()
{
using (var host = CreateHost())
{
Assert.Equal("SystemTimelineTracker", host.Name);
}
}
}
}
@@ -339,6 +339,114 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Runtime
Assert.False(StartupEnvironmentProbes.IsNtpWithinTolerance(null!));
}
// ================================================================= DO GroupId auto-set
// Builder/fingerprint tests only — TrySetGroupId itself does live registry + ARP I/O and
// must never run in a unit test (it would write HKLM on the build machine).
[Fact]
public void BuildDoGroupIdEvent_written_success_is_info_with_full_data()
{
var result = new DoGroupIdSetResult
{
Success = true,
Written = true,
GroupId = "00063065-0000-00a8-b201-001122334455",
PreviousValue = null,
GatewayIp = "192.168.178.1",
GatewayMac = "00-11-22-33-44-55",
};
var evt = StartupEnvironmentProbes.BuildDoGroupIdEvent(Config(), result);
Assert.Equal("do_group_id_auto_set", evt.EventType);
Assert.Equal(EventSeverity.Info, evt.Severity);
Assert.Equal("StartupEnvironmentProbes", evt.Source);
Assert.Equal("S1", evt.SessionId);
Assert.Equal("T1", evt.TenantId);
Assert.Contains("00063065-0000-00a8-b201-001122334455", evt.Message);
Assert.Equal("192.168.178.1", evt.Data["gatewayIp"]);
Assert.Equal("00-11-22-33-44-55", evt.Data["gatewayMac"]);
Assert.Equal(true, evt.Data["written"]);
Assert.Equal(true, evt.Data["success"]);
Assert.Equal(false, evt.Data["skipped"]);
}
[Fact]
public void BuildDoGroupIdEvent_noop_success_says_already_set()
{
var result = new DoGroupIdSetResult
{
Success = true,
Written = false,
GroupId = "00063065-0000-00a8-b201-001122334455",
};
var evt = StartupEnvironmentProbes.BuildDoGroupIdEvent(Config(), result);
Assert.Equal(EventSeverity.Info, evt.Severity);
Assert.Contains("already set", evt.Message);
Assert.Equal(false, evt.Data["written"]);
}
[Fact]
public void BuildDoGroupIdEvent_skip_is_info_with_reason()
{
var result = new DoGroupIdSetResult
{
Skipped = true,
SkipReason = "foreign_value_present",
PreviousValue = "11111111-2222-3333-4444-555555555555",
};
var evt = StartupEnvironmentProbes.BuildDoGroupIdEvent(Config(), result);
Assert.Equal(EventSeverity.Info, evt.Severity);
Assert.Contains("foreign_value_present", evt.Message);
Assert.Equal("foreign_value_present", evt.Data["skipReason"]);
Assert.Equal("11111111-2222-3333-4444-555555555555", evt.Data["previousValue"]);
}
[Fact]
public void BuildDoGroupIdEvent_error_is_warning()
{
var result = new DoGroupIdSetResult { Error = "readback mismatch" };
var evt = StartupEnvironmentProbes.BuildDoGroupIdEvent(Config(), result);
Assert.Equal(EventSeverity.Warning, evt.Severity);
Assert.Contains("readback mismatch", evt.Message);
Assert.Equal("readback mismatch", evt.Data["error"]);
}
[Fact]
public void DoGroupIdFingerprint_roundtrips_ownership_marker_for_success()
{
var fp = StartupEnvironmentProbes.ComputeDoGroupIdFingerprint(new DoGroupIdSetResult
{
Success = true,
GroupId = "00063065-0000-00a8-b201-001122334455",
});
Assert.Equal("written:00063065-0000-00a8-b201-001122334455", fp);
Assert.Equal("00063065-0000-00a8-b201-001122334455", StartupEnvironmentProbes.ExtractLastWrittenGroupId(fp));
}
[Fact]
public void DoGroupIdFingerprint_skip_carries_no_ownership_and_errors_never_dedupe()
{
var skipFp = StartupEnvironmentProbes.ComputeDoGroupIdFingerprint(new DoGroupIdSetResult
{
Skipped = true,
SkipReason = "mdm_policy_present",
});
Assert.Equal("skipped:mdm_policy_present", skipFp);
Assert.Null(StartupEnvironmentProbes.ExtractLastWrittenGroupId(skipFp));
// Errors: no fingerprint → the probe re-runs AND re-emits each failing start.
Assert.Null(StartupEnvironmentProbes.ComputeDoGroupIdFingerprint(new DoGroupIdSetResult { Error = "boom" }));
Assert.Null(StartupEnvironmentProbes.ExtractLastWrittenGroupId(null));
}
[Fact]
public async Task RunAsync_skips_geo_timezone_and_ntp_once_all_succeeded_only_power_state_emits()
{
@@ -109,6 +109,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
/// </summary>
public bool EnableTimezoneAutoSet { get; set; } = false;
/// <summary>
/// Whether to set the Delivery Optimization group ID (DOGroupId policy value) from a
/// deterministic network fingerprint (default gateway IP + MAC, RealmJoin-compatible).
/// Existing DOGroupId/DOGroupIdSource policies are never overwritten.
/// Default: false
/// </summary>
public bool EnableDoGroupIdAutoSet { get; set; } = false;
/// <summary>
/// Continue-Anyway observation mode (operator-set tenant opt-in, delivered via
/// remote config): stamped into the esp_config_detected decision-signal payload so
@@ -94,6 +94,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
// a tenant admin can clear a previously-applied custom NTP server.
agentConfig.NtpServer = remoteConfig.NtpServer;
agentConfig.EnableTimezoneAutoSet = remoteConfig.EnableTimezoneAutoSet;
agentConfig.EnableDoGroupIdAutoSet = remoteConfig.EnableDoGroupIdAutoSet;
// ---------------- Diagnostics — remote wins (including DiagnosticsUploadMode="Off"
// which must clear any CLI-initial default).
@@ -0,0 +1,681 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.Eventing.Reader;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Xml.Linq;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Newtonsoft.Json;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
{
/// <summary>
/// Watches the <c>System</c> event log for two enrollment blind spots and forwards them as
/// timeline ground truth:
/// <list type="bullet">
/// <item><b>System clock steps</b> (Kernel-General EventID 1) → <c>system_clock_changed</c>.
/// The payload carries <c>oldTime</c>/<c>newTime</c>/<c>timeDeltaMs</c> plus the
/// setting process — the "clock set from X to Y" fact that today has to be inferred
/// from timestamp discontinuities. w32time also logs frequent 1 ms micro-slews on the
/// same EventID, so emissions are gated on <see cref="MinClockDeltaMs"/>.</item>
/// <item><b>Completed sleep episodes</b> → <c>system_sleep_episode</c>. Classic S3/S4 comes
/// from Power-Troubleshooter EventID 1 (one event per episode at wake, carrying
/// SleepTime + WakeTime). Modern Standby comes from Kernel-Power EventID 507 (exit),
/// whose payload carries the scenario duration and the actual sleep residency; the
/// enter time is derived as exit − duration. Kernel-Power 506 (enter) is deliberately
/// NOT consumed: 507 already carries everything, and pairing 506/507 would need an
/// orphan-tolerant state machine across agent restarts for zero information gain —
/// an episode without a 507 never completed and would not be emitted under any design.</item>
/// </list>
/// <para>
/// <b>Payload times are authoritative.</b> The decisive clock correction (w32time fixing a
/// wrong BIOS clock) happens exactly when the device clock is untrustworthy, and the backend
/// clamps implausible event timestamps to server time. All semantic instants therefore travel
/// as explicit ISO-8601 payload fields, never only as the event's own <c>Timestamp</c>.
/// </para>
/// <para>
/// <b>Backfill + cross-restart dedup</b> mirror <see cref="WindowsUpdateTracker"/>: the System
/// .evtx persists across the OOBE reboots, so a startup scan recovers pre-agent clock steps
/// and standby episodes; a persisted RecordId watermark plus an intra-run seen-set (a HashSet,
/// NOT a high-water mark — see the field comment) keep restarts from re-emitting.
/// </para>
/// </summary>
internal sealed class SystemTimelineTracker : IDisposable
{
internal const string Channel = "System";
internal const string ProviderKernelGeneral = "Microsoft-Windows-Kernel-General"; // EventID 1: system time changed
internal const string ProviderPowerTroubleshooter = "Microsoft-Windows-Power-Troubleshooter"; // EventID 1: resumed from classic sleep/hibernate
internal const string ProviderKernelPower = "Microsoft-Windows-Kernel-Power"; // EventID 507: Modern Standby exit
internal const int EventId_ClockChange = 1;
internal const int EventId_ClassicResume = 1;
internal const int EventId_ModernStandbyExit = 507;
internal const string WatermarkStateFileName = "system-timeline-watermark.json";
// w32time logs Kernel-General 1 for routine 1 ms slew corrections (verified live: several
// per hour). Only genuine steps are timeline-relevant; anything below this is claimed in
// the dedup set but never emitted.
internal const long MinClockDeltaMs = 2000;
// A step of at least 5 minutes can invalidate token/cert validation windows and visibly
// reorders the timeline (matches the web's TimeJumpBadge threshold) — surface it fast.
internal const long ClockDeltaWarningMs = 5 * 60 * 1000;
// Modern Standby logs a 506/507 pair for every screen-off scenario, including seconds-long
// ones where the device never actually slept (SleepDurationInUs 0). Only episodes with
// real sleep residency are emitted. Classic Power-Troubleshooter episodes have no floor:
// they only exist for genuinely completed S3/S4 transitions.
internal const long MinSleepDurationSeconds = 60;
private readonly AgentLogger _logger;
private readonly string _sessionId;
private readonly string _tenantId;
private readonly InformationalEventPost _post;
private readonly bool _backfillEnabled;
private readonly int _backfillLookbackMinutes;
private readonly string _stateDirectory;
private EventLogWatcher _watcher;
// Dedup. Guarded because EventRecordWritten callbacks run concurrently with the backfill scan.
//
// Cross-restart boundary loaded from disk: the highest RecordId emitted in a PRIOR run.
// Immutable during this run — records at/below it were already emitted before this process
// started and must not be re-emitted by the backfill scan.
private long _restartWatermark = -1;
// RecordIds claimed in THIS run — the intra-run dedup set. Deliberately a HashSet, NOT a
// high-water mark: the live watcher is armed before the backfill runs, so a live record with
// a HIGHER RecordId can be processed before/during backfill. A high-water mark would then
// make the dedup skip every older, never-emitted backfill record — silently dropping exactly
// the pre-agent clock corrections and standby episodes this tracker exists for. The targeted
// records are low-volume, so the set stays tiny.
private readonly HashSet<long> _seenThisRun = new HashSet<long>();
// Highest RecordId claimed so far (max of restart watermark + this run). Persisted so the
// next run's restart watermark is correct.
private long _maxEmittedRecordId = -1;
private readonly object _watermarkLock = new object();
public SystemTimelineTracker(
string sessionId,
string tenantId,
InformationalEventPost post,
AgentLogger logger,
bool backfillEnabled = true,
int backfillLookbackMinutes = 1440,
string stateDirectory = null)
{
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
_post = post ?? throw new ArgumentNullException(nameof(post));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_backfillEnabled = backfillEnabled;
_backfillLookbackMinutes = backfillLookbackMinutes;
_stateDirectory = stateDirectory != null ? Environment.ExpandEnvironmentVariables(stateDirectory) : null;
}
public void Start()
{
LoadWatermark();
StartWatcher();
if (_backfillEnabled && _backfillLookbackMinutes > 0)
BackfillRecentEvents();
else
_logger.Info("SystemTimeline backfill disabled by config");
}
public void Stop()
{
if (_watcher == null) return;
try
{
_watcher.Enabled = false;
_watcher.Dispose();
_logger.Info("SystemTimeline watcher stopped");
}
catch (Exception ex)
{
_logger.Error("Error stopping SystemTimeline watcher", ex);
}
finally
{
_watcher = null;
}
}
public void Dispose() => Stop();
// -----------------------------------------------------------------------
// Watcher lifecycle
// -----------------------------------------------------------------------
private void StartWatcher()
{
try
{
var query = new EventLogQuery(Channel, PathType.LogName, BuildXPath());
_watcher = new EventLogWatcher(query);
_watcher.EventRecordWritten += OnEventRecordWritten;
_watcher.Enabled = true;
_logger.Info($"SystemTimeline watcher started: {Channel} (clock changes + sleep episodes)");
}
catch (EventLogNotFoundException)
{
_logger.Warning($"SystemTimeline event log not found: {Channel} (normal on non-Windows test environments)");
}
catch (UnauthorizedAccessException ex)
{
_logger.Warning($"SystemTimeline watcher access denied for {Channel}: {ex.Message}");
}
catch (Exception ex)
{
_logger.Error($"Failed to start SystemTimeline watcher for {Channel}", ex);
CollectorDegradationReporter.Report(_post, _sessionId, _tenantId,
collectorName: "SystemTimelineTracker", reason: $"watcher_arm_failed:{Channel}", ex: ex);
}
}
/// <summary>
/// Provider-qualified XPath for the three targeted signals. Provider filtering is mandatory
/// on the System channel — EventID 1 alone matches many unrelated providers.
/// Exposed as internal for tests.
/// </summary>
internal static string BuildXPath() =>
"*[System[" + ProviderIdClauses() + "]]";
/// <summary>Backfill variant of <see cref="BuildXPath"/> with the lookback window applied.</summary>
internal static string BuildBackfillXPath(long lookbackMs) =>
"*[System[(" + ProviderIdClauses() + $") and TimeCreated[timediff(@SystemTime) <= {lookbackMs}]]]";
private static string ProviderIdClauses() =>
$"(Provider[@Name='{ProviderKernelGeneral}'] and EventID={EventId_ClockChange})" +
$" or (Provider[@Name='{ProviderPowerTroubleshooter}'] and EventID={EventId_ClassicResume})" +
$" or (Provider[@Name='{ProviderKernelPower}'] and EventID={EventId_ModernStandbyExit})";
// -----------------------------------------------------------------------
// Event processing
// -----------------------------------------------------------------------
private void OnEventRecordWritten(object sender, EventRecordWrittenEventArgs e)
{
if (e.EventRecord == null) return;
try
{
ProcessRecord(e.EventRecord, isBackfill: false);
}
catch (Exception ex)
{
_logger.Error("Error processing SystemTimeline event", ex);
}
}
private void ProcessRecord(EventRecord record, bool isBackfill)
{
var recordId = record.RecordId ?? -1;
// Cheap dedup gate BEFORE the XML rendering work.
if (IsAlreadyProcessed(recordId))
return;
string xml = null;
try { xml = record.ToXml(); }
catch { /* fall through with empty EventData */ }
ProcessEvent(
providerName: record.ProviderName,
eventId: record.Id,
recordId: recordId,
timeCreatedUtc: record.TimeCreated?.ToUniversalTime(),
eventData: ParseEventData(xml),
isBackfill: isBackfill);
}
/// <summary>
/// Core processing extracted to primitive inputs so tests can drive it without synthesizing
/// an abstract, Windows-only <see cref="EventRecord"/> (WindowsUpdateTracker test-seam
/// pattern; a plain dictionary instead of named args because three providers with ~10
/// payload fields each make a fixed signature impractical).
/// </summary>
internal void ProcessEvent(
string providerName,
int eventId,
long recordId,
DateTime? timeCreatedUtc,
Dictionary<string, string> eventData,
bool isBackfill)
{
if (!MarkProcessed(recordId))
return; // already emitted (cross-restart or duplicate delivery)
eventData = eventData ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (string.Equals(providerName, ProviderKernelGeneral, StringComparison.OrdinalIgnoreCase) && eventId == EventId_ClockChange)
HandleClockChange(recordId, timeCreatedUtc, eventData, isBackfill);
else if (string.Equals(providerName, ProviderPowerTroubleshooter, StringComparison.OrdinalIgnoreCase) && eventId == EventId_ClassicResume)
HandleClassicResume(recordId, timeCreatedUtc, eventData, isBackfill);
else if (string.Equals(providerName, ProviderKernelPower, StringComparison.OrdinalIgnoreCase) && eventId == EventId_ModernStandbyExit)
HandleModernStandbyExit(recordId, timeCreatedUtc, eventData, isBackfill);
// Anything else slipped past the XPath filter — ignore.
}
// -----------------------------------------------------------------------
// system_clock_changed (Kernel-General EventID 1)
// -----------------------------------------------------------------------
private void HandleClockChange(long recordId, DateTime? timeCreatedUtc, Dictionary<string, string> eventData, bool isBackfill)
{
eventData.TryGetValue("NewTime", out var newTimeRaw);
eventData.TryGetValue("OldTime", out var oldTimeRaw);
var newTime = TryParseUtc(newTimeRaw);
var oldTime = TryParseUtc(oldTimeRaw);
// Signed delta preferably from the authoritative instants (NewTime − OldTime carries
// the direction unambiguously); TimeDeltaInMs is the fallback when either fails to
// parse. Records with neither are unusable and skipped (already claimed by dedup).
long deltaMs;
if (newTime.HasValue && oldTime.HasValue)
{
deltaMs = (long)Math.Round((newTime.Value - oldTime.Value).TotalMilliseconds);
}
else if (eventData.TryGetValue("TimeDeltaInMs", out var deltaRaw) &&
long.TryParse(deltaRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedDelta))
{
deltaMs = parsedDelta;
}
else
{
_logger.Debug($"SystemTimeline: clock-change record {recordId} has no usable times/delta — skipped");
return;
}
if (Math.Abs(deltaMs) < MinClockDeltaMs)
return; // w32time micro-slew — claimed (MarkProcessed) but never emitted
eventData.TryGetValue("Reason", out var reasonRaw);
int.TryParse(reasonRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var reason);
var reasonText = reason == 1 ? "application_set"
: reason == 2 ? "hardware_clock_sync"
: "unknown";
eventData.TryGetValue("ProcessName", out var processName);
var data = new Dictionary<string, object>
{
{ "timeDeltaMs", deltaMs },
{ "reason", reason },
{ "reasonText", reasonText },
{ "backfilled", isBackfill },
};
// Authoritative instants: re-normalized ISO-8601 when parseable, raw pass-through
// otherwise — the payload must survive even a malformed provider rendering.
data["newTime"] = newTime.HasValue ? newTime.Value.ToString("o") : (object)(newTimeRaw ?? string.Empty);
data["oldTime"] = oldTime.HasValue ? oldTime.Value.ToString("o") : (object)(oldTimeRaw ?? string.Empty);
if (recordId >= 0) data["recordId"] = recordId;
if (timeCreatedUtc.HasValue) data["timeCreated"] = timeCreatedUtc.Value.ToString("o");
if (!string.IsNullOrEmpty(processName)) data["processName"] = processName;
if (eventData.TryGetValue("ProcessID", out var pidRaw) &&
int.TryParse(pidRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var pid))
{
data["processId"] = pid;
}
var direction = deltaMs >= 0 ? "forward" : "backward";
var processLeaf = ProcessLeafName(processName);
var message = $"System clock stepped {direction} {FormatDuration(TimeSpan.FromMilliseconds(Math.Abs(deltaMs)))}" +
$" (reason: {reasonText}" + (processLeaf != null ? $", process: {processLeaf})" : ")");
var isLargeStep = Math.Abs(deltaMs) >= ClockDeltaWarningMs;
_post.Emit(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
// The record's own creation time, not UtcNow: backfilled pre-agent clock steps must
// land on the timeline where they happened (WindowsUpdateTracker convention).
Timestamp = timeCreatedUtc ?? DateTime.UtcNow,
EventType = Constants.EventTypes.SystemClockChanged,
Severity = isLargeStep ? EventSeverity.Warning : EventSeverity.Info,
Source = "SystemTimelineWatcher",
Phase = EnrollmentPhase.Unknown,
Message = message,
Data = data,
ImmediateUpload = isLargeStep,
});
}
// -----------------------------------------------------------------------
// system_sleep_episode — classic sleep/hibernate (Power-Troubleshooter EventID 1)
// -----------------------------------------------------------------------
private void HandleClassicResume(long recordId, DateTime? timeCreatedUtc, Dictionary<string, string> eventData, bool isBackfill)
{
eventData.TryGetValue("SleepTime", out var sleepRaw);
eventData.TryGetValue("WakeTime", out var wakeRaw);
var enteredAt = TryParseUtc(sleepRaw);
var exitedAt = TryParseUtc(wakeRaw);
// EffectiveState 5 = hibernate (S4); TargetState is the fallback when the effective
// state is missing (older event versions).
eventData.TryGetValue("EffectiveState", out var effectiveState);
eventData.TryGetValue("TargetState", out var targetState);
var stateValue = !string.IsNullOrEmpty(effectiveState) ? effectiveState : targetState;
var kind = stateValue == "5" ? "hibernate" : "sleep";
long? durationSeconds = null;
if (enteredAt.HasValue && exitedAt.HasValue && exitedAt.Value > enteredAt.Value)
durationSeconds = (long)Math.Round((exitedAt.Value - enteredAt.Value).TotalSeconds);
var data = new Dictionary<string, object>
{
{ "kind", kind },
{ "backfilled", isBackfill },
};
data["enteredAt"] = enteredAt.HasValue ? enteredAt.Value.ToString("o") : (object)(sleepRaw ?? string.Empty);
data["exitedAt"] = exitedAt.HasValue ? exitedAt.Value.ToString("o") : (object)(wakeRaw ?? string.Empty);
if (durationSeconds.HasValue) data["durationSeconds"] = durationSeconds.Value;
if (recordId >= 0) data["recordId"] = recordId;
if (timeCreatedUtc.HasValue) data["timeCreated"] = timeCreatedUtc.Value.ToString("o");
if (eventData.TryGetValue("WakeSourceType", out var wakeTypeRaw) &&
int.TryParse(wakeTypeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var wakeType))
{
data["wakeSourceType"] = wakeType;
}
eventData.TryGetValue("WakeSourceText", out var wakeText);
if (!string.IsNullOrEmpty(wakeText)) data["wakeSourceText"] = wakeText;
var durationLabel = durationSeconds.HasValue
? " after " + FormatDuration(TimeSpan.FromSeconds(durationSeconds.Value))
: string.Empty;
var wakeLabel = !string.IsNullOrEmpty(wakeText) ? $" (wake source: {wakeText})" : string.Empty;
EmitSleepEpisode(timeCreatedUtc, data,
$"Device resumed from {kind}{durationLabel}{wakeLabel}");
}
// -----------------------------------------------------------------------
// system_sleep_episode — Modern Standby (Kernel-Power EventID 507)
// -----------------------------------------------------------------------
private void HandleModernStandbyExit(long recordId, DateTime? timeCreatedUtc, Dictionary<string, string> eventData, bool isBackfill)
{
eventData.TryGetValue("SleepDurationInUs", out var sleepUsRaw);
long.TryParse(sleepUsRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var sleepUs);
var sleepDurationSeconds = sleepUs / 1_000_000;
// Every screen-off scenario logs a 507, including seconds-long ones where the device
// never slept (SleepDurationInUs 0, incl. SleepEntered=false) — one gate covers both.
if (sleepDurationSeconds < MinSleepDurationSeconds)
return; // claimed but not an episode worth the timeline
eventData.TryGetValue("DurationInUs", out var durationUsRaw);
long.TryParse(durationUsRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var durationUs);
if (durationUs < sleepUs) durationUs = sleepUs; // scenario duration can never undercut its sleep residency
var durationSeconds = durationUs / 1_000_000;
var exitedAt = timeCreatedUtc; // 507 is written at scenario exit
DateTime? enteredAt = exitedAt.HasValue
? exitedAt.Value.AddMilliseconds(-(durationUs / 1000.0))
: (DateTime?)null;
var data = new Dictionary<string, object>
{
{ "kind", "modern_standby" },
{ "durationSeconds", durationSeconds },
{ "sleepDurationSeconds", sleepDurationSeconds },
{ "backfilled", isBackfill },
};
if (enteredAt.HasValue) data["enteredAt"] = enteredAt.Value.ToString("o");
if (exitedAt.HasValue) data["exitedAt"] = exitedAt.Value.ToString("o");
if (recordId >= 0) data["recordId"] = recordId;
if (timeCreatedUtc.HasValue) data["timeCreated"] = timeCreatedUtc.Value.ToString("o");
if (eventData.TryGetValue("Reason", out var reasonRaw) &&
int.TryParse(reasonRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var reason))
{
data["reason"] = reason;
}
bool? onAcPower = null;
if (eventData.TryGetValue("PowerStateAc", out var acRaw) && bool.TryParse(acRaw, out var ac))
{
onAcPower = ac;
data["onAcPower"] = ac;
}
if (eventData.TryGetValue("BatteryRemainingCapacityOnExit", out var battRaw) &&
long.TryParse(battRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var batt))
{
data["batteryRemainingCapacityOnExit"] = batt;
}
var powerLabel = onAcPower.HasValue ? (onAcPower.Value ? ", on AC" : ", on battery") : string.Empty;
EmitSleepEpisode(timeCreatedUtc, data,
$"Device exited Modern Standby after {FormatDuration(TimeSpan.FromSeconds(durationSeconds))}" +
$" (slept {FormatDuration(TimeSpan.FromSeconds(sleepDurationSeconds))}{powerLabel})");
}
private void EmitSleepEpisode(DateTime? timeCreatedUtc, Dictionary<string, object> data, string message)
{
_post.Emit(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
// Wake/exit moment (the record's creation time) — backfilled pre-agent episodes
// must land on the timeline where the device actually woke.
Timestamp = timeCreatedUtc ?? DateTime.UtcNow,
EventType = Constants.EventTypes.SystemSleepEpisode,
Severity = EventSeverity.Info,
Source = "SystemTimelineWatcher",
Phase = EnrollmentPhase.Unknown,
Message = message,
Data = data,
ImmediateUpload = false,
});
}
// -----------------------------------------------------------------------
// Parsing helpers
// -----------------------------------------------------------------------
/// <summary>
/// Extracts <c>&lt;Data Name="..."&gt;value&lt;/Data&gt;</c> pairs from an event's rendered
/// XML, keyed case-insensitively. Namespace-agnostic (matches on local name). Returns an
/// empty map on null/malformed XML — never throws.
/// </summary>
internal static Dictionary<string, string> ParseEventData(string xml)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrEmpty(xml)) return result;
try
{
var doc = XDocument.Parse(xml);
foreach (var el in doc.Descendants().Where(e => e.Name.LocalName == "Data"))
{
var nameAttr = el.Attribute("Name");
if (nameAttr == null || string.IsNullOrEmpty(nameAttr.Value)) continue;
if (!result.ContainsKey(nameAttr.Value))
result[nameAttr.Value] = el.Value;
}
}
catch
{
// Malformed / unexpected XML — best effort, return what we have.
}
return result;
}
internal static DateTime? TryParseUtc(string value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
return DateTime.TryParse(value, CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed)
? parsed
: (DateTime?)null;
}
/// <summary>Compact human duration for messages: "2h 05m", "56m 12s", "45s".</summary>
internal static string FormatDuration(TimeSpan span)
{
if (span.TotalHours >= 1)
return $"{(long)span.TotalHours}h {span.Minutes:D2}m";
if (span.TotalMinutes >= 1)
return $"{span.Minutes}m {span.Seconds:D2}s";
return $"{(long)Math.Ceiling(span.TotalSeconds)}s";
}
/// <summary>Leaf file name of a kernel device path like <c>\Device\HarddiskVolume3\Windows\System32\svchost.exe</c>.</summary>
internal static string ProcessLeafName(string devicePath)
{
if (string.IsNullOrEmpty(devicePath)) return null;
var idx = devicePath.LastIndexOf('\\');
var leaf = idx >= 0 ? devicePath.Substring(idx + 1) : devicePath;
return string.IsNullOrEmpty(leaf) ? null : leaf;
}
// -----------------------------------------------------------------------
// Backfill
// -----------------------------------------------------------------------
private void BackfillRecentEvents()
{
try
{
var lookbackMs = (long)_backfillLookbackMinutes * 60 * 1000;
_logger.Info($"SystemTimeline backfill: scanning {Channel} " +
$"(lookback={_backfillLookbackMinutes}min, restartWatermark={_restartWatermark})");
var query = new EventLogQuery(Channel, PathType.LogName, BuildBackfillXPath(lookbackMs));
int processed = 0;
using (var reader = new EventLogReader(query))
{
EventRecord record;
while ((record = reader.ReadEvent()) != null)
{
using (record)
{
ProcessRecord(record, isBackfill: true);
processed++;
}
}
}
if (processed == 0)
_logger.Debug($"SystemTimeline backfill: no targeted events in last {_backfillLookbackMinutes} minutes");
else
_logger.Info($"SystemTimeline backfill: scanned {processed} record(s)");
}
catch (EventLogNotFoundException)
{
_logger.Warning($"SystemTimeline event log not found during backfill: {Channel} (normal on non-Windows test environments)");
}
catch (UnauthorizedAccessException ex)
{
_logger.Warning($"SystemTimeline backfill access denied: {ex.Message}");
}
catch (Exception ex)
{
_logger.Warning($"SystemTimeline backfill failed: {ex.Message}");
}
}
// -----------------------------------------------------------------------
// Watermark dedup (cross-restart)
// -----------------------------------------------------------------------
private bool IsAlreadyProcessed(long recordId)
{
if (recordId < 0) return false; // no RecordId → cannot dedup, let it through
lock (_watermarkLock)
{
return recordId <= _restartWatermark || _seenThisRun.Contains(recordId);
}
}
/// <summary>
/// Atomically claims <paramref name="recordId"/>. Returns true if this is the first time we
/// see it (caller should evaluate/emit), false if it was already processed — either in a
/// PRIOR run (at/below the restart watermark) or already this run (in the seen-set). Only
/// advances + persists the max on a genuinely-new, higher RecordId. Records without a
/// RecordId (-1) are always processed, never tracked or persisted. Note: threshold-suppressed
/// records are claimed too, so a restart's backfill does not re-evaluate them.
/// </summary>
private bool MarkProcessed(long recordId)
{
if (recordId < 0) return true;
long toPersist = -1;
lock (_watermarkLock)
{
if (recordId <= _restartWatermark || !_seenThisRun.Add(recordId))
return false;
if (recordId > _maxEmittedRecordId)
{
_maxEmittedRecordId = recordId;
toPersist = _maxEmittedRecordId;
}
}
if (toPersist >= 0) PersistWatermark(toPersist);
return true;
}
internal void LoadWatermark()
{
if (string.IsNullOrEmpty(_stateDirectory)) return;
var filePath = Path.Combine(_stateDirectory, WatermarkStateFileName);
if (!File.Exists(filePath)) return;
try
{
var json = File.ReadAllText(filePath);
var state = JsonConvert.DeserializeObject<WatermarkState>(json);
if (state != null)
{
lock (_watermarkLock)
{
_restartWatermark = state.LastRecordId;
_maxEmittedRecordId = state.LastRecordId;
}
_logger.Info($"SystemTimeline watermark loaded: lastRecordId={state.LastRecordId} (persisted {state.PersistedUtc:O})");
}
}
catch (Exception ex)
{
_logger.Warning($"Failed to load SystemTimeline watermark: {ex.Message}");
}
}
private void PersistWatermark(long recordId)
{
if (string.IsNullOrEmpty(_stateDirectory)) return;
try
{
Directory.CreateDirectory(_stateDirectory);
var filePath = Path.Combine(_stateDirectory, WatermarkStateFileName);
var state = new WatermarkState { LastRecordId = recordId, PersistedUtc = DateTime.UtcNow };
var json = JsonConvert.SerializeObject(state, Formatting.Indented);
var tempPath = filePath + ".tmp";
File.WriteAllText(tempPath, json);
File.Copy(tempPath, filePath, overwrite: true);
try { File.Delete(tempPath); } catch { }
}
catch (Exception ex)
{
_logger.Warning($"Failed to persist SystemTimeline watermark: {ex.Message}");
}
}
internal sealed class WatermarkState
{
public long LastRecordId { get; set; }
public DateTime PersistedUtc { get; set; }
}
}
}
@@ -0,0 +1,64 @@
#nullable enable
using System;
using System.Net;
using System.Net.Sockets;
using System.Runtime.InteropServices;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
{
/// <summary>
/// ARP resolution via <c>iphlpapi!SendARP</c> — resolves the MAC address of an on-link IPv4
/// host (in practice: the default gateway). IPv4 only by API design; the OS answers from its
/// ARP cache or sends a real ARP request, so the target must be on the local subnet.
/// </summary>
internal static class ArpNativeMethods
{
[DllImport("iphlpapi.dll", ExactSpelling = true)]
private static extern int SendARP(uint destIP, uint srcIP, byte[] macAddress, ref uint macAddressLength);
/// <summary>
/// Fail-soft MAC resolution. Returns false (with a diagnostic in <paramref name="error"/>)
/// for non-IPv4 addresses, SendARP failures, or unexpected reply lengths — never throws.
/// </summary>
internal static bool TryResolveMac(IPAddress? ipv4Address, out byte[] mac, out string? error)
{
mac = new byte[6];
error = null;
try
{
if (ipv4Address == null || ipv4Address.AddressFamily != AddressFamily.InterNetwork)
{
error = "not an IPv4 address";
return false;
}
// SendARP takes the IPv4 address as a uint in network byte order (first octet in
// the lowest byte).
var addressBytes = ipv4Address.GetAddressBytes();
uint dest = ((uint)addressBytes[3] << 24)
| ((uint)addressBytes[2] << 16)
| ((uint)addressBytes[1] << 8)
| addressBytes[0];
uint len = (uint)mac.Length;
var err = SendARP(dest, 0, mac, ref len);
if (err != 0)
{
error = $"SendARP returned {err}";
return false;
}
if (len != 6)
{
error = $"SendARP returned unexpected MAC length {len}";
return false;
}
return true;
}
catch (Exception ex)
{
error = ex.Message;
return false;
}
}
}
}
@@ -0,0 +1,68 @@
#nullable enable
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring
{
/// <summary>
/// Shared "which NIC is the device actually using" heuristic: Up, not Loopback/Tunnel,
/// carries a non-0.0.0.0 default gateway. Fail-soft — any enumeration error yields null.
/// </summary>
internal static class NetworkInterfaceLocator
{
/// <summary>
/// Finds the active network interface: Up, not Loopback/Tunnel, has a non-0.0.0.0 gateway.
/// </summary>
internal static NetworkInterface? FindActiveNetworkInterface()
{
try
{
var interfaces = NetworkInterface.GetAllNetworkInterfaces();
foreach (var nic in interfaces)
{
if (nic.OperationalStatus != OperationalStatus.Up)
continue;
if (nic.NetworkInterfaceType == NetworkInterfaceType.Loopback ||
nic.NetworkInterfaceType == NetworkInterfaceType.Tunnel)
continue;
var ipProps = nic.GetIPProperties();
foreach (var gw in ipProps.GatewayAddresses)
{
if (gw.Address.ToString() != "0.0.0.0")
return nic;
}
}
}
catch
{
// Caller handles null
}
return null;
}
/// <summary>First IPv4 default gateway of the interface (0.0.0.0 excluded), or null.</summary>
internal static IPAddress? GetIpv4Gateway(NetworkInterface nic)
{
try
{
foreach (var gw in nic.GetIPProperties().GatewayAddresses)
{
var address = gw?.Address;
if (address != null
&& address.AddressFamily == AddressFamily.InterNetwork
&& address.ToString() != "0.0.0.0")
{
return address;
}
}
}
catch
{
// Caller handles null
}
return null;
}
}
}
@@ -922,36 +922,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
}
}
/// <summary>
/// Finds the active network interface: Up, not Loopback/Tunnel, has a non-0.0.0.0 gateway.
/// </summary>
private static NetworkInterface FindActiveNetworkInterface()
{
try
{
var interfaces = NetworkInterface.GetAllNetworkInterfaces();
foreach (var nic in interfaces)
{
if (nic.OperationalStatus != OperationalStatus.Up)
continue;
if (nic.NetworkInterfaceType == NetworkInterfaceType.Loopback ||
nic.NetworkInterfaceType == NetworkInterfaceType.Tunnel)
continue;
var ipProps = nic.GetIPProperties();
foreach (var gw in ipProps.GatewayAddresses)
{
if (gw.Address.ToString() != "0.0.0.0")
return nic;
}
}
}
catch
{
// Caller handles null
}
return null;
}
private static NetworkInterface FindActiveNetworkInterface() =>
Monitoring.NetworkInterfaceLocator.FindActiveNetworkInterface();
/// <summary>
/// Collects WiFi signal info via the native WLAN API (<see cref="WifiInfoReader"/>,
@@ -420,36 +420,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
// Helpers
// -------------------------------------------------------------------
/// <summary>
/// Finds the active network interface: Up, not Loopback/Tunnel, has a non-0.0.0.0 gateway.
/// </summary>
private static NetworkInterface FindActiveNetworkInterface()
{
try
{
var interfaces = NetworkInterface.GetAllNetworkInterfaces();
foreach (var nic in interfaces)
{
if (nic.OperationalStatus != OperationalStatus.Up)
continue;
if (nic.NetworkInterfaceType == NetworkInterfaceType.Loopback ||
nic.NetworkInterfaceType == NetworkInterfaceType.Tunnel)
continue;
var ipProps = nic.GetIPProperties();
foreach (var gw in ipProps.GatewayAddresses)
{
if (gw.Address.ToString() != "0.0.0.0")
return nic;
}
}
}
catch
{
// Caller handles null
}
return null;
}
private static NetworkInterface FindActiveNetworkInterface() =>
NetworkInterfaceLocator.FindActiveNetworkInterface();
private static string BuildChangeMessage(NetworkStateSnapshot before, NetworkStateSnapshot after)
{
@@ -0,0 +1,217 @@
#nullable enable
using System;
using System.Net;
using System.Net.Sockets;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic;
using Microsoft.Win32;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
{
public class DoGroupIdSetResult
{
/// <summary>The DOGroupId value is the computed one after this run (written or already there).</summary>
public bool Success { get; set; }
/// <summary>True when this run actually wrote the registry value (false for the idempotent no-op).</summary>
public bool Written { get; set; }
/// <summary>Deliberate non-action (existing foreign policy/value, no usable network) — not an error.</summary>
public bool Skipped { get; set; }
public string? SkipReason { get; set; }
public string? GroupId { get; set; }
public string? PreviousValue { get; set; }
public string? GatewayIp { get; set; }
public string? GatewayMac { get; set; }
public string? Error { get; set; }
}
internal enum DoGroupIdAction
{
Write,
NoOp,
SkipForeign,
}
/// <summary>
/// Sets the Delivery Optimization group ID policy value (<c>DOGroupId</c> under the GPO
/// DeliveryOptimization key) from a network fingerprint: a deterministic GUID derived from the
/// default gateway's IPv4 address and MAC address, so all devices behind the same gateway land
/// in the same DO peering group. The byte layout is RealmJoin-compatible — devices managed by
/// RealmJoin on the same network compute the identical GUID and peer with ours.
/// <para>
/// Deliberately conservative where RealmJoin is not: no random-GUID fallback (a per-device
/// random ID would isolate the device in a one-member group — worse than the OS default
/// grouping), and existing foreign configuration always wins: a DOGroupIdSource policy, an
/// MDM-managed DOGroupId, or a GPO value we did not write ourselves all result in a skip.
/// Never throws.
/// </para>
/// </summary>
public static class DoGroupIdService
{
internal const string GroupIdValueName = "DOGroupId";
internal const string GroupIdSourceValueName = "DOGroupIdSource";
// RealmJoin's fingerprint-GUID prefix — part of the compatible byte layout, do not change.
private static readonly byte[] GuidPrefix = { 101, 48, 6 };
/// <summary>
/// Computes and applies the fingerprint GroupId. <paramref name="lastWrittenGroupId"/> is
/// the GUID a previous agent run wrote (null when none is recorded) — it lets us update our
/// own value after a gateway change while never touching a foreign one.
/// </summary>
public static DoGroupIdSetResult TrySetGroupId(string? lastWrittenGroupId, AgentLogger logger)
{
var result = new DoGroupIdSetResult();
try
{
// ---- Network fingerprint -------------------------------------------------
var nic = NetworkInterfaceLocator.FindActiveNetworkInterface();
var gateway = nic == null ? null : NetworkInterfaceLocator.GetIpv4Gateway(nic);
if (gateway == null)
{
result.Skipped = true;
result.SkipReason = "no_ipv4_gateway";
logger.Info("DO GroupId auto-set: no active interface with an IPv4 default gateway — skipping.");
return result;
}
result.GatewayIp = gateway.ToString();
if (!ArpNativeMethods.TryResolveMac(gateway, out var mac, out var arpError))
{
result.Skipped = true;
result.SkipReason = "arp_failed";
result.Error = arpError;
logger.Warning($"DO GroupId auto-set: could not resolve gateway MAC ({arpError}) — skipping.");
return result;
}
result.GatewayMac = BitConverter.ToString(mac);
var computed = ComputeGroupId(gateway.GetAddressBytes(), mac).ToString();
result.GroupId = computed;
// ---- Foreign-configuration checks ----------------------------------------
// DOGroupIdSource (either store) selects a dynamic group source — a fingerprint
// GroupId would fight it. An MDM-managed DOGroupId means Intune owns the setting.
if (ReadPolicyValue(DoThrottlePolicyReader.GpoKeyPath, GroupIdSourceValueName) != null
|| ReadPolicyValue(DoThrottlePolicyReader.MdmKeyPath, GroupIdSourceValueName) != null)
{
result.Skipped = true;
result.SkipReason = "group_id_source_policy";
logger.Info("DO GroupId auto-set: DOGroupIdSource policy is configured — skipping.");
return result;
}
if (ReadPolicyValue(DoThrottlePolicyReader.MdmKeyPath, GroupIdValueName) != null)
{
result.Skipped = true;
result.SkipReason = "mdm_policy_present";
logger.Info("DO GroupId auto-set: DOGroupId is managed via MDM policy — skipping.");
return result;
}
// ---- Ownership decision ---------------------------------------------------
var existing = ReadPolicyValue(DoThrottlePolicyReader.GpoKeyPath, GroupIdValueName) as string;
result.PreviousValue = existing;
switch (DecideAction(existing, computed, lastWrittenGroupId))
{
case DoGroupIdAction.NoOp:
result.Success = true;
logger.Info($"DO GroupId auto-set: already set to {computed} — nothing to do.");
return result;
case DoGroupIdAction.SkipForeign:
result.Skipped = true;
result.SkipReason = "foreign_value_present";
logger.Info($"DO GroupId auto-set: existing DOGroupId '{existing}' was not written by us — skipping.");
return result;
}
// ---- Write + readback -----------------------------------------------------
using (var hklm = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64))
using (var key = hklm.CreateSubKey(DoThrottlePolicyReader.GpoKeyPath))
{
key.SetValue(GroupIdValueName, computed, RegistryValueKind.String);
}
var readback = ReadPolicyValue(DoThrottlePolicyReader.GpoKeyPath, GroupIdValueName) as string;
if (!string.Equals(readback, computed, StringComparison.OrdinalIgnoreCase))
{
result.Error = $"readback mismatch: wrote '{computed}', read '{readback ?? "<null>"}'";
logger.Warning($"DO GroupId auto-set failed: {result.Error}");
return result;
}
result.Success = true;
result.Written = true;
logger.Info($"DO GroupId auto-set: DOGroupId set to {computed} (gateway {result.GatewayIp}, previous: {existing ?? "<none>"})");
return result;
}
catch (Exception ex)
{
result.Error = ex.Message;
logger.Warning($"DO GroupId auto-set failed: {ex.Message}");
return result;
}
}
/// <summary>
/// RealmJoin-compatible fingerprint GUID: 16 bytes laid out as
/// prefix {101,48,6} + {0,0,0} + {ip[1],0,ip[2],ip[3]} + mac[6], passed to
/// <c>new Guid(byte[])</c>. The IP byte shuffle mirrors RealmJoin's "swap for
/// new Guid()" step — the layout must stay bit-identical for cross-product peering.
/// </summary>
internal static Guid ComputeGroupId(byte[] ipv4Bytes, byte[] macBytes)
{
if (ipv4Bytes == null || ipv4Bytes.Length != 4)
throw new ArgumentException("IPv4 address bytes required", nameof(ipv4Bytes));
if (macBytes == null || macBytes.Length != 6)
throw new ArgumentException("6-byte MAC required", nameof(macBytes));
var bytes = new byte[16];
Array.Copy(GuidPrefix, 0, bytes, 0, GuidPrefix.Length);
// bytes[3..5] stay zero (RealmJoin's zero padding)
bytes[6] = ipv4Bytes[1];
// bytes[7] stays zero
bytes[8] = ipv4Bytes[2];
bytes[9] = ipv4Bytes[3];
Array.Copy(macBytes, 0, bytes, 10, macBytes.Length);
return new Guid(bytes);
}
/// <summary>
/// Pure ownership decision. GUID comparison tolerates case and braces so a value written
/// by another tool in "{...}" form still matches semantically.
/// </summary>
internal static DoGroupIdAction DecideAction(string? existing, string computed, string? lastWrittenByUs)
{
if (string.IsNullOrEmpty(existing))
return DoGroupIdAction.Write;
if (GuidEquals(existing, computed))
return DoGroupIdAction.NoOp;
if (GuidEquals(existing, lastWrittenByUs))
return DoGroupIdAction.Write; // our own stale value — gateway changed
return DoGroupIdAction.SkipForeign;
}
private static bool GuidEquals(string? a, string? b)
{
if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return false;
return Guid.TryParse(a, out var ga) && Guid.TryParse(b, out var gb) && ga == gb;
}
private static object? ReadPolicyValue(string keyPath, string valueName)
{
// Registry64 explicitly — SOFTWARE\Policies is WOW64-redirected, and the policy
// stores live in the 64-bit view regardless of the agent's bitness.
using (var hklm = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64))
using (var key = hklm.OpenSubKey(keyPath))
{
return key?.GetValue(valueName);
}
}
}
}
@@ -557,6 +557,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
stateDirectory: _stateDirectory));
}
// System timeline watcher — System channel: Kernel-General 1 (system clock stepped,
// ground truth for time jumps incl. old/new time + setting process) and completed
// sleep/standby episodes (Power-Troubleshooter 1, Kernel-Power 507). Backfill recovers
// the pre-agent OOBE clock correction and standby episodes from the persistent .evtx.
if (collectors.EnableSystemTimelineWatcher)
{
hosts.Add(new SystemTimelineWatcherHost(
sessionId: sessionId,
tenantId: tenantId,
logger: logger,
ingress: ingress,
clock: clock,
backfillLookbackMinutes: collectors.SystemEventBackfillLookbackMinutes,
stateDirectory: _stateDirectory));
}
// ----- Peripheral hosts (event-only; driven by remote-config toggles) --------------
// V1 parity (PeriodicCollectorManager) — combine Performance + AgentSelfMetrics under
@@ -0,0 +1,68 @@
#nullable enable
using System;
using System.Threading;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
namespace AutopilotMonitor.Agent.V2.Core.Orchestration
{
/// <summary>
/// Standalone host for the <see cref="SystemTimelineTracker"/> — watches the <c>System</c>
/// event log for system clock steps (Kernel-General 1) and completed sleep/standby episodes
/// (Power-Troubleshooter 1 / Kernel-Power 507) and forwards them via the shared single-rail
/// ingress. Mirrors <see cref="WindowsUpdateWatcherHost"/>: builds its own
/// <see cref="InformationalEventPost"/> and delegates Start/Stop to the tracker.
/// </summary>
internal sealed class SystemTimelineWatcherHost : ICollectorHost
{
public string Name => "SystemTimelineTracker";
private readonly SystemTimelineTracker _tracker;
private readonly AgentLogger _logger;
private int _disposed;
public SystemTimelineWatcherHost(
string sessionId,
string tenantId,
AgentLogger logger,
ISignalIngressSink ingress,
IClock clock,
int backfillLookbackMinutes,
string stateDirectory)
{
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
if (clock == null) throw new ArgumentNullException(nameof(clock));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
var post = new InformationalEventPost(ingress, clock, logger);
_tracker = new SystemTimelineTracker(
sessionId: sessionId,
tenantId: tenantId,
post: post,
logger: logger,
backfillEnabled: backfillLookbackMinutes > 0,
backfillLookbackMinutes: backfillLookbackMinutes,
stateDirectory: stateDirectory);
}
public void Start()
{
_tracker.Start();
_logger.Info("SystemTimelineWatcherHost: started.");
}
public void Stop()
{
try { _tracker.Stop(); }
catch (Exception ex) { _logger.Warning($"SystemTimelineWatcherHost: stop failed: {ex.Message}"); }
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
Stop();
}
}
}
@@ -61,6 +61,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
// detection — the exact scenario the power watcher exists to observe.
SharedConstants.EventTypes.PowerStateChange,
// Same reasoning for the system timeline watcher: a clock step, a wake from standby,
// or a backfilled episode at agent start is environment observation and must not
// reset the idle/stall clocks.
SharedConstants.EventTypes.SystemClockChanged,
SharedConstants.EventTypes.SystemSleepEpisode,
// Agent health / control / transport — not device/enrollment progress.
SharedConstants.EventTypes.CollectorDegraded,
SharedConstants.EventTypes.TelemetryUploadPoisoned,
@@ -124,6 +124,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Persistence
}
}
/// <summary>
/// Read-only peek of the key's current fingerprint (possibly restored from a previous
/// agent run), or null when none is recorded. Like <see cref="HasFingerprint"/> this makes
/// NO claim — use it when the fingerprint carries state the caller needs to read back
/// (e.g. the DO GroupId ownership marker).
/// </summary>
public string? GetFingerprint(string key)
{
if (string.IsNullOrEmpty(key)) return null;
lock (_lock)
{
return _entries.TryGetValue(key, out var entry) ? entry.Fingerprint : null;
}
}
/// <summary>Retry-until-success: true when <see cref="MarkSucceeded"/> latched this key in any run.</summary>
public bool AlreadySucceeded(string key)
{
@@ -25,6 +25,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Runtime
/// Always emitted — offset &gt;60s is Warning, smaller is Info, failure is Warning.</item>
/// <item><b>power_state_check</b> — AC vs. battery snapshot via Win32 <c>GetSystemPowerStatus</c>.
/// On battery with &lt;80% charge is Warning, otherwise Info, probe failure is Warning.</item>
/// <item><b>do_group_id_auto_set</b> — only when <c>EnableDoGroupIdAutoSet=true</c>. Sets the
/// Delivery Optimization <c>DOGroupId</c> policy value from a network fingerprint via
/// <see cref="DoGroupIdService"/>; skips (Info) rather than fight foreign policy values.</item>
/// </list>
/// <para>
/// Each probe runs best-effort: failures are logged + optionally emitted as a Warning event,
@@ -78,6 +81,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Runtime
await RunNtpCheck(configuration, logger, post, gate).ConfigureAwait(false);
}
if (configuration.EnableDoGroupIdAutoSet)
RunDoGroupIdAutoSet(configuration, logger, post, gate);
RunPowerStateCheck(configuration, logger, post);
}
@@ -331,6 +337,99 @@ namespace AutopilotMonitor.Agent.V2.Core.Runtime
};
}
/// <summary>
/// One-shot DO GroupId apply with change-based restart dedup: the gate fingerprint doubles
/// as the ownership marker ("written:&lt;guid&gt;" records the GUID we last wrote, so a later
/// run can distinguish our own stale value from a foreign one after a gateway change).
/// Unchanged outcomes (same GUID, same skip reason) re-run silently without a duplicate
/// event; errors never claim a fingerprint so each failing start keeps its retry trail.
/// </summary>
private static void RunDoGroupIdAutoSet(
AgentConfiguration configuration,
AgentLogger logger,
Orchestration.InformationalEventPost post,
Persistence.StartupEventGate? gate)
{
try
{
var lastWritten = ExtractLastWrittenGroupId(gate?.GetFingerprint(Constants.EventTypes.DoGroupIdAutoSet));
var result = DoGroupIdService.TrySetGroupId(lastWritten, logger);
var fingerprint = ComputeDoGroupIdFingerprint(result);
if (fingerprint != null && gate != null
&& !gate.ShouldEmit(Constants.EventTypes.DoGroupIdAutoSet, fingerprint))
{
logger.Debug("StartupEnvironmentProbes: DO GroupId outcome unchanged — skipping duplicate event (restart dedup).");
return;
}
SafeEmit(post, logger, BuildDoGroupIdEvent(configuration, result));
if (fingerprint != null)
gate?.MarkEmitted(Constants.EventTypes.DoGroupIdAutoSet);
}
catch (Exception ex)
{
logger.Warning($"StartupEnvironmentProbes: DO GroupId auto-set threw: {ex.Message}");
}
}
internal const string DoGroupIdWrittenFingerprintPrefix = "written:";
/// <summary>
/// Success (written or already correct) fingerprints as "written:&lt;guid&gt;", a skip as
/// "skipped:&lt;reason&gt;"; errors return null so they are never deduped.
/// </summary>
internal static string? ComputeDoGroupIdFingerprint(DoGroupIdSetResult result)
{
if (result.Success) return DoGroupIdWrittenFingerprintPrefix + result.GroupId;
if (result.Skipped) return "skipped:" + result.SkipReason;
return null;
}
/// <summary>Ownership marker readback: the GUID from a "written:" fingerprint, else null.</summary>
internal static string? ExtractLastWrittenGroupId(string? fingerprint) =>
fingerprint != null && fingerprint.StartsWith(DoGroupIdWrittenFingerprintPrefix, StringComparison.Ordinal)
? fingerprint.Substring(DoGroupIdWrittenFingerprintPrefix.Length)
: null;
internal static EnrollmentEvent BuildDoGroupIdEvent(AgentConfiguration configuration, DoGroupIdSetResult result)
{
string message;
if (result.Success && result.Written)
message = $"Delivery Optimization GroupId set to {result.GroupId} (gateway {result.GatewayIp})";
else if (result.Success)
message = $"Delivery Optimization GroupId already set to {result.GroupId}";
else if (result.Skipped)
message = $"Delivery Optimization GroupId not set: {result.SkipReason}";
else
message = $"Delivery Optimization GroupId auto-set failed: {result.Error}";
return new EnrollmentEvent
{
SessionId = configuration.SessionId,
TenantId = configuration.TenantId,
EventType = Constants.EventTypes.DoGroupIdAutoSet,
Severity = result.Success || result.Skipped ? EventSeverity.Info : EventSeverity.Warning,
Source = "StartupEnvironmentProbes",
Phase = EnrollmentPhase.Unknown,
Timestamp = DateTime.UtcNow,
Message = message,
Data = new Dictionary<string, object>
{
{ "groupId", result.GroupId ?? "" },
{ "previousValue", result.PreviousValue ?? "" },
{ "gatewayIp", result.GatewayIp ?? "" },
{ "gatewayMac", result.GatewayMac ?? "" },
{ "written", result.Written },
{ "skipped", result.Skipped },
{ "skipReason", result.SkipReason ?? "" },
{ "success", result.Success },
{ "error", result.Error ?? "" },
},
};
}
private static void RunPowerStateCheck(
AgentConfiguration configuration,
AgentLogger logger,
@@ -54,9 +54,13 @@ public class GraphFeatureCatalogSyncTests
.Select(m => m.Groups["f"].Value)
.ToArray();
// 'All' is a script-side meta-value that expands to every catalog feature — it is not
// (and must never become) a GraphFeatureCatalog entry, but the script must keep offering it.
Assert.Contains("All", scriptFeatures);
Assert.Equal(
GraphFeatureCatalog.Features.OrderBy(f => f, StringComparer.OrdinalIgnoreCase),
scriptFeatures.OrderBy(f => f, StringComparer.OrdinalIgnoreCase));
scriptFeatures.Where(f => f != "All").OrderBy(f => f, StringComparer.OrdinalIgnoreCase));
}
private static string ReadGrantScript()
@@ -0,0 +1,137 @@
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// ANALYZE-DEV-012 — pins the firing behaviour of the built-in that consumes the agent's
/// <c>system_sleep_episode</c> events (SystemTimelineWatcherHost): informational, fires once
/// (trigger single) when a completed sleep/hibernate/Modern-Standby episode of at least
/// 5 minutes is observed. Single-required-condition on durationSeconds so the match is pinned
/// to one event instance and the interpolated duration is the matched value. Also pins that the
/// agent's numeric payload (long) satisfies the rule's string "300" via numeric gte.
/// </summary>
public class SleepEpisodeRuleTests
{
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
private const string SessionId = "66666666-7777-8888-9999-aaaaaaaaaaaa";
[Fact]
public async Task ANALYZE_DEV_012_fires_info_on_a_five_minute_episode()
{
var rule = BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == "ANALYZE-DEV-012");
Assert.True(rule.Enabled);
Assert.Equal("info", rule.Severity);
Assert.False(rule.MarkSessionAsFailedDefault); // environment observation, never a KO verdict
var events = new List<EnrollmentEvent>
{
SleepEpisode(durationSeconds: 300, kind: "modern_standby"),
};
var outcome = await RunAsync(rule, events);
var result = Assert.Single(outcome.Results);
Assert.Equal("ANALYZE-DEV-012", result.RuleId);
Assert.Equal("info", result.Severity);
// durationSeconds is the matched condition's dataField, so {{durationSeconds}} in the
// explanation interpolates the matched value (the DEV-009 whitelist lesson).
var matched = AsDict(result.MatchedConditions["slept_during_enrollment"]);
Assert.Equal("durationSeconds", AsString(matched["field"]));
}
[Theory]
[InlineData(60)] // real but short episode — below the rule's 5-minute floor
[InlineData(299)] // boundary: gte 300 must not fire at 299
public async Task ANALYZE_DEV_012_does_not_fire_below_the_five_minute_floor(long durationSeconds)
{
var rule = BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == "ANALYZE-DEV-012");
var events = new List<EnrollmentEvent>
{
SleepEpisode(durationSeconds, kind: "sleep"),
};
var outcome = await RunAsync(rule, events);
Assert.Empty(outcome.Results);
}
[Fact]
public async Task ANALYZE_DEV_012_does_not_fire_on_a_clock_change_event()
{
// The sibling watcher event carries a large timeDeltaMs but is a different event type —
// the rule must key on system_sleep_episode only.
var rule = BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == "ANALYZE-DEV-012");
var events = new List<EnrollmentEvent>
{
new()
{
EventId = Guid.NewGuid().ToString(),
TenantId = TenantId,
SessionId = SessionId,
EventType = "system_clock_changed",
Timestamp = DateTime.UtcNow,
Sequence = 7,
Data = new Dictionary<string, object>
{
["timeDeltaMs"] = 7_200_000L,
["durationSeconds"] = 900L, // adversarial: same field name on the wrong type
["reason"] = 1,
},
},
};
var outcome = await RunAsync(rule, events);
Assert.Empty(outcome.Results);
}
// ===== Event builder — mirrors the SystemTimelineTracker emit shape =====
private static EnrollmentEvent SleepEpisode(long durationSeconds, string kind) => new()
{
EventId = Guid.NewGuid().ToString(),
TenantId = TenantId,
SessionId = SessionId,
EventType = "system_sleep_episode",
Timestamp = DateTime.UtcNow,
Sequence = 42,
Data = new Dictionary<string, object>
{
["kind"] = kind,
["enteredAt"] = DateTime.UtcNow.AddSeconds(-durationSeconds).ToString("o"),
["exitedAt"] = DateTime.UtcNow.ToString("o"),
["durationSeconds"] = durationSeconds,
["backfilled"] = false,
}
};
private static Dictionary<string, object> AsDict(object o)
{
if (o is Dictionary<string, object> d) return d;
throw new InvalidOperationException($"Expected Dictionary<string,object>, got {o?.GetType().Name ?? "null"}");
}
private static string AsString(object o) => o?.ToString() ?? string.Empty;
private static async Task<AnalysisOutcome> RunAsync(AnalyzeRule rule, List<EnrollmentEvent> events)
{
var ruleRepo = new Mock<IRuleRepository>();
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync("global")).ReturnsAsync(new List<AnalyzeRule> { rule });
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync(TenantId)).ReturnsAsync(new List<AnalyzeRule>());
ruleRepo.Setup(r => r.GetRuleStatesAsync(It.IsAny<string>())).ReturnsAsync(new Dictionary<string, RuleState>());
ruleRepo.Setup(r => r.GetRuleResultsAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(new List<RuleResult>());
var sessionRepo = new Mock<ISessionRepository>();
sessionRepo.Setup(s => s.GetSessionEventsStrictAsync(TenantId, SessionId, It.IsAny<int>())).ReturnsAsync(events);
var ruleService = new AnalyzeRuleService(ruleRepo.Object, NullLogger<AnalyzeRuleService>.Instance);
var engine = new RuleEngine(ruleService, ruleRepo.Object, sessionRepo.Object, NullLogger<SleepEpisodeRuleTests>.Instance);
return await engine.AnalyzeSessionAsync(TenantId, SessionId);
}
}
@@ -120,6 +120,31 @@ public class TenantConfigTableSerializationTests
Assert.Null(mapped.AbsoluteMaxSessionHours);
}
[Fact]
public void Roundtrip_EnableDoGroupIdAutoSet_SurvivesStoreAndMap()
{
var config = new TenantConfiguration
{
TenantId = TenantId,
DomainName = "contoso.com",
UpdatedBy = "admin@contoso.com",
EnableDoGroupIdAutoSet = true
};
var mapped = TableConfigRepository.ConvertFromTenantTableEntity(
TableConfigRepository.ConvertToTenantTableEntity(config));
Assert.True(mapped.EnableDoGroupIdAutoSet);
}
[Fact]
public void Map_LegacyRow_WithoutDoGroupIdColumn_ReadsNull()
{
// null = "not set" → GetAgentConfigFunction serves the agent default (false).
var entity = new TableEntity(TenantId, "config") { { "DomainName", "fabrikam.com" } };
Assert.Null(TableConfigRepository.ConvertFromTenantTableEntity(entity).EnableDoGroupIdAutoSet);
}
[Fact]
public void Roundtrip_NotificationChannelsJson_SurvivesStoreAndMap()
{
@@ -499,6 +499,136 @@ public class TimeAttributionCalculatorTests
Assert.Equal(1200 - 300, b.GetSegmentTotals()[TimeAttributionSegments.EspApps]);
}
// ── sleep spans: payload instants are authoritative, annotation not partition ─────────
[Fact]
public void SleepSpan_ComesFromPayloadInstants_NotFromEventTimestamp()
{
var enteredAt = T0.AddMinutes(8);
var exitedAt = T0.AddMinutes(14);
var events = new List<EnrollmentEvent>
{
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
Evt(T0.AddMinutes(5), "esp_phase_changed", EnrollmentPhase.AppsDevice),
// Emitted at wake (its Timestamp is the wake moment, possibly clamped) — only the
// payload carries the true episode bounds.
Evt(T0.AddMinutes(14.2), "system_sleep_episode", data: new Dictionary<string, object>
{
["kind"] = "modern_standby",
["enteredAt"] = enteredAt.ToString("o"),
["exitedAt"] = exitedAt.ToString("o"),
["durationSeconds"] = 360L,
}),
Evt(T0.AddMinutes(20), "enrollment_complete"),
};
var b = TimeAttributionCalculator.Compute(
Input(events, completedAt: T0.AddMinutes(20), durationSeconds: 1200))!;
AssertExactPartition(b); // sleep is an annotation — the wall-clock partition is untouched
var span = Assert.Single(b.SleepSpans);
Assert.Equal(enteredAt, span.StartUtc);
Assert.Equal(exitedAt, span.EndUtc);
Assert.Equal(360, span.Seconds);
Assert.Equal(360, b.SleepSeconds);
Assert.Equal("modern_standby", span.Kind);
Assert.Equal(TimeAttributionSegments.EspApps, span.SegmentKey);
}
[Fact]
public void SleepSpan_DuplicateEpisode_NotDoubleCounted()
{
var enteredAt = T0.AddMinutes(8);
var exitedAt = T0.AddMinutes(10);
Dictionary<string, object> Episode() => new Dictionary<string, object>
{
["kind"] = "sleep",
["enteredAt"] = enteredAt.ToString("o"),
["exitedAt"] = exitedAt.ToString("o"),
};
var events = new List<EnrollmentEvent>
{
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
// Same episode observed twice (e.g. backfilled again after an agent restart from a
// different event-log record).
Evt(T0.AddMinutes(10.1), "system_sleep_episode", data: Episode()),
Evt(T0.AddMinutes(12), "system_sleep_episode", data: Episode()),
Evt(T0.AddMinutes(20), "enrollment_complete"),
};
var b = TimeAttributionCalculator.Compute(
Input(events, completedAt: T0.AddMinutes(20), durationSeconds: 1200))!;
Assert.Single(b.SleepSpans);
Assert.Equal(120, b.SleepSeconds);
}
[Fact]
public void SleepSpan_MissingOrUnparseablePayload_Ignored()
{
var events = new List<EnrollmentEvent>
{
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
Evt(T0.AddMinutes(5), "system_sleep_episode", data: new Dictionary<string, object>
{
["kind"] = "sleep",
["enteredAt"] = "not-a-timestamp",
["exitedAt"] = T0.AddMinutes(5).ToString("o"),
}),
Evt(T0.AddMinutes(6), "system_sleep_episode", data: new Dictionary<string, object>
{
["kind"] = "sleep", // bounds missing entirely
}),
// exitedAt <= enteredAt — pathological, no claim.
Evt(T0.AddMinutes(7), "system_sleep_episode", data: new Dictionary<string, object>
{
["kind"] = "sleep",
["enteredAt"] = T0.AddMinutes(7).ToString("o"),
["exitedAt"] = T0.AddMinutes(6).ToString("o"),
}),
Evt(T0.AddMinutes(20), "enrollment_complete"),
};
var b = TimeAttributionCalculator.Compute(
Input(events, completedAt: T0.AddMinutes(20), durationSeconds: 1200))!;
Assert.Empty(b.SleepSpans);
Assert.Equal(0, b.SleepSeconds);
}
[Fact]
public void WhiteGlove_SleepAcrossPause_CountsOnlyInWindowFlanks()
{
var part1End = T0.AddMinutes(20);
var resumedAt = T0.AddDays(2);
var completedAt = resumedAt.AddMinutes(10);
var events = new List<EnrollmentEvent>
{
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
Evt(T0.AddMinutes(2), "esp_phase_changed", EnrollmentPhase.AppsDevice),
Evt(part1End, "whiteglove_part1_complete"),
// Sealed device slept through the pause: episode starts 1 min before the seal and
// ends 30 s into part 2 — only the in-window flanks may count (90 s), never the
// ~2-day pause (RebootSpan precedent).
Evt(resumedAt.AddSeconds(35), "system_sleep_episode", data: new Dictionary<string, object>
{
["kind"] = "hibernate",
["enteredAt"] = part1End.AddMinutes(-1).ToString("o"),
["exitedAt"] = resumedAt.AddSeconds(30).ToString("o"),
}),
Evt(completedAt, "enrollment_complete"),
};
var b = TimeAttributionCalculator.Compute(
Input(events, completedAt, durationSeconds: 1800, wg: true, resumedAt: resumedAt))!;
AssertExactPartition(b);
var span = Assert.Single(b.SleepSpans);
Assert.Equal(90, span.Seconds);
Assert.Equal(90, b.SleepSeconds);
}
// ── WhiteGlove pause vs. intervals/reboots (Codex review P1): the pause lies INSIDE
// the windows' hull, so hull-clamping alone would count it — a reboot bracketing the
// pause must contribute only its in-window flanks, never the pause itself ─────────────
@@ -46,6 +46,11 @@ public class TimeAttributionPersistenceAndAggregationTests
{
new() { StartUtc = T0.AddMinutes(10), EndUtc = T0.AddMinutes(14), Seconds = 240, SegmentKey = TimeAttributionSegments.EspApps },
},
SleepSeconds = 360,
SleepSpans = new List<SleepSpan>
{
new() { StartUtc = T0.AddMinutes(20), EndUtc = T0.AddMinutes(26), Seconds = 360, SegmentKey = TimeAttributionSegments.EspApps, Kind = "modern_standby" },
},
BlockingApps = new List<BlockingAppInterval>
{
new() { AppId = AppX, AppName = "App X", StartUtc = T0.AddMinutes(6), EndUtc = T0.AddMinutes(16), Seconds = 600 },
@@ -77,6 +82,13 @@ public class TimeAttributionPersistenceAndAggregationTests
Assert.Equal(TimeAttributionSegments.EspApps, reboot.SegmentKey);
Assert.Equal(240, reboot.Seconds);
Assert.Equal(360, mapped.SleepSeconds);
var sleep = Assert.Single(mapped.SleepSpans);
Assert.Equal("modern_standby", sleep.Kind);
Assert.Equal(TimeAttributionSegments.EspApps, sleep.SegmentKey);
Assert.Equal(T0.AddMinutes(20), sleep.StartUtc);
Assert.Equal(360, sleep.Seconds);
var app = Assert.Single(mapped.BlockingApps);
Assert.Equal(AppX, app.AppId);
Assert.Equal("App X", app.AppName);
@@ -620,6 +620,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{ "RebootDelaySeconds", config.RebootDelaySeconds },
{ "EnableGeoLocation", config.EnableGeoLocation },
{ "EnableTimezoneAutoSet", config.EnableTimezoneAutoSet },
{ "EnableDoGroupIdAutoSet", config.EnableDoGroupIdAutoSet },
{ "NtpServer", config.NtpServer },
{ "EnableImeMatchLog", config.EnableImeMatchLog },
{ "EnableGatherRuleDebugLog", config.EnableGatherRuleDebugLog },
@@ -727,6 +728,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
RebootDelaySeconds = entity.GetInt32("RebootDelaySeconds"),
EnableGeoLocation = entity.GetBoolean("EnableGeoLocation"),
EnableTimezoneAutoSet = entity.GetBoolean("EnableTimezoneAutoSet"),
EnableDoGroupIdAutoSet = entity.GetBoolean("EnableDoGroupIdAutoSet"),
NtpServer = string.IsNullOrWhiteSpace(entity.GetString("NtpServer")) ? "time.windows.com" : entity.GetString("NtpServer"),
EnableImeMatchLog = entity.GetBoolean("EnableImeMatchLog"),
EnableGatherRuleDebugLog = entity.GetBoolean("EnableGatherRuleDebugLog"),
@@ -274,7 +274,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new AgentConfigResponse
{
ConfigVersion = 38, // Collectors.EnablePowerStateWatcher (live AC/battery watcher)
ConfigVersion = 40, // EnableDoGroupIdAutoSet (Delivery Optimization group ID from network fingerprint)
UploadIntervalSeconds = Shared.Constants.DefaultUploadIntervalSeconds,
SelfDestructOnComplete = tenantConfig.SelfDestructOnComplete ?? true,
KeepLogFile = tenantConfig.KeepLogFile ?? false,
@@ -312,6 +312,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
AllowAgentDowngrade = adminConfig.AllowAgentDowngrade,
NtpServer = string.IsNullOrEmpty(tenantConfig.NtpServer) ? "time.windows.com" : tenantConfig.NtpServer,
EnableTimezoneAutoSet = tenantConfig.EnableTimezoneAutoSet ?? false,
EnableDoGroupIdAutoSet = tenantConfig.EnableDoGroupIdAutoSet ?? false,
SendTraceEvents = tenantConfig.SendTraceEvents,
UnrestrictedMode = TenantEntitlementService.IsUnrestrictedModeActive(tenantConfig, DateTime.UtcNow),
GatherRules = gatherRules,
@@ -72,7 +72,9 @@ public static class TimeAttributionCalculator
/// <summary>Bump on any semantic change so aggregates never mix definitions (truthfulness rule 8).</summary>
// v2: PriorEnrollmentResidue flag — sessions whose phase anchors are distorted by
// pre-enrollment state on disk are now flagged and excluded from fleet segment stats.
public const int CurrentVersion = 2;
// v3: SleepSpans — completed sleep/standby episodes (system_sleep_episode) as cross-cutting
// annotations mirroring RebootSpans; the maintenance sweep recomputes older rows.
public const int CurrentVersion = 3;
/// <summary>
/// Enrollment class for fleet aggregation — classes are NEVER mixed in one aggregate (a
@@ -168,6 +170,7 @@ public static class TimeAttributionCalculator
}
var rebootSpans = BuildRebootSpans(events, windows, spans);
var sleepSpans = BuildSleepSpans(events, windows, spans);
if (events.Any(e => e.EventType == Constants.EventTypes.AgentLateStart))
flags |= TimeAttributionFlags.PartialObservation;
@@ -191,6 +194,8 @@ public static class TimeAttributionCalculator
UnattributedSeconds = unattributed,
RebootSeconds = rebootSpans.Sum(r => r.Seconds),
RebootSpans = rebootSpans,
SleepSeconds = sleepSpans.Sum(s => s.Seconds),
SleepSpans = sleepSpans,
BlockingApps = blockingApps
.OrderByDescending(a => a.Seconds)
.ThenBy(a => a.AppName, StringComparer.OrdinalIgnoreCase)
@@ -709,6 +714,63 @@ public static class TimeAttributionCalculator
return result;
}
// ── sleep spans ─────────────────────────────────────────────────────────
/// <summary>
/// One span per completed sleep episode (<c>system_sleep_episode</c>). The payload's
/// <c>enteredAt</c>/<c>exitedAt</c> are the authoritative instants — the event's own
/// timestamp is stamped at wake and may have been clamped server-side. Structural clone of
/// <see cref="BuildRebootSpans"/>: spans are clipped to the observation hull (a WhiteGlove
/// pause episode contributes nothing), dedup'd on the enter instant (an episode straddling
/// an agent restart can be backfilled twice from different records), and annotate the
/// segment they started in — they are never a slice of the wall-clock partition.
/// </summary>
private static List<SleepSpan> BuildSleepSpans(
List<EnrollmentEvent> events, List<Window> windows, List<TimeAttributionSpan> spans)
{
var result = new List<SleepSpan>();
var seenEpisodes = new HashSet<DateTime>();
foreach (var evt in events)
{
if (evt.EventType != Constants.EventTypes.SystemSleepEpisode || evt.Data == null) continue;
var enteredAt = ParseUtcInstant(evt.Data, "enteredAt");
var exitedAt = ParseUtcInstant(evt.Data, "exitedAt");
if (!enteredAt.HasValue || !exitedAt.HasValue || exitedAt.Value <= enteredAt.Value) continue;
if (!seenEpisodes.Add(enteredAt.Value)) continue; // duplicate observation of the same episode
var clamped = ClampToWindows(enteredAt.Value, exitedAt.Value, windows);
if (!clamped.HasValue) continue;
var seconds = clamped.Value.Seconds;
if (seconds <= 0) continue;
var kind = evt.Data.TryGetValue("kind", out var kindObj) ? kindObj?.ToString() ?? string.Empty : string.Empty;
result.Add(new SleepSpan
{
StartUtc = clamped.Value.Start,
EndUtc = clamped.Value.End,
Seconds = seconds,
SegmentKey = SegmentAt(spans, clamped.Value.Start),
Kind = kind,
});
}
return result.OrderBy(s => s.StartUtc).ToList();
}
private static DateTime? ParseUtcInstant(Dictionary<string, object> data, string key)
{
if (!data.TryGetValue(key, out var raw)) return null;
var text = raw?.ToString();
if (string.IsNullOrEmpty(text)) return null;
return DateTime.TryParse(text, CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed)
? parsed
: (DateTime?)null;
}
private static string SegmentAt(List<TimeAttributionSpan> spans, DateTime ts)
{
foreach (var span in spans)
@@ -381,6 +381,10 @@ namespace AutopilotMonitor.Functions.Services
// Environment observation (battery drain / AC flap), not enrollment progress —
// must not count as "real activity" that heals a Stalled session.
"power_state_change" => true,
// System timeline watcher: a clock step or a completed standby episode is environment
// observation too — a device that slept through its stall must stay Stalled.
"system_clock_changed" => true,
"system_sleep_episode" => true,
_ => false
};
@@ -117,10 +117,12 @@ namespace AutopilotMonitor.Functions.Services
["WallClockSeconds"] = b.WallClockSeconds,
["UnattributedSeconds"] = b.UnattributedSeconds,
["RebootSeconds"] = b.RebootSeconds,
["SleepSeconds"] = b.SleepSeconds,
["BlockingAppCount"] = b.BlockingAppCount,
["QualityFlags"] = (int)b.QualityFlags,
["SegmentsJson"] = JsonSerializer.Serialize(b.Segments),
["RebootSpansJson"] = JsonSerializer.Serialize(b.RebootSpans),
["SleepSpansJson"] = JsonSerializer.Serialize(b.SleepSpans),
["BlockingAppsJson"] = JsonSerializer.Serialize(b.BlockingApps),
["ComputedAt"] = DateTime.UtcNow,
};
@@ -142,11 +144,13 @@ namespace AutopilotMonitor.Functions.Services
WallClockSeconds = entity.GetInt32("WallClockSeconds") ?? 0,
UnattributedSeconds = entity.GetInt32("UnattributedSeconds") ?? 0,
RebootSeconds = entity.GetInt32("RebootSeconds") ?? 0,
SleepSeconds = entity.GetInt32("SleepSeconds") ?? 0,
BlockingAppCount = entity.GetInt32("BlockingAppCount") ?? 0,
EspAppsOccupancySeconds = entity.GetInt32("EspAppsOccupancySeconds"),
QualityFlags = (TimeAttributionFlags)(entity.GetInt32("QualityFlags") ?? 0),
Segments = DeserializeJsonColumn<TimeAttributionSpan>(entity.GetString("SegmentsJson")),
RebootSpans = DeserializeJsonColumn<RebootSpan>(entity.GetString("RebootSpansJson")),
SleepSpans = DeserializeJsonColumn<SleepSpan>(entity.GetString("SleepSpansJson")),
BlockingApps = DeserializeJsonColumn<BlockingAppInterval>(entity.GetString("BlockingAppsJson")),
};
}
@@ -108,9 +108,11 @@ export const EVENT_TYPES_CATALOG = {
'device_location',
'outbound_ip',
'timezone_auto_set',
'do_group_id_auto_set',
'ntp_time_check',
'power_state_check',
'power_state_change',
'system_clock_changed',
],
identity_events: [
'aad_placeholder_user_detected',
@@ -156,6 +158,7 @@ export const EVENT_TYPES_CATALOG = {
'ime_session_change',
'ime_process_exited',
'system_reboot_detected',
'system_sleep_episode',
'prior_run_died_with_state',
'previous_crash_detected',
'agent_emergency_break',
@@ -29,7 +29,7 @@ export const KEY_EVENT_TYPES = new Set([
'desktop_arrived', 'hello_policy_detected', 'waiting_for_hello', 'hello_completion_timeout',
'agent_started', 'agent_shutdown', 'agent_shutting_down', 'agent_trace',
'script_started', 'script_completed', 'script_failed', 'historic_ime_replay_detected',
'vulnerability_report',
'vulnerability_report', 'system_sleep_episode',
]);
// Phase-defining events promoted to the top of the triage timeline. Module-level
// (not handler-local) so the same drift test can validate it.
@@ -560,9 +560,12 @@ namespace AutopilotMonitor.Shared
public const string DeviceLocation = "device_location";
public const string OutboundIp = "outbound_ip";
public const string TimezoneAutoSet = "timezone_auto_set";
public const string DoGroupIdAutoSet = "do_group_id_auto_set";
public const string NtpTimeCheck = "ntp_time_check";
public const string PowerStateCheck = "power_state_check";
public const string PowerStateChange = "power_state_change";
public const string SystemClockChanged = "system_clock_changed"; // Kernel-General EventID 1: OS clock stepped (oldTime/newTime/timeDeltaMs in Data are authoritative — the event's own Timestamp may be clamped server-side).
public const string SystemSleepEpisode = "system_sleep_episode"; // Completed sleep/hibernate/modern-standby episode (Power-Troubleshooter 1 / Kernel-Power 507), emitted retroactively at wake.
public const string AgentTrace = "agent_trace";
// Crash / process lifecycle
@@ -225,6 +225,14 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public bool EnableTimezoneAutoSet { get; set; } = false;
/// <summary>
/// Whether to set the Delivery Optimization group ID (DOGroupId policy value) from a
/// deterministic network fingerprint (default gateway IP + MAC, RealmJoin-compatible
/// byte layout). Existing DOGroupId/DOGroupIdSource policies are never overwritten.
/// Default: false
/// </summary>
public bool EnableDoGroupIdAutoSet { get; set; } = false;
/// <summary>
/// Control-channel mirror of the telemetry-response block signal: the calling device
/// (serial) or agent version matched an active admin Block/Kill rule. Delivered on the
@@ -530,6 +538,30 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public int MdmRebootPolicyBackfillLookbackMinutes { get; set; } = 60;
// -----------------------------------------------------------------------
// System timeline watcher (System channel: clock changes + sleep episodes)
// -----------------------------------------------------------------------
/// <summary>
/// Master switch for the system timeline watcher. When enabled the agent subscribes to the
/// System event log for Kernel-General EventID 1 (system clock stepped — ground truth for
/// time jumps incl. old/new time, delta and the setting process) and completed sleep
/// episodes (Power-Troubleshooter EventID 1 for classic sleep/hibernate, Kernel-Power
/// EventID 507 for Modern Standby) and emits system_clock_changed / system_sleep_episode
/// events. Explains timeline gaps and time jumps that are otherwise blind spots.
/// Default: true.
/// </summary>
public bool EnableSystemTimelineWatcher { get; set; } = true;
/// <summary>
/// Lookback window in minutes for the system timeline backfill scan on startup. The
/// decisive clock correction (w32time fixing a wrong BIOS clock) and pre-agent standby
/// episodes happen early in OOBE, often before the agent starts; the System .evtx persists
/// across reboots, so a generous backfill recovers them. Bounded well inside the backend's
/// 168 h past-tolerance clamp. 0 = backfill disabled. Default: 1440 minutes (24 h).
/// </summary>
public int SystemEventBackfillLookbackMinutes { get; set; } = 1440;
/// <summary>
/// Creates default collector configuration
/// </summary>
@@ -366,6 +366,16 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public bool? EnableTimezoneAutoSet { get; set; }
/// <summary>
/// Whether to set the Delivery Optimization group ID (DOGroupId policy value) from a
/// network fingerprint: a deterministic GUID derived from the default gateway's IP and
/// MAC address, so devices on the same local network peer with each other (byte layout
/// is RealmJoin-compatible). Only takes effect with DO Download Mode = Group (2);
/// existing DOGroupId/DOGroupIdSource policies (Intune/GPO) are never overwritten.
/// null = use agent default (false).
/// </summary>
public bool? EnableDoGroupIdAutoSet { get; set; }
/// <summary>
/// Whether to write a match log for every IME log line matched by a pattern.
/// When true, the agent writes to the default path Constants.ImeMatchLogPath.
@@ -147,6 +147,27 @@ namespace AutopilotMonitor.Shared.Models
public string SegmentKey { get; set; } = string.Empty;
}
/// <summary>
/// One completed sleep episode observed during the session (<c>system_sleep_episode</c>
/// payload — <c>enteredAt</c>/<c>exitedAt</c> are the authoritative instants, never the
/// event's own timestamp, which is stamped at wake and may be clamped). Cross-cutting
/// annotation like <see cref="RebootSpan"/>: sleep overlaps the segment it started in and is
/// NOT part of the wall-clock partition — the session really took that long; the span
/// explains where the time went.
/// </summary>
public class SleepSpan
{
public DateTime StartUtc { get; set; }
public DateTime EndUtc { get; set; }
public int Seconds { get; set; }
/// <summary>Segment the episode started in (<see cref="TimeAttributionSegments"/> key, or "unattributed").</summary>
public string SegmentKey { get; set; } = string.Empty;
/// <summary>Episode kind from the agent payload: "sleep", "hibernate" or "modern_standby".</summary>
public string Kind { get; set; } = string.Empty;
}
/// <summary>
/// Per-session enrollment time attribution (F1, insights spec) — an exact partition of the
/// session's authoritative wall clock into named segments plus an explicit unattributed
@@ -187,6 +208,11 @@ namespace AutopilotMonitor.Shared.Models
public List<RebootSpan> RebootSpans { get; set; } = new List<RebootSpan>();
/// <summary>Total observed in-window sleep seconds. Cross-cutting annotation like <see cref="RebootSeconds"/> — the wall clock keeps the pause; this discloses it.</summary>
public int SleepSeconds { get; set; }
public List<SleepSpan> SleepSpans { get; set; } = new List<SleepSpan>();
/// <summary>
/// Install intervals of ESP-blocking apps (positive-evidence join against the latest
/// <c>esp_config_detected</c> lists), top 20 by duration. <see cref="BlockingAppCount"/>
+9
View File
@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
@@ -57,6 +57,7 @@ interface TenantConfig {
rebootDelaySeconds?: number | null;
enableGeoLocation?: boolean | null;
enableTimezoneAutoSet?: boolean | null;
enableDoGroupIdAutoSet?: boolean | null;
ntpServer?: string | null;
enableImeMatchLog?: boolean | null;
enableGatherRuleDebugLog?: boolean | null;
@@ -131,6 +132,7 @@ const DEFAULTS: Record<string, unknown> = {
rebootDelaySeconds: null,
enableGeoLocation: null,
enableTimezoneAutoSet: null,
enableDoGroupIdAutoSet: null,
ntpServer: 'time.windows.com',
enableImeMatchLog: null,
enableGatherRuleDebugLog: null,
@@ -171,6 +173,7 @@ const RUNTIME_DEFAULTS: Record<string, unknown> = {
keepLogFile: false,
enableGeoLocation: true,
enableTimezoneAutoSet: false,
enableDoGroupIdAutoSet: false,
ntpServer: 'time.windows.com',
enableImeMatchLog: false,
enableGatherRuleDebugLog: false,
@@ -301,6 +304,7 @@ function computeRuntime(c: TenantConfig): Record<string, unknown> {
keepLogFile: c.keepLogFile ?? false,
enableGeoLocation: c.enableGeoLocation ?? true,
enableTimezoneAutoSet: c.enableTimezoneAutoSet ?? false,
enableDoGroupIdAutoSet: c.enableDoGroupIdAutoSet ?? false,
ntpServer: c.ntpServer ?? 'time.windows.com',
enableImeMatchLog: c.enableImeMatchLog ?? false,
enableGatherRuleDebugLog: c.enableGatherRuleDebugLog ?? false,
@@ -587,6 +591,7 @@ export function SectionTenantConfigReport() {
<ConfigRow label="Reboot Delay (sec)" value={config.rebootDelaySeconds} configKey="rebootDelaySeconds" defaults={DEFAULTS} />
<ConfigRow label="Geo-Location" value={config.enableGeoLocation} configKey="enableGeoLocation" defaults={DEFAULTS} />
<ConfigRow label="Timezone Auto-Set" value={config.enableTimezoneAutoSet} configKey="enableTimezoneAutoSet" defaults={DEFAULTS} />
<ConfigRow label="DO GroupId Auto-Set" value={config.enableDoGroupIdAutoSet} configKey="enableDoGroupIdAutoSet" defaults={DEFAULTS} />
<ConfigRow label="NTP Server" value={config.ntpServer} configKey="ntpServer" defaults={DEFAULTS} />
<ConfigRow label="IME Match Log" value={config.enableImeMatchLog} configKey="enableImeMatchLog" defaults={DEFAULTS} />
<ConfigRow label="Gather Rule Debug Log" value={config.enableGatherRuleDebugLog} configKey="enableGatherRuleDebugLog" defaults={DEFAULTS} />
@@ -661,6 +666,7 @@ export function SectionTenantConfigReport() {
<ConfigRow label="Reboot Delay (sec)" value={runtime.rebootDelaySeconds} configKey="rebootDelaySeconds" defaults={RUNTIME_DEFAULTS} />
<ConfigRow label="Geo-Location" value={runtime.enableGeoLocation} configKey="enableGeoLocation" defaults={RUNTIME_DEFAULTS} />
<ConfigRow label="Timezone Auto-Set" value={runtime.enableTimezoneAutoSet} configKey="enableTimezoneAutoSet" defaults={RUNTIME_DEFAULTS} />
<ConfigRow label="DO GroupId Auto-Set" value={runtime.enableDoGroupIdAutoSet} configKey="enableDoGroupIdAutoSet" defaults={RUNTIME_DEFAULTS} />
<ConfigRow label="NTP Server" value={runtime.ntpServer} configKey="ntpServer" defaults={RUNTIME_DEFAULTS} />
<ConfigRow label="IME Match Log" value={runtime.enableImeMatchLog} configKey="enableImeMatchLog" defaults={RUNTIME_DEFAULTS} />
<ConfigRow label="Gather Rule Debug Log" value={runtime.enableGatherRuleDebugLog} configKey="enableGatherRuleDebugLog" defaults={RUNTIME_DEFAULTS} />
@@ -154,6 +154,10 @@ export const KNOWN_EVENT_TYPES: EventTypeEntry[] = [
description: "Network state changed (online/offline/interface)." },
{ value: "power_state_change", label: "power_state_change", category: "diagnostics",
description: "Live AC/battery change during enrollment (WMI push, no polling): transition=ac_to_battery (Warning) / battery_to_ac (Info) / threshold_crossed with thresholdPercent 50/30/15 (each level latched once per session). Payload carries onAcPower, batteryPercent, isCharging, batteryLifeMinutes. Never emitted on devices without a battery." },
{ value: "system_clock_changed", label: "system_clock_changed", category: "diagnostics",
description: "The OS clock was stepped (System log, Kernel-General EventID 1) — ground truth for time jumps: oldTime/newTime (ISO-8601, authoritative — the event's own timestamp may be clamped), signed timeDeltaMs, reason/reasonText (application_set / hardware_clock_sync), processName/processId of the setter. Steps below 2 s (w32time micro-slews) are suppressed; steps of 5+ min are Warning with immediate upload. Backfilled from before agent start (backfilled=true) — catches the early-OOBE w32time correction of a wrong BIOS clock." },
{ value: "system_sleep_episode", label: "system_sleep_episode", category: "diagnostics",
description: "A completed sleep episode, emitted retroactively at wake: kind=sleep/hibernate (Power-Troubleshooter EventID 1, carries wakeSourceType/wakeSourceText) or modern_standby (Kernel-Power EventID 507, carries sleepDurationSeconds, reason, onAcPower, batteryRemainingCapacityOnExit). Payload enteredAt/exitedAt (ISO-8601, authoritative) and durationSeconds explain timeline gaps and inflated wall-clock durations. Modern-standby episodes under 60 s of real sleep are suppressed; pre-agent episodes are backfilled (backfilled=true)." },
{ value: "network_bandwidth_estimate", label: "network_bandwidth_estimate", category: "diagnostics",
description: "Session-level internet-bandwidth estimate derived passively from the Delivery Optimization byte counters (no synthetic traffic). At most twice per session: an interim snapshot after DeviceSetup (snapshotTrigger=device_setup_end — survives account-phase starvation) and the authoritative final at enrollment end (collector_stop). p90/max Mbit/s for internet-path (HTTP + internet peers) and LAN sources (peers/Connected Cache) separately, plus bucket (<10 / 10-50 / 50-100 / 100-250 / 250+) and confidence." },
{ value: "vulnerability_report", label: "vulnerability_report", category: "diagnostics",
@@ -0,0 +1,151 @@
"use client";
import { DeviceStatus, DeviceNetworkStatus, DevicePowerStatus } from "../hooks/deviceStatus";
/**
* Taskbar-style power/network status chips for the progress page header.
*
* Values are "last reported by the agent", not continuously sampled: power
* events ride the normal batched upload cadence (only warning-level
* transitions upload immediately), so AC/battery flips can lag by tens of
* seconds — and an agent that dies cannot report going offline.
*/
function BatteryIcon({ percent, low }: { percent: number | null; low: boolean }) {
// Fill width scales into the 13-unit body (x 4..17); null percent renders outline only.
const fillWidth = percent === null ? 0 : Math.max(0, Math.min(13, (13 * percent) / 100));
return (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<rect x="2.5" y="7.5" width="16" height="9" rx="2" />
<path strokeLinecap="round" d="M21 10.5v3" />
{fillWidth > 0 && (
<rect
x="4.5"
y="9.5"
width={fillWidth}
height="5"
rx="0.5"
fill="currentColor"
stroke="none"
className={low ? "text-red-600" : ""}
/>
)}
</svg>
);
}
function BoltIcon() {
return (
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 24 24" stroke="none">
<path d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
);
}
function WifiIcon({ signalPercent, offline }: { signalPercent?: number; offline?: boolean }) {
// Arc tiers mirror the taskbar: unknown signal renders as fully connected.
const tier = offline ? 0 : signalPercent === undefined ? 3 : signalPercent >= 66 ? 3 : signalPercent >= 33 ? 2 : signalPercent > 0 ? 1 : 0;
return (
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
>
<path d="M2.25 8.51a13.5 13.5 0 0 1 19.5 0" className={tier >= 3 ? "" : "opacity-25"} />
<path d="M5.13 11.7a9.38 9.38 0 0 1 13.74 0" className={tier >= 2 ? "" : "opacity-25"} />
<path d="M8.01 14.89a5.25 5.25 0 0 1 7.98 0" className={tier >= 1 ? "" : "opacity-25"} />
<path d="M12 18.75h.008v.008H12z" strokeWidth={2.5} />
{offline && <path d="M4 4l16 16" />}
</svg>
);
}
function EthernetIcon() {
return (
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4 9h16v8a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V9z" />
<path d="M8 9V6h8v3" />
<path d="M7.5 12.5v2M10.5 12.5v2M13.5 12.5v2M16.5 12.5v2" />
</svg>
);
}
function chipClass(danger?: boolean) {
return `inline-flex items-center gap-1 rounded-full bg-white/70 border border-black/5 px-2 py-0.5 text-xs ${
danger ? "text-red-600" : "text-gray-600"
}`;
}
function PowerChip({ power }: { power: DevicePowerStatus }) {
// Windows-tray parity: desktops/VMs without a battery show no power chip.
if (!power.hasBattery) return null;
const pct = power.batteryPercent;
const low = pct !== null && pct < 20 && !power.onAcPower;
return (
<span
className={chipClass(low)}
title={
pct === null
? "Battery level unknown"
: power.onAcPower
? `On AC power${power.isCharging ? ", charging" : ""}`
: "On battery"
}
>
<BatteryIcon percent={pct} low={low} />
{power.onAcPower && <BoltIcon />}
{pct !== null && <span className="tabular-nums">{pct}%</span>}
</span>
);
}
function NetworkChip({ network }: { network: DeviceNetworkStatus }) {
if (network.type === "None") {
return (
<span className={chipClass(true)} title="No network connection reported">
<WifiIcon offline />
<span>Offline</span>
</span>
);
}
if (network.type === "Ethernet") {
return (
<span className={chipClass()} title="Ethernet">
<EthernetIcon />
<span>Ethernet</span>
</span>
);
}
return (
<span className={chipClass()} title={network.ssid ? `WiFi: ${network.ssid}` : "WiFi"}>
<WifiIcon signalPercent={network.signalPercent} />
<span>WiFi</span>
{network.signalPercent !== undefined && (
<span className="tabular-nums text-gray-400">{network.signalPercent}%</span>
)}
</span>
);
}
export function DeviceStatusChips({ status }: { status: DeviceStatus }) {
const showPower = status.power !== null && status.power.hasBattery;
// Old agents emit none of the source events — render nothing, not an empty row.
if (!showPower && status.network === null) return null;
return (
<div className="mt-2 flex items-center justify-center gap-2">
{status.network !== null && <NetworkChip network={status.network} />}
{status.power !== null && <PowerChip power={status.power} />}
</div>
);
}
@@ -0,0 +1,227 @@
import { describe, it, expect } from "vitest";
import { computeDeviceStatus } from "../deviceStatus";
import { EnrollmentEvent } from "@/types";
/**
* computeDeviceStatus feeds the progress page's taskbar-style power/network
* chips: a sequence-ordered fold where later events win, "unknown" battery
* percent maps to null, and sessions from agents without these events must
* yield nulls so the chips simply don't render.
*/
function ev(
sequence: number,
eventType: string,
data?: Record<string, unknown>,
): EnrollmentEvent {
return {
eventId: `evt-${sequence}`,
sessionId: "session-1",
timestamp: "2026-08-21T10:00:00Z",
eventType,
severity: "Info",
source: "Test",
phase: 2,
message: `event ${sequence}`,
sequence,
data,
};
}
describe("computeDeviceStatus", () => {
it("returns nulls for no events or only unrelated events", () => {
expect(computeDeviceStatus([])).toEqual({ power: null, network: null });
expect(
computeDeviceStatus([ev(1, "info_event", { foo: "bar" }), ev(2, "download_progress")]),
).toEqual({ power: null, network: null });
});
it("captures a desktop baseline (hasBattery false) from power_state_check", () => {
const { power } = computeDeviceStatus([
ev(1, "power_state_check", { onAcPower: true, hasBattery: false, isCharging: false }),
]);
expect(power).toEqual({
onAcPower: true,
hasBattery: false,
batteryPercent: null,
isCharging: false,
});
});
it("lets a later power_state_change win over the baseline and implies hasBattery", () => {
const { power } = computeDeviceStatus([
ev(1, "power_state_check", {
onAcPower: true,
hasBattery: true,
batteryPercent: 90,
isCharging: true,
}),
ev(2, "power_state_change", {
transition: "ac_to_battery",
onAcPower: false,
batteryPercent: 87,
isCharging: false,
}),
]);
expect(power).toEqual({
onAcPower: false,
hasBattery: true,
batteryPercent: 87,
isCharging: false,
});
});
it("tracks threshold_crossed percent updates while on battery", () => {
const { power } = computeDeviceStatus([
ev(1, "power_state_change", {
transition: "ac_to_battery",
onAcPower: false,
batteryPercent: 55,
isCharging: false,
}),
ev(2, "power_state_change", {
transition: "threshold_crossed",
thresholdPercent: 30,
onAcPower: false,
batteryPercent: 29,
isCharging: false,
}),
]);
expect(power?.batteryPercent).toBe(29);
expect(power?.onAcPower).toBe(false);
});
it('maps the literal "unknown" battery percent to null on both event types', () => {
const check = computeDeviceStatus([
ev(1, "power_state_check", { onAcPower: false, hasBattery: true, batteryPercent: "unknown" }),
]);
expect(check.power?.batteryPercent).toBeNull();
const change = computeDeviceStatus([
ev(1, "power_state_change", { onAcPower: false, batteryPercent: "unknown" }),
]);
expect(change.power?.batteryPercent).toBeNull();
});
it("skips a power_state_check that carries a probe error", () => {
const { power } = computeDeviceStatus([
ev(1, "power_state_change", { onAcPower: false, batteryPercent: 42, isCharging: false }),
ev(2, "power_state_check", { onAcPower: true, hasBattery: false, error: "GetSystemPowerStatus failed" }),
]);
expect(power).toEqual({
onAcPower: false,
hasBattery: true,
batteryPercent: 42,
isCharging: false,
});
});
it("reads Ethernet from network_interface_info", () => {
const { network } = computeDeviceStatus([
ev(1, "network_interface_info", { connectionType: "Ethernet", linkSpeedMbps: 1000 }),
]);
expect(network).toEqual({ type: "Ethernet" });
});
it("merges wifi_signal_info into a WiFi connection and clears on switch to Ethernet", () => {
const events = [
ev(1, "network_interface_info", { connectionType: "WiFi" }),
ev(2, "wifi_signal_info", { wifiSsid: "CorpNet", wifiSignalPercent: 84 }),
];
expect(computeDeviceStatus(events).network).toEqual({
type: "WiFi",
ssid: "CorpNet",
signalPercent: 84,
});
events.push(
ev(3, "network_state_change", {
changeType: "type_change",
after_connectionType: "Ethernet",
hasNetwork: true,
}),
);
expect(computeDeviceStatus(events).network).toEqual({ type: "Ethernet" });
});
it("goes offline on hasNetwork false and recovers on network_restored", () => {
const events = [
ev(1, "network_interface_info", { connectionType: "WiFi" }),
ev(2, "network_state_change", {
changeType: "network_lost",
after_connectionType: "None",
hasNetwork: false,
}),
];
expect(computeDeviceStatus(events).network).toEqual({ type: "None" });
events.push(
ev(3, "network_state_change", {
changeType: "network_restored",
after_connectionType: "WiFi",
after_wifiSsid: "CorpNet",
hasNetwork: true,
}),
);
expect(computeDeviceStatus(events).network).toEqual({ type: "WiFi", ssid: "CorpNet" });
});
it('treats the "n/a" SSID sentinel as absent and carries the prior ssid', () => {
const { network } = computeDeviceStatus([
ev(1, "wifi_signal_info", { wifiSsid: "CorpNet", wifiSignalPercent: 70 }),
ev(2, "network_state_change", {
changeType: "ip_change",
after_connectionType: "WiFi",
after_wifiSsid: "n/a",
hasNetwork: true,
}),
]);
expect(network).toEqual({ type: "WiFi", ssid: "CorpNet", signalPercent: 70 });
});
it("keeps ssid/signal across the enrollment-end network_interface_info re-emit", () => {
const { network } = computeDeviceStatus([
ev(1, "network_interface_info", { connectionType: "WiFi" }),
ev(2, "wifi_signal_info", { wifiSsid: "CorpNet", wifiSignalPercent: 61 }),
ev(3, "network_interface_info", { connectionType: "WiFi", linkSpeedMbps: 866 }),
]);
expect(network).toEqual({ type: "WiFi", ssid: "CorpNet", signalPercent: 61 });
});
it("maps no_active_interface to None", () => {
const { network } = computeDeviceStatus([
ev(1, "network_interface_info", { status: "no_active_interface" }),
]);
expect(network).toEqual({ type: "None" });
});
it("coerces string-serialized booleans and numbers", () => {
const { power, network } = computeDeviceStatus([
ev(1, "power_state_check", {
onAcPower: "true",
hasBattery: "true",
batteryPercent: "73",
isCharging: "false",
}),
ev(2, "wifi_signal_info", { wifiSsid: "CorpNet", wifiSignalPercent: "58" }),
ev(3, "network_state_change", {
changeType: "network_lost",
after_connectionType: "None",
hasNetwork: "false",
}),
]);
expect(power).toEqual({
onAcPower: true,
hasBattery: true,
batteryPercent: 73,
isCharging: false,
});
expect(network).toEqual({ type: "None" });
});
it("ignores events without a data payload", () => {
expect(
computeDeviceStatus([ev(1, "power_state_change"), ev(2, "network_state_change")]),
).toEqual({ power: null, network: null });
});
});
@@ -0,0 +1,149 @@
import { EnrollmentEvent } from "@/types";
export interface DevicePowerStatus {
onAcPower: boolean;
hasBattery: boolean;
/** null when the agent reported the literal "unknown". */
batteryPercent: number | null;
isCharging: boolean;
}
export type NetworkKind = "WiFi" | "Ethernet" | "None";
export interface DeviceNetworkStatus {
type: NetworkKind;
ssid?: string;
signalPercent?: number;
}
export interface DeviceStatus {
power: DevicePowerStatus | null;
network: DeviceNetworkStatus | null;
}
// Some agent paths serialize payload values as strings, so every read is coerced.
function asBool(value: unknown): boolean {
return value === true || value === "true" || value === "True";
}
function asIntOrNull(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) return Math.round(value);
if (typeof value === "string") {
const parsed = parseInt(value, 10);
if (!isNaN(parsed)) return parsed;
}
return null;
}
// Non-empty string, filtering the agent's "n/a"/"None" placeholder sentinels.
function asStr(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
if (!trimmed || trimmed === "n/a" || trimmed === "None") return undefined;
return trimmed;
}
function asNetworkKind(value: unknown): NetworkKind {
return value === "WiFi" || value === "Ethernet" ? value : "None";
}
// Snapshot of the WiFi details worth carrying across events that don't restate
// them. Extracted as a typed helper — inlining these reads into the `network`
// assignments makes TS's control-flow analysis circular (TS7022).
function wifiCarry(prev: DeviceNetworkStatus | null): { ssid?: string; signalPercent?: number } {
return prev !== null && prev.type === "WiFi"
? { ssid: prev.ssid, signalPercent: prev.signalPercent }
: {};
}
/**
* Latest-known device power + network state, folded from the raw event stream
* (already merged by eventId and sorted by sequence — later events win).
*
* Sources: power_state_check (once per agent start), power_state_change (live
* watcher, battery devices only), network_interface_info (start + enrollment
* end), network_state_change (live), wifi_signal_info (WiFi NICs).
* Sessions from agents without these events yield { power: null, network: null }.
*/
export function computeDeviceStatus(events: EnrollmentEvent[]): DeviceStatus {
let power: DevicePowerStatus | null = null;
let network: DeviceNetworkStatus | null = null;
for (const evt of events) {
const d = evt.data;
if (!d) continue;
switch (evt.eventType) {
case "power_state_check": {
// A failed probe carries defaults, not truth — keep the previous state.
if (d.error !== undefined && d.error !== null) break;
power = {
onAcPower: asBool(d.onAcPower),
hasBattery: asBool(d.hasBattery),
batteryPercent: asIntOrNull(d.batteryPercent),
isCharging: asBool(d.isCharging),
};
break;
}
case "power_state_change": {
// Emitted only on devices with a battery; the payload carries no hasBattery.
power = {
onAcPower: asBool(d.onAcPower),
hasBattery: true,
batteryPercent: asIntOrNull(d.batteryPercent),
isCharging: asBool(d.isCharging),
};
break;
}
case "network_interface_info": {
if (d.status === "no_active_interface") {
network = { type: "None" };
break;
}
const type = asNetworkKind(d.connectionType);
if (type === "None") break; // malformed/legacy payload — keep previous state
if (type === "WiFi") {
// This event never carries an SSID; keep the one wifi_signal_info gave us
// so the enrollment-end re-emit doesn't blank the chip.
const prior = wifiCarry(network);
network = { type, ssid: prior.ssid, signalPercent: prior.signalPercent };
} else {
network = { type };
}
break;
}
case "network_state_change": {
if (d.hasNetwork === false || d.hasNetwork === "false" || d.hasNetwork === "False") {
network = { type: "None" };
break;
}
const type = asNetworkKind(d.after_connectionType);
if (type === "WiFi") {
const prior = wifiCarry(network);
const ssid = asStr(d.after_wifiSsid) ?? prior.ssid;
network = {
type,
ssid,
// A signal reading only stays meaningful on the same network.
signalPercent: prior.ssid === ssid ? prior.signalPercent : undefined,
};
} else {
network = { type };
}
break;
}
case "wifi_signal_info": {
// Existence of this event implies the active NIC is WiFi.
const prior = wifiCarry(network);
network = {
type: "WiFi",
ssid: asStr(d.wifiSsid) ?? prior.ssid,
signalPercent: asIntOrNull(d.wifiSignalPercent) ?? prior.signalPercent,
};
break;
}
}
}
return { power, network };
}
@@ -2,6 +2,7 @@
import { useEffect, useMemo, useState } from "react";
import { EnrollmentEvent, Session } from "@/types";
import { computeDeviceStatus, DeviceStatus } from "./deviceStatus";
// Minimal structural view of the event payloads this hook reads — only fields used in a
// typed position are declared (the agent serializes them as strings); everything else
@@ -116,6 +117,7 @@ export interface UseProgressDerivedDataReturn {
currentInstall: CurrentInstall | null;
installElapsedMs: number | null;
overallProgress: number;
deviceStatus: DeviceStatus;
}
/**
@@ -171,6 +173,8 @@ export function useProgressDerivedData(
[events]
);
const deviceStatus = useMemo<DeviceStatus>(() => computeDeviceStatus(events), [events]);
const currentInstall = useMemo<CurrentInstall | null>(() => {
const installTypes = new Set([
"app_install_started",
@@ -278,5 +282,6 @@ export function useProgressDerivedData(
currentInstall,
installElapsedMs,
overallProgress,
deviceStatus,
};
}
@@ -10,6 +10,7 @@ import { useProgressSearch } from "./hooks/useProgressSearch";
import { useProgressEvents } from "./hooks/useProgressEvents";
import { useProgressSignalR } from "./hooks/useProgressSignalR";
import { useProgressDerivedData } from "./hooks/useProgressDerivedData";
import { DeviceStatusChips } from "./components/DeviceStatusChips";
function formatDuration(ms: number): string {
const seconds = Math.floor(ms / 1000);
@@ -71,7 +72,7 @@ export default function ProgressPortalPage() {
addNotification,
});
const { appSummary, currentDownload, currentInstall, installElapsedMs, overallProgress } =
const { appSummary, currentDownload, currentInstall, installElapsedMs, overallProgress, deviceStatus } =
useProgressDerivedData(events, session);
const handleKeyDown = (e: React.KeyboardEvent) => {
@@ -253,6 +254,9 @@ export default function ProgressPortalPage() {
{session.deviceName || session.serialNumber} |{" "}
{session.manufacturer} {session.model}
</p>
{/* Live chips only while in progress — on finished sessions the
last-known values would masquerade as current state. */}
{session.status === "InProgress" && <DeviceStatusChips status={deviceStatus} />}
</div>
</div>
@@ -1,6 +1,8 @@
"use client";
import { useState, useMemo } from "react";
import Link from "next/link";
import { networkTimelineUrl } from "@/lib/routes";
import { EnrollmentEvent, Session } from "@/types";
import OobeConfigModal from "./OobeConfigModal";
import { compareVersions, stripGitHashSuffix } from "@/utils/bootstrapVersion";
@@ -245,6 +247,18 @@ export default function DeviceDetailsCard({ events, latestAgentVersion, session
{/* Network */}
{(networkAdapters || dnsConfig || proxyConfig || networkInterfaceInfo || wifiSignalInfo) && (
<DetailSection title="Network">
{/* Deep link to the network timeline page */}
{session && (
<div className="-mt-1 mb-2">
<Link
href={networkTimelineUrl(session.sessionId, { tenantId: session.tenantId })}
className="text-xs text-green-700 hover:text-green-800 hover:underline inline-flex items-center gap-1"
>
<span>Network timeline</span>
<span aria-hidden>→</span>
</Link>
</div>
)}
{networkAdapters && networkAdapters.adapters && (
(networkAdapters.adapters as unknown[]).map((adapter, i: number) => {
const a = adapter as { [key: string]: unknown; macAddress?: string };
@@ -4,7 +4,7 @@ import { useState, useMemo } from "react";
import { EnrollmentEvent, Session } from "@/types";
import { normalizeEventDataForDisplay, shortenBuildHashInMessage } from "../utils/eventHelpers";
import { getEnrichedOrLookup, formatErrorCode, type ErrorCodeEntry } from "@/utils/errorCodeMap";
import { readTimeProvenance, classifyTimeJump } from "@/lib/timeProvenance";
import { readTimeProvenance, classifyTimeJump, readClockChangeDeltaMs } from "@/lib/timeProvenance";
import { formatDuration, formatUtcOffset } from "@/lib/formatting";
interface EventTimelineProps {
@@ -75,6 +75,17 @@ export default function EventTimeline({
return filtered.sort((a, b) => a.sequence - b.sequence);
}, [events, severityFilters, matchesSearch]);
// Ground-truth clock steps recorded by the agent's system timeline watcher — lets the
// TimeJumpBadge name a backwards display step as an actual OS clock set instead of an
// unexplained amber anomaly. Collected from ALL events (filters must not hide the cause).
const clockDeltas = useMemo(
() => events
.filter(e => e.eventType === "system_clock_changed")
.map(e => readClockChangeDeltaMs(e.data))
.filter((n): n is number => n !== null),
[events],
);
const filterPhaseEvents = (phaseEvents: EnrollmentEvent[]) =>
matchesSearch ? phaseEvents.filter(matchesSearch) : phaseEvents;
@@ -187,6 +198,7 @@ export default function EventTimeline({
key={ev.eventId || `${ev.sessionId}-${ev.sequence}`}
event={ev}
prevEvent={i > 0 ? sortedBySequence[i - 1] : null}
clockDeltas={clockDeltas}
/>
))}
</div>
@@ -220,6 +232,7 @@ export default function EventTimeline({
onToggle={() => togglePhase(`pre-${phaseName}`)}
showScriptOutput={showScriptOutput}
borderColor="border-amber-400"
clockDeltas={clockDeltas}
/>
))}
</div>
@@ -257,6 +270,7 @@ export default function EventTimeline({
isExpanded={expandedPhases.has(`user-${phaseName}`)}
onToggle={() => togglePhase(`user-${phaseName}`)}
showScriptOutput={showScriptOutput}
clockDeltas={clockDeltas}
/>
))}
</div>
@@ -304,6 +318,7 @@ export default function EventTimeline({
isExpanded={expandedPhases.has(phaseName)}
onToggle={() => togglePhase(phaseName)}
showScriptOutput={showScriptOutput}
clockDeltas={clockDeltas}
/>
))}
</div>
@@ -322,7 +337,8 @@ function PhaseSection({
isExpanded,
onToggle,
showScriptOutput,
borderColor = 'border-blue-500'
borderColor = 'border-blue-500',
clockDeltas,
}: {
phaseName: string;
events: EnrollmentEvent[];
@@ -330,6 +346,7 @@ function PhaseSection({
onToggle: () => void;
showScriptOutput?: boolean;
borderColor?: string;
clockDeltas?: number[];
}) {
return (
<div id={`phase-${phaseName.replace(/[^a-zA-Z0-9]/g, '-')}`} className={`border-l-4 ${borderColor} pl-4`}>
@@ -353,6 +370,7 @@ function PhaseSection({
event={event}
showScriptOutput={showScriptOutput}
prevEvent={i > 0 ? events[i - 1] : null}
clockDeltas={clockDeltas}
/>
))}
</div>
@@ -413,11 +431,13 @@ function GapBadge({ prevTime, eventTime }: { prevTime: Date | null; eventTime: D
// tier; an unexplained backwards step gets amber.
const TIME_JUMP_ORDER_NOTE = "Events are shown in true write order (sequence).";
function TimeJumpBadge({ prevEvent, event }: { prevEvent?: EnrollmentEvent | null; event: EnrollmentEvent }) {
function TimeJumpBadge({ prevEvent, event, clockDeltas }: { prevEvent?: EnrollmentEvent | null; event: EnrollmentEvent; clockDeltas?: number[] }) {
if (!prevEvent) return null;
const jump = classifyTimeJump(
{ displayTime: getDisplayTime(prevEvent), provenance: readTimeProvenance(prevEvent.data) },
{ displayTime: getDisplayTime(event), provenance: readTimeProvenance(event.data) },
undefined,
clockDeltas,
);
if (!jump) return null;
@@ -430,7 +450,9 @@ function TimeJumpBadge({ prevEvent, event }: { prevEvent?: EnrollmentEvent | nul
const times = `(${getDisplayTime(prevEvent).toLocaleTimeString()} → ${getDisplayTime(event).toLocaleTimeString()})`;
const title =
jump.cause === "era-offset"
jump.cause === "clock-set"
? `Time moved backwards by ${human} vs the previous event ${times}. The OS clock was actually set back — a system_clock_changed event in this session records a matching step (ground truth, not an ordering artifact). ${TIME_JUMP_ORDER_NOTE}`
: jump.cause === "era-offset"
? `Time moved backwards by ${human} vs the previous event ${times}. The two log lines were corrected with different UTC offsets (era-mixed log) — the jump reflects the offset change, not reordering. ${TIME_JUMP_ORDER_NOTE}`
: jump.cause === "derived-timestamp"
? `Backdated log line: written ${human} before the previous event's displayed time ${times}. Its own timestamp was unusable, so the agent's clock time is shown. ${TIME_JUMP_ORDER_NOTE}`
@@ -449,7 +471,7 @@ function TimeJumpBadge({ prevEvent, event }: { prevEvent?: EnrollmentEvent | nul
const CLAMPED_TOOLTIP_PREFIX =
"The device-reported timestamp was outside the accepted range and was clamped to server receive time on ingest.";
function EventRow({ event, showScriptOutput, prevEvent }: { event: EnrollmentEvent; showScriptOutput?: boolean; prevEvent?: EnrollmentEvent | null }) {
function EventRow({ event, showScriptOutput, prevEvent, clockDeltas }: { event: EnrollmentEvent; showScriptOutput?: boolean; prevEvent?: EnrollmentEvent | null; clockDeltas?: number[] }) {
const prevDisplayTime = prevEvent ? getDisplayTime(prevEvent) : null;
const [showDetails, setShowDetails] = useState(false);
const [showRaw, setShowRaw] = useState(false);
@@ -541,7 +563,7 @@ function EventRow({ event, showScriptOutput, prevEvent }: { event: EnrollmentEve
)}
</span>
<GapBadge prevTime={prevDisplayTime ?? null} eventTime={recordedAt ?? new Date(event.timestamp)} />
<TimeJumpBadge prevEvent={prevEvent} event={event} />
<TimeJumpBadge prevEvent={prevEvent} event={event} clockDeltas={clockDeltas} />
<SeverityBadge severity={event.severity} />
{isBackfilled && (
<span
@@ -686,6 +708,79 @@ function EventRow({ event, showScriptOutput, prevEvent }: { event: EnrollmentEve
</div>
);
})()}
{/* Ground-truth clock step (system timeline watcher): the OS clock was set from
oldTime to newTime — payload times are authoritative. Amber at ≥5 min (can
invalidate token/cert windows and visibly reorders the timeline), sky below. */}
{event.eventType === "system_clock_changed" && (() => {
const deltaMs = readClockChangeDeltaMs(event.data);
if (deltaMs === null) return null;
const forward = deltaMs >= 0;
const isLarge = Math.abs(deltaMs) >= 5 * 60 * 1000;
const reasonText = event.data?.reasonText as string | undefined;
const processName = event.data?.processName as string | undefined;
const processLeaf = processName ? processName.split("\\").pop() : null;
return (
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs">
<span
className={`px-1.5 py-0.5 rounded font-mono font-medium ${isLarge ? "bg-amber-100 text-amber-800" : "bg-sky-100 text-sky-800"}`}
title={`The system clock was stepped ${forward ? "forward" : "backward"} by ${formatDuration(Math.abs(deltaMs) / 1000)}. oldTime/newTime in the event details are the authoritative instants.`}
>
⏱ {forward ? "+" : "−"}{formatDuration(Math.abs(deltaMs) / 1000)}
</span>
{reasonText && <span className="text-gray-500">{reasonText === "application_set" ? "set by a process" : reasonText === "hardware_clock_sync" ? "hardware clock sync" : reasonText}</span>}
{processLeaf && <span className="text-gray-500 font-mono">{processLeaf}</span>}
</div>
);
})()}
{/* Completed sleep episode (emitted retroactively at wake): enteredAt/exitedAt in the
payload are the authoritative bounds — the gap in the surrounding timeline is this
episode, not missing telemetry. */}
{event.eventType === "system_sleep_episode" && (() => {
const kind = event.data?.kind as string | undefined;
const kindLabel = kind === "modern_standby" ? "Modern Standby" : kind === "hibernate" ? "Hibernate" : "Sleep";
const durationSeconds = Number(event.data?.durationSeconds);
const wakeText = event.data?.wakeSourceText as string | undefined;
const onAc = event.data?.onAcPower;
const onAcLabel = onAc === true || onAc === "true" ? "on AC" : onAc === false || onAc === "false" ? "on battery" : null;
return (
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs">
<span
className="px-1.5 py-0.5 rounded bg-indigo-100 text-indigo-800 font-medium"
title="The device completed a sleep episode; this event is recorded at wake. enteredAt/exitedAt in the details are the episode bounds — the surrounding timeline gap is explained by this pause."
>
🌙 {kindLabel}
</span>
{Number.isFinite(durationSeconds) && durationSeconds > 0 && (
<span className="text-gray-600 font-medium">{formatDuration(durationSeconds)}</span>
)}
{wakeText && <span className="text-gray-500">wake: {wakeText}</span>}
{onAcLabel && <span className="text-gray-500">{onAcLabel}</span>}
</div>
);
})()}
{/* Live power-state change (power watcher, DEV-009/010): make the transition readable
without expanding the details. */}
{event.eventType === "power_state_change" && (() => {
const transition = event.data?.transition as string | undefined;
const batteryPercent = event.data?.batteryPercent;
const threshold = event.data?.thresholdPercent;
const chip = transition === "ac_to_battery"
? { label: "AC → battery", cls: "bg-amber-100 text-amber-800" }
: transition === "battery_to_ac"
? { label: "Battery → AC", cls: "bg-emerald-100 text-emerald-800" }
: transition === "threshold_crossed"
? { label: `Battery below ${String(threshold ?? "?")}%`, cls: String(threshold) === "15" ? "bg-red-100 text-red-800" : "bg-amber-100 text-amber-800" }
: null;
if (!chip) return null;
return (
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs">
<span className={`px-1.5 py-0.5 rounded font-medium ${chip.cls}`}>🔋 {chip.label}</span>
{batteryPercent !== undefined && batteryPercent !== null && String(batteryPercent) !== "unknown" && (
<span className="text-gray-500">{String(batteryPercent)}% charge</span>
)}
</div>
);
})()}
<div className="mt-1 flex items-center gap-3 text-xs text-gray-500">
<span>Source: {event.source}</span>
<span>Seq: {event.sequence}</span>
@@ -989,7 +1084,7 @@ function TimeProvenanceRows({ event, provenance }: { event: EnrollmentEvent; pro
);
}
function RawEventRow({ event, prevEvent }: { event: EnrollmentEvent; prevEvent?: EnrollmentEvent | null }) {
function RawEventRow({ event, prevEvent, clockDeltas }: { event: EnrollmentEvent; prevEvent?: EnrollmentEvent | null; clockDeltas?: number[] }) {
const [expanded, setExpanded] = useState(false);
const detailData = useMemo(() => normalizeEventDataForDisplay(event.data), [event.data]);
const hasDetails = detailData && Object.keys(detailData).length > 0;
@@ -1011,7 +1106,7 @@ function RawEventRow({ event, prevEvent }: { event: EnrollmentEvent; prevEvent?:
<span className="text-gray-400 w-8 text-right flex-shrink-0">{event.sequence}</span>
<span className="text-gray-500 flex-shrink-0">{(recordedAt ?? new Date(event.timestamp)).toLocaleTimeString()}</span>
<GapBadge prevTime={prevDisplayTime ?? null} eventTime={recordedAt ?? new Date(event.timestamp)} />
<TimeJumpBadge prevEvent={prevEvent} event={event} />
<TimeJumpBadge prevEvent={prevEvent} event={event} clockDeltas={clockDeltas} />
<span className={`flex-shrink-0 w-14 ${sevColor[event.severity] || "text-gray-500"}`}>{event.severity}</span>
{isBackfilled && (
<span className="text-slate-500 flex-shrink-0" title={BACKFILL_TOOLTIP}>⟲</span>
@@ -12,9 +12,17 @@ interface SessionInfoCardProps {
isGatherRulesSession: boolean;
ntpOffset?: { offsetSeconds: number; ntpServer?: string } | null;
configMgrDetected?: { ccmVersion?: string; ccmServiceState?: string; siteCode?: string; confidenceScore?: number } | null;
/** Total observed sleep/standby seconds (system_sleep_episode ground truth) — explains an inflated wall-clock duration. */
standbySeconds?: number | null;
}
export default function SessionInfoCard({ session, enrollmentDuration, displayStatus, isGatherRulesSession, ntpOffset, configMgrDetected }: SessionInfoCardProps) {
// Compact duration for the standby note: "56m", "1h 2m".
function formatStandby(seconds: number): string {
if (seconds >= 3600) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
return `${Math.max(1, Math.round(seconds / 60))}m`;
}
export default function SessionInfoCard({ session, enrollmentDuration, displayStatus, isGatherRulesSession, ntpOffset, configMgrDetected, standbySeconds }: SessionInfoCardProps) {
const lastContactTooltip = session.lastEventAt
? `Last contact: ${new Date(session.lastEventAt).toLocaleString([], { dateStyle: "short", timeStyle: "medium" })}`
: undefined;
@@ -56,7 +64,25 @@ export default function SessionInfoCard({ session, enrollmentDuration, displaySt
copyText={session.sessionId}
/>
<InfoItem label="Started" value={new Date(session.startedAt).toLocaleString([], { dateStyle: "short", timeStyle: "short" })} tooltip={lastContactTooltip} />
<InfoItem label="Duration" value={enrollmentDuration ?? `${Math.round(session.durationSeconds / 60)} min`} tooltip={lastContactTooltip} />
<InfoItem
label="Duration"
value={
standbySeconds && standbySeconds >= 60 ? (
<span>
{enrollmentDuration ?? `${Math.round(session.durationSeconds / 60)} min`}
<span
className="text-indigo-600 font-normal"
title={`The device spent ${formatStandby(standbySeconds)} asleep (standby/hibernate) during this window — the wall-clock duration includes that pause. Subtract it for the active enrollment time.`}
>
{" "}· 🌙 {formatStandby(standbySeconds)} standby
</span>
</span>
) : (
enrollmentDuration ?? `${Math.round(session.durationSeconds / 60)} min`
)
}
tooltip={lastContactTooltip}
/>
<InfoItem label="Events" value={session.eventCount.toString()} tooltip={lastContactTooltip} />
<InfoItem label="Reboots" value={(session.rebootCount ?? 0).toString()} tooltip="System reboots observed during enrollment (V2 only)" />
<InfoItem label="Status" value={<StatusBadge status={displayStatus} failureReason={session.failureReason} failureSource={session.failureSource} adminMarkedAction={session.adminMarkedAction} reconcileReason={session.reconcileReason} espSoftFailure={session.espSoftFailure} />} />
@@ -33,6 +33,15 @@ export interface RebootSpanDto {
segmentKey: string;
}
export interface SleepSpanDto {
startUtc: string;
endUtc: string;
seconds: number;
segmentKey: string;
/** "sleep" | "hibernate" | "modern_standby" (from the system_sleep_episode payload). */
kind: string;
}
export interface SessionTimeBreakdownDto {
tenantId: string;
sessionId: string;
@@ -42,6 +51,9 @@ export interface SessionTimeBreakdownDto {
unattributedSeconds: number;
rebootSeconds: number;
rebootSpans: RebootSpanDto[];
/** Optional: rows computed before AttributionVersion 3 lack these. */
sleepSeconds?: number;
sleepSpans?: SleepSpanDto[];
blockingApps: BlockingAppIntervalDto[];
blockingAppCount: number;
espAppsOccupancySeconds: number | null;
@@ -108,14 +120,24 @@ export default function TimeAttributionLane({ breakdown }: { breakdown: SessionT
{formatDuration(wallClock)} enrollment time
</span>
</h3>
{breakdown.rebootSeconds > 0 && (
<span
className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-full px-2 py-0.5"
title="Reboot outage overlaps the segment it started in — it is not a separate slice of the bar."
>
⟳ {breakdown.rebootSpans.length} reboot{breakdown.rebootSpans.length !== 1 ? "s" : ""} · {formatDuration(breakdown.rebootSeconds)}
</span>
)}
<span className="inline-flex items-center gap-2">
{(breakdown.sleepSeconds ?? 0) > 0 && (
<span
className="text-xs text-indigo-700 bg-indigo-50 border border-indigo-200 rounded-full px-2 py-0.5"
title="The device was asleep (standby/hibernate) for this long inside the enrollment window — the wall clock keeps the pause; this chip discloses it. Like reboots, sleep overlaps the segment it started in and is not a separate slice of the bar."
>
🌙 {(breakdown.sleepSpans ?? []).length} standby · {formatDuration(breakdown.sleepSeconds ?? 0)}
</span>
)}
{breakdown.rebootSeconds > 0 && (
<span
className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-full px-2 py-0.5"
title="Reboot outage overlaps the segment it started in — it is not a separate slice of the bar."
>
⟳ {breakdown.rebootSpans.length} reboot{breakdown.rebootSpans.length !== 1 ? "s" : ""} · {formatDuration(breakdown.rebootSeconds)}
</span>
)}
</span>
</div>
{/* Proportional stacked bar over the authoritative wall clock */}
@@ -25,6 +25,7 @@ export interface UseSessionDerivedDataReturn {
gatherRulesSucceeded: boolean;
displayStatus: string;
enrollmentDurationFromEvents: string | null;
standbySeconds: number | null;
phaseNamesMap: Record<number, string>;
phaseOrder: string[];
isSkipUserStatusPage: boolean;
@@ -138,6 +139,25 @@ export function useSessionDerivedData(
return `${Math.floor(durationSec / 3600)}h ${Math.floor((durationSec % 3600) / 60)}m`;
}, [events]);
// Total observed sleep/standby seconds (system_sleep_episode ground truth). The wall-clock
// duration deliberately keeps the pause — this number tells the story next to it ("1h 20m,
// 56m of it standby"). Dedup on enteredAt: the same episode can be observed twice (agent
// restart re-backfills it from a different event-log record).
const standbySeconds = useMemo(() => {
const seenEpisodes = new Set<string>();
let total = 0;
for (const e of events) {
if (e.eventType !== "system_sleep_episode" || !e.data) continue;
const duration = Number(e.data.durationSeconds);
if (!Number.isFinite(duration) || duration <= 0) continue;
const enteredAt = typeof e.data.enteredAt === "string" ? e.data.enteredAt : `seq-${e.sequence}`;
if (seenEpisodes.has(enteredAt)) continue;
seenEpisodes.add(enteredAt);
total += duration;
}
return total > 0 ? Math.round(total) : null;
}, [events]);
const phaseNamesMap = session?.enrollmentType === "v2" ? V2_PHASE_NAMES : V1_PHASE_NAMES;
const phaseOrder = session?.enrollmentType === "v2" ? V2_PHASE_ORDER : V1_PHASE_ORDER;
@@ -213,6 +233,7 @@ export function useSessionDerivedData(
gatherRulesSucceeded: !!gatherRulesSucceeded,
displayStatus,
enrollmentDurationFromEvents,
standbySeconds,
phaseNamesMap,
phaseOrder,
isSkipUserStatusPage,
@@ -0,0 +1,428 @@
'use client';
// ─────────────────────────────────────────────────────────────────────────────
// Network timeline — the band chart (SVG).
//
// Phase context lane, one colored segment per network, offline gaps hatched,
// lifecycle + connectivity-check markers, hover/tap tooltips. Mobile keeps a
// minimum width and scrolls horizontally.
// ─────────────────────────────────────────────────────────────────────────────
import { useCallback, useRef, useState } from 'react';
import {
fmtDuration,
fmtTime,
HotspotHint,
NetworkModel,
OFFLINE_COLOR,
segmentTitle,
SegmentKind,
UNKNOWN_COLOR,
W,
} from './networkTimelineModel';
interface TooltipState {
x: number;
y: number;
maxLeft: number;
content: React.ReactNode;
}
export default function NetworkBand({ model }: { model: NetworkModel }) {
const wrapRef = useRef<HTMLDivElement>(null);
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
const showTooltip = useCallback((e: React.MouseEvent, content: React.ReactNode) => {
const rect = wrapRef.current?.getBoundingClientRect();
if (!rect) return;
setTooltip({ x: e.clientX - rect.left, y: e.clientY - rect.top, maxLeft: rect.width - 260, content });
}, []);
const hideTooltip = useCallback(() => setTooltip(null), []);
const { segments, colors, scale, phases, checks, lifeMarkers } = model;
// Headroom above the phase lane for the slanted lifecycle-marker labels
// (Reboot / Desktop / Completed / Clock ±… ) written directly at their dots —
// sized so even a long "Clock −3h 0m" label stays inside the SVG instead of
// running into the HTML legend above it.
const MARKER_LABEL_H = 48;
const PHASE_Y = 8 + MARKER_LABEL_H;
const PHASE_H = 22;
const BAND_Y = PHASE_Y + 32;
const BAND_H = 58;
const TICK_Y = BAND_Y + BAND_H;
const H = 172 + MARKER_LABEL_H;
// Boundary ticks: real times at every segment boundary, staggered rows,
// labels suppressed when they would collide.
const ticks: { x: number; t: number; row: number }[] = [];
const lastX = [-100, -100];
scale.tB.forEach((t, i) => {
const x = scale.xB[i];
const row = ticks.length % 2;
if (x - lastX[row] > 60) {
ticks.push({ x, t, row });
lastX[row] = x;
}
});
// Lifecycle-marker label layout: a marker's slanted label is suppressed when
// the previous shown label sits closer than 30 units (tooltip still works).
const markerXs = lifeMarkers.map((m) => Math.min(Math.max(scale.x(m.t), 6), W - 6));
const markerLabelVisible: boolean[] = [];
const lastLabelX = [-100];
for (const x of markerXs) {
const show = x - lastLabelX[0] >= 30;
if (show) lastLabelX[0] = x;
markerLabelVisible.push(show);
}
// Legend entries in first-seen order; the strongest hotspot hint wins.
const legend: { identity: string; label: string; color: string; kind: SegmentKind; hotspot?: HotspotHint }[] = [];
for (const seg of segments) {
const existing = legend.find((l) => l.identity === seg.identity);
if (existing) {
if (seg.hotspot && (!existing.hotspot || (seg.hotspot.confident && !existing.hotspot.confident))) {
existing.hotspot = seg.hotspot;
}
continue;
}
legend.push({
identity: seg.identity,
label: segmentTitle(seg),
color: colors.get(seg.identity) ?? UNKNOWN_COLOR,
kind: seg.kind,
hotspot: seg.hotspot,
});
}
return (
<div className="mt-3">
<div className="flex flex-wrap gap-x-4 gap-y-1 mb-2 text-xs text-gray-700">
{legend.map((l) => (
<span key={l.identity} className="inline-flex items-center gap-1.5">
{l.kind === 'offline' ? (
<svg width="14" height="10" className="rounded-sm">
<rect width="14" height="10" fill="none" stroke={OFFLINE_COLOR} strokeWidth="1" />
<line x1="0" y1="10" x2="10" y2="0" stroke={OFFLINE_COLOR} strokeWidth="1.5" />
<line x1="6" y1="12" x2="16" y2="2" stroke={OFFLINE_COLOR} strokeWidth="1.5" />
</svg>
) : (
<span className="w-3.5 h-2.5 rounded-sm inline-block" style={{ backgroundColor: l.color }} />
)}
{l.label}
{l.hotspot && (
<span className="text-[10px] px-1 py-px rounded bg-amber-100 text-amber-800 font-medium">
{l.hotspot.confident ? 'Hotspot' : 'Hotspot?'}
</span>
)}
</span>
))}
<span className="inline-flex items-center gap-1.5 ml-auto text-gray-500">
<span className="inline-flex w-3.5 h-3.5 rounded-full bg-green-600 text-white items-center justify-center text-[9px] leading-none">
✓
</span>
Connectivity check after switch
</span>
</div>
{/* Mobile: horizontal scroll for the band. The tooltip lives OUTSIDE the
scroll container (on the relative wrapper) so it can neither be
clipped nor spawn scrollbars when it extends past the band. */}
<div ref={wrapRef} className="relative">
<div className="overflow-x-auto">
<div className="min-w-[700px]">
<svg viewBox={`0 0 ${W} ${H}`} className="w-full select-none" role="img" aria-label="Netzwerk-Timeline">
<defs>
<pattern id="offlineHatch" width="8" height="8" patternTransform="rotate(45)" patternUnits="userSpaceOnUse">
<rect width="8" height="8" fill="transparent" />
<line x1="0" y1="0" x2="0" y2="8" stroke={OFFLINE_COLOR} strokeWidth="2.5" />
</pattern>
</defs>
{/* Phase context lane */}
{phases.map((p, i) => {
const x0 = scale.x(p.start);
const x1 = scale.x(p.end);
if (x1 - x0 < 1) return null;
const tip = (
<div>
<div className="font-semibold">{p.name}</div>
<div>
{fmtTime(p.start)} – {fmtTime(p.end)} ({fmtDuration(p.end - p.start)})
</div>
</div>
);
return (
<g key={`ph-${i}`}>
<rect
x={x0}
y={PHASE_Y}
width={x1 - x0}
height={PHASE_H}
className={i % 2 === 0 ? 'fill-gray-100' : 'fill-gray-200'}
onMouseMove={(e) => showTooltip(e, tip)}
onClick={(e) => showTooltip(e, tip)}
onMouseLeave={hideTooltip}
/>
{x1 - x0 > 70 && (
<text
x={(x0 + x1) / 2}
y={PHASE_Y + PHASE_H / 2 + 4}
textAnchor="middle"
fontSize="11"
className="fill-gray-600 pointer-events-none"
>
{p.name}
</text>
)}
</g>
);
})}
{/* Network band */}
{segments.map((seg, i) => {
const x0 = scale.xB[i];
const x1 = scale.xB[i + 1];
const color = colors.get(seg.identity) ?? UNKNOWN_COLOR;
const w = x1 - x0 - 2; // 2px spacer between fills
// Hotspot classification lives ONLY in the legend badge (and tooltip) —
// no marker on the segment itself, and no emoji in SVG <text> (renders broken).
const label = segmentTitle(seg);
const sub =
seg.kind === 'wifi' && seg.signalPercent != null
? `${seg.signalPercent}%`
: seg.linkSpeedMbps
? `${seg.linkSpeedMbps} Mbit`
: '';
const tip = (
<div className="space-y-0.5">
<div className="font-semibold">{segmentTitle(seg)}</div>
<div>
{fmtTime(seg.start)} – {fmtTime(seg.end)} · {fmtDuration(seg.end - seg.start)}
</div>
{seg.ip && <div>IP: {seg.ip}</div>}
{seg.gateway && <div>Gateway: {seg.gateway}</div>}
{seg.linkSpeedMbps != null && <div>Link: {seg.linkSpeedMbps} Mbit/s</div>}
{seg.signalPercent != null && (
<div>
Signal: {seg.signalPercent}%{seg.radioType ? ` (${seg.radioType})` : ''}
</div>
)}
{seg.adapterDescription && <div>{seg.adapterDescription}</div>}
{seg.kind === 'asleep' && (
<div>
{`${seg.sleepKind === 'modern_standby' ? 'Modern Standby' : (seg.sleepKind ?? 'Sleep')}${
seg.wakeSourceText ? ` · wake: ${seg.wakeSourceText}` : ''
}${seg.onAcPower === undefined ? '' : seg.onAcPower ? ' · on AC power' : ' · on battery'}`}
</div>
)}
{seg.hotspot && (
<div className="text-amber-300">
{`${seg.hotspot.confident ? 'Smartphone hotspot' : 'Probable smartphone hotspot'} (${seg.hotspot.vendor}) — ${seg.hotspot.reason}`}
</div>
)}
{seg.ssidInferred && <div className="text-amber-300">SSID inferred from matching gateway</div>}
</div>
);
return (
<g key={`seg-${i}`}>
<rect
x={x0 + 1}
y={BAND_Y}
width={Math.max(w, 4)}
height={BAND_H}
rx="4"
fill={seg.kind === 'offline' ? 'url(#offlineHatch)' : color}
stroke={seg.kind === 'offline' ? OFFLINE_COLOR : 'none'}
strokeWidth={seg.kind === 'offline' ? 1.5 : 0}
fillOpacity={seg.kind === 'offline' ? 1 : 0.92}
onMouseMove={(e) => showTooltip(e, tip)}
onClick={(e) => showTooltip(e, tip)}
onMouseLeave={hideTooltip}
/>
{w > 60 && (
<text
x={x0 + (x1 - x0) / 2}
y={BAND_Y + BAND_H / 2 - 2}
textAnchor="middle"
fontSize="12"
fontWeight="600"
fill="#ffffff"
className="pointer-events-none"
style={seg.kind === 'offline' ? { fill: OFFLINE_COLOR } : undefined}
>
{label}
</text>
)}
{w > 60 && sub && seg.kind !== 'offline' && (
<text
x={x0 + (x1 - x0) / 2}
y={BAND_Y + BAND_H / 2 + 16}
textAnchor="middle"
fontSize="10"
fill="#ffffff"
fillOpacity="0.85"
className="pointer-events-none"
>
{sub}
</text>
)}
</g>
);
})}
{/* Lifecycle markers (reboot / desktop / terminal) — labeled directly
at the dot, slanted up-right; near the right edge the label flips
to up-left so it never leaves the drawing. A label is suppressed
(tooltip still works) when its neighbor's label sits too close. */}
{lifeMarkers.map((m, i) => {
const x = markerXs[i];
const showLabel = markerLabelVisible[i];
// Content stops at CONTENT_W, so every label can slant up-right
// uniformly — no flipping at the right edge.
const lx = x + 7;
const ly = PHASE_Y - 10;
const tip = (
<div>
<div className="font-semibold">{m.label}</div>
<div>{fmtTime(m.t)}</div>
{m.detail && <div className="opacity-80">{m.detail}</div>}
</div>
);
return (
<g
key={`life-${i}`}
onMouseMove={(e) => showTooltip(e, tip)}
onClick={(e) => showTooltip(e, tip)}
onMouseLeave={hideTooltip}
>
<line
x1={x}
y1={PHASE_Y}
x2={x}
y2={TICK_Y}
stroke="currentColor"
strokeDasharray="3 3"
strokeWidth="1"
className="text-gray-400"
/>
<circle cx={x} cy={PHASE_Y - 1} r="4.5" className="fill-gray-500" />
{/* invisible enlarged hit target — the dot alone is hard to hover */}
<circle cx={x} cy={PHASE_Y - 1} r="12" fill="transparent" />
{showLabel && (
<text
x={lx}
y={ly}
textAnchor="start"
fontSize="10"
transform={`rotate(-30 ${lx} ${ly})`}
className="fill-gray-500"
>
{m.label}
</text>
)}
</g>
);
})}
{/* Connectivity check markers */}
{checks.map((c, i) => {
// Clamp so the circle (r=8 + 2 stroke) never clips at the viewBox edges
const x = Math.min(Math.max(scale.x(c.t), 11), W - 11);
const tip = (
<div>
<div className="font-semibold mb-1">
Connectivity check · {c.reachable}/{c.total} reachable
</div>
<div>{fmtTime(c.t)}</div>
{c.results.map((r) => (
<div key={r.endpoint} className="flex gap-2">
<span>{r.reachable ? '✓' : '✗'}</span>
<span>{r.endpoint}</span>
<span className="opacity-70">
{r.reachable ? `${r.latencyMs} ms` : (r.error ?? 'unreachable')}
</span>
</div>
))}
</div>
);
return (
<g
key={`chk-${i}`}
onMouseMove={(e) => showTooltip(e, tip)}
onClick={(e) => showTooltip(e, tip)}
onMouseLeave={hideTooltip}
>
<circle
cx={x}
cy={BAND_Y + BAND_H - 1}
r="8"
fill={c.ok ? '#16a34a' : '#d97706'}
stroke="#ffffff"
strokeWidth="2"
/>
<text
x={x}
y={BAND_Y + BAND_H + 3}
textAnchor="middle"
fontSize="10"
fontWeight="700"
fill="#ffffff"
className="pointer-events-none"
>
{c.ok ? '✓' : '!'}
</text>
</g>
);
})}
{/* Boundary ticks with real times */}
{ticks.map((tk, i) => {
// Edge ticks anchor their label flush to the tick line instead of
// clamping a centered label sideways (which reads as a stray dash).
const anchor = tk.x < 30 ? 'start' : tk.x > W - 30 ? 'end' : 'middle';
return (
<g key={`tick-${i}`}>
<line
x1={tk.x}
y1={TICK_Y}
x2={tk.x}
y2={TICK_Y + 6}
stroke="currentColor"
strokeWidth="1"
className="text-gray-400"
/>
<text
x={tk.x}
y={TICK_Y + 20 + tk.row * 14}
textAnchor={anchor}
fontSize="10"
className="fill-gray-500"
>
{fmtTime(tk.t)}
</text>
</g>
);
})}
</svg>
</div>
</div>
{tooltip && (
<div
className="absolute z-10 pointer-events-none bg-gray-900/95 text-gray-100 text-xs rounded-md px-3 py-2 shadow-lg max-w-xs"
style={{ left: Math.max(0, Math.min(tooltip.x + 12, tooltip.maxLeft)), top: tooltip.y + 14 }}
>
{tooltip.content}
</div>
)}
</div>
<p className="mt-2 text-[11px] text-gray-400">
Time axis: linear within each segment; very short segments are stretched to a minimum width — the ticks at the
segment boundaries show the real times. * = inferred SSID.
</p>
</div>
);
}
@@ -0,0 +1,185 @@
import { describe, expect, it } from 'vitest';
import {
buildNetworkModel,
detectHotspot,
NetSegment,
} from '../networkTimelineModel';
import type { EnrollmentEvent, Session } from '@/types';
const T0 = Date.parse('2026-08-21T10:00:00.000Z');
const MIN = 60_000;
const iso = (offsetMs: number) => new Date(T0 + offsetMs).toISOString();
function ev(sequence: number, eventType: string, offsetMs: number, data?: Record<string, unknown>): EnrollmentEvent {
return {
eventId: `e${sequence}`,
sessionId: 's',
timestamp: iso(offsetMs),
eventType,
severity: 'Info',
source: 'test',
phase: -1,
message: '',
sequence,
data,
} as EnrollmentEvent;
}
const session = {
sessionId: 's',
tenantId: 't',
startedAt: iso(0),
enrollmentType: 'v1',
} as unknown as Session;
const ethernetInfo = (seq: number, offsetMs: number) =>
ev(seq, 'network_interface_info', offsetMs, {
connectionType: 'Ethernet',
linkSpeedMbps: 1000,
gateways: '10.0.0.1',
});
const wifiInfo = (seq: number, offsetMs: number) =>
ev(seq, 'network_interface_info', offsetMs, {
connectionType: 'WiFi',
linkSpeedMbps: 866,
gateways: '10.0.0.1',
});
describe('clock-era correction', () => {
it('rebases the axis after a live clock step and adds a marker', () => {
// Clock stepped +1h at real minute 5 — later raw timestamps are 1h ahead.
const events = [
ethernetInfo(1, 0),
ev(2, 'log_entry', 5 * MIN),
ev(3, 'system_clock_changed', 65 * MIN, {
timeDeltaMs: 3_600_000,
oldTime: iso(5 * MIN),
newTime: iso(65 * MIN),
backfilled: false,
}),
ev(4, 'log_entry', 70 * MIN),
];
const model = buildNetworkModel(session, events)!;
expect(model).not.toBeNull();
// True elapsed time is 10 minutes, not 70.
expect(model.t1 - model.t0).toBe(10 * MIN);
const clockMarker = model.lifeMarkers.find((m) => m.label.startsWith('Clock +'));
expect(clockMarker).toBeDefined();
expect(clockMarker!.label).toBe('Clock +1h 0m');
expect(clockMarker!.detail).toContain('axis rebased');
expect(clockMarker!.t).toBe(T0 + 5 * MIN);
expect(model.clockChangeCount).toBe(1);
});
it('does not rebase for a backfilled step but still shows a marker', () => {
const events = [
ethernetInfo(1, 0),
ev(2, 'system_clock_changed', 5 * MIN, {
timeDeltaMs: 3_600_000,
oldTime: iso(-60 * MIN),
newTime: iso(0),
backfilled: true,
timeCreated: iso(2 * MIN),
}),
ev(3, 'log_entry', 10 * MIN),
];
const model = buildNetworkModel(session, events)!;
expect(model.t1 - model.t0).toBe(10 * MIN);
const clockMarker = model.lifeMarkers.find((m) => m.label.startsWith('Clock +'));
expect(clockMarker).toBeDefined();
expect(clockMarker!.detail).toContain('before agent start');
});
it('ignores sub-30s steps (NTP nudges)', () => {
const events = [
ethernetInfo(1, 0),
ev(2, 'system_clock_changed', 5 * MIN, { timeDeltaMs: 10_000, backfilled: false }),
ev(3, 'log_entry', 10 * MIN),
];
const model = buildNetworkModel(session, events)!;
expect(model.clockChangeCount).toBe(0);
expect(model.lifeMarkers.some((m) => m.label.startsWith('Clock'))).toBe(false);
expect(model.t1 - model.t0).toBe(10 * MIN);
});
});
describe('sleep episodes', () => {
it('carves an asleep segment out of the active network', () => {
const events = [
wifiInfo(1, 0),
ev(2, 'wifi_signal_info', 1 * MIN, { wifiSsid: 'CorpNet', wifiSignalPercent: 80 }),
ev(3, 'system_sleep_episode', 20 * MIN, {
kind: 'modern_standby',
enteredAt: iso(10 * MIN),
exitedAt: iso(20 * MIN),
durationSeconds: 600,
onAcPower: false,
backfilled: false,
}),
ev(4, 'log_entry', 30 * MIN),
];
const model = buildNetworkModel(session, events)!;
const kinds = model.segments.map((s) => s.kind);
expect(kinds).toEqual(['wifi', 'asleep', 'wifi']);
expect(model.segments[1].start).toBe(T0 + 10 * MIN);
expect(model.segments[1].end).toBe(T0 + 20 * MIN);
expect(model.segments[1].sleepKind).toBe('modern_standby');
expect(model.segments[1].onAcPower).toBe(false);
expect(model.asleepMs).toBe(10 * MIN);
// Both wifi pieces keep the same identity (same network, one legend entry)
expect(model.segments[0].identity).toBe(model.segments[2].identity);
expect(model.distinctNetworks.size).toBe(1);
});
it('applies era correction to episode boundaries carried by a post-step event', () => {
// Step +1h at real minute 5; the sleep episode event (emitted at wake,
// raw minute 80 = real 20) carries raw enteredAt/exitedAt of 70/80.
const events = [
wifiInfo(1, 0),
ev(2, 'system_clock_changed', 65 * MIN, {
timeDeltaMs: 3_600_000,
oldTime: iso(5 * MIN),
newTime: iso(65 * MIN),
backfilled: false,
}),
ev(3, 'system_sleep_episode', 80 * MIN, {
kind: 'sleep',
enteredAt: iso(70 * MIN),
exitedAt: iso(80 * MIN),
backfilled: false,
}),
ev(4, 'log_entry', 85 * MIN),
];
const model = buildNetworkModel(session, events)!;
const asleep = model.segments.find((s) => s.kind === 'asleep')!;
expect(asleep.start).toBe(T0 + 10 * MIN);
expect(asleep.end).toBe(T0 + 20 * MIN);
expect(model.t1 - model.t0).toBe(25 * MIN);
});
});
describe('hotspot confidence', () => {
const wifiSeg = (ssid: string | undefined, gateway: string): NetSegment => ({
start: 0,
end: 1,
kind: 'wifi',
ssid,
gateway,
identity: 'x',
});
it('subnet-only evidence stays a hedged hint', () => {
const hint = detectHotspot(wifiSeg('Daniel Langhof', '172.20.10.1'))!;
expect(hint.vendor).toBe('Apple');
expect(hint.confident).toBe(false);
});
it('an iPhone-named SSID is confident', () => {
const hint = detectHotspot(wifiSeg("Ruben Ryhan's iPhone", '172.20.10.1'))!;
expect(hint.confident).toBe(true);
const ssidOnly = detectHotspot(wifiSeg('iPhone von Max', '192.168.0.1'))!;
expect(ssidOnly.vendor).toBe('Apple');
expect(ssidOnly.confident).toBe(true);
});
});
@@ -0,0 +1,578 @@
// ─────────────────────────────────────────────────────────────────────────────
// Network timeline — domain model.
//
// Derives network segments (Ethernet / WiFi SSIDs / offline gaps) from the
// session's event stream, assigns fixed-order categorical colors, builds the
// piecewise time→x scale and classifies probable smartphone hotspots from
// well-known vendor subnets.
//
// Clock correction: system_clock_changed events (timeDeltaMs is authoritative)
// split the stream into clock eras; every event timestamp after a live-observed
// step is rebased by the cumulative delta, so the axis shows true elapsed time.
// Backfilled steps (replayed from the event log at agent start — they happened
// before the session's event stream) only get a marker, never a rebase.
// Sleep: system_sleep_episode events (emitted retroactively at wake) overlay
// the affected time range as their own 'asleep' segment kind instead of
// attributing the quiet period to the active network.
// ─────────────────────────────────────────────────────────────────────────────
import { V1_PHASE_NAMES, V2_PHASE_NAMES } from '@/app/sessions/utils/phaseConstants';
import type { EnrollmentEvent, Session } from '@/types';
// ── Types ────────────────────────────────────────────────────────────────────
export type SegmentKind = 'ethernet' | 'wifi' | 'offline' | 'asleep' | 'unknown';
export interface HotspotHint {
vendor: 'Apple' | 'Windows' | 'Android';
reason: string;
/**
* true when the SSID itself matches a typical phone-hotspot name
* (e.g. "xyz's iPhone") — strong enough to drop the "?" from the badge.
* false = subnet-only evidence, shown as "Hotspot?".
*/
confident: boolean;
}
export interface NetSegment {
start: number;
end: number;
kind: SegmentKind;
ssid?: string;
ssidInferred?: boolean;
ip?: string;
gateway?: string;
linkSpeedMbps?: number;
adapterDescription?: string;
signalPercent?: number;
radioType?: string;
hotspot?: HotspotHint;
/** 'asleep' segments only: sleep/hibernate/modern_standby + wake details. */
sleepKind?: string;
wakeSourceText?: string;
onAcPower?: boolean;
identity: string;
}
export interface PhaseBand {
start: number;
end: number;
name: string;
}
export interface CheckMarker {
t: number;
ok: boolean;
reachable: number;
total: number;
results: { endpoint: string; reachable: boolean; latencyMs?: number; httpStatus?: number; error?: string }[];
}
export interface LifeMarker {
t: number;
label: string;
/** Optional extra tooltip line (clock markers: old→new time, reason). */
detail?: string;
}
export interface NetworkModel {
t0: number;
t1: number;
segments: NetSegment[];
colors: Map<string, string>;
scale: { tB: number[]; xB: number[]; x: (t: number) => number };
phases: PhaseBand[];
checks: CheckMarker[];
lifeMarkers: LifeMarker[];
networkEvents: EnrollmentEvent[];
offlineMs: number;
asleepMs: number;
clockChangeCount: number;
distinctNetworks: Set<string>;
switchCount: number;
hotspotDetected: boolean;
}
// ── Constants ────────────────────────────────────────────────────────────────
// Fixed-order categorical palette (validated light+dark, dataviz skill):
// Ethernet always blue; each distinct WiFi network takes the next slot.
export const ETHERNET_COLOR = '#3b82f6';
export const WIFI_COLORS = ['#16a34a', '#8b5cf6', '#d97706', '#0891b2'];
export const WIFI_OVERFLOW_COLOR = '#64748b';
export const OFFLINE_COLOR = '#dc2626';
export const ASLEEP_COLOR = '#64748b';
export const UNKNOWN_COLOR = '#9ca3af';
export const NETWORK_EVENT_TYPES = new Set([
'network_interface_info',
'network_state_change',
'network_connectivity_check',
'wifi_signal_info',
'network_adapters',
'network_bandwidth_estimate',
'dns_configuration',
'proxy_configuration',
'outbound_ip',
'system_clock_changed',
'system_sleep_episode',
]);
// ── Formatting helpers ───────────────────────────────────────────────────────
export function fmtTime(ms: number): string {
return new Date(ms).toLocaleTimeString(undefined, { hour12: false });
}
export function fmtDuration(ms: number): string {
const s = Math.round(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ${s % 60}s`;
const h = Math.floor(m / 60);
return `${h}h ${m % 60}m`;
}
export function segmentTitle(seg: NetSegment): string {
if (seg.kind === 'offline') return 'Offline';
if (seg.kind === 'asleep') return 'Asleep';
if (seg.kind === 'unknown') return 'Unknown';
if (seg.kind === 'ethernet') return 'Ethernet';
return seg.ssid ? `WiFi "${seg.ssid}"${seg.ssidInferred ? ' *' : ''}` : 'WiFi';
}
function str(v: unknown): string | undefined {
if (typeof v !== 'string') return undefined;
if (v === '' || v === 'n/a' || v === 'None') return undefined;
return v;
}
function num(v: unknown): number | undefined {
return typeof v === 'number' && isFinite(v) && v > 0 ? v : undefined;
}
// ── Hotspot heuristic ────────────────────────────────────────────────────────
// Vendor-default hotspot subnets. Apple hard-codes 172.20.10.0/28 for the
// personal hotspot — near-fingerprint precision. Windows Mobile Hotspot
// defaults to 192.168.137.1. Older Android uses 192.168.43.1 (newer versions
// randomize, so Android detection has false NEGATIVES). Always presented as
// a heuristic ("vermutlich"), never as fact.
export function detectHotspot(seg: NetSegment): HotspotHint | undefined {
if (seg.kind !== 'wifi') return undefined;
const gw = seg.gateway ?? '';
const ip = seg.ip ?? '';
// iOS default hotspot SSID is "<name>'s iPhone" (localized variants keep
// "iPhone"). A user COULD name any AP like this, but that is atypical —
// treat an iPhone-named SSID as confident, not just probable.
const iphoneSsid = /iphone/i.test(seg.ssid ?? '');
if (gw.includes('172.20.10.1') || ip.startsWith('172.20.10.')) {
return {
vendor: 'Apple',
confident: iphoneSsid,
reason: iphoneSsid
? 'subnet 172.20.10.x (hard-wired by iOS) and the SSID matches the typical iPhone hotspot name'
: 'subnet 172.20.10.x — hard-wired by iOS for the personal hotspot',
};
}
if (gw.includes('192.168.137.1') || ip.startsWith('192.168.137.')) {
return { vendor: 'Windows', confident: false, reason: 'subnet 192.168.137.x — Windows mobile hotspot default' };
}
if (gw.includes('192.168.43.1') || ip.startsWith('192.168.43.')) {
return { vendor: 'Android', confident: false, reason: 'subnet 192.168.43.x — classic Android hotspot default' };
}
if (iphoneSsid) {
return { vendor: 'Apple', confident: true, reason: `SSID "${seg.ssid}" matches the typical iPhone hotspot name` };
}
return undefined;
}
// ── Segment derivation ───────────────────────────────────────────────────────
export function buildSegments(events: EnrollmentEvent[], t0: number, t1: number): NetSegment[] {
const segs: NetSegment[] = [];
const netInfo = events.find((e) => e.eventType === 'network_interface_info');
const d0 = (netInfo?.data ?? {}) as Record<string, unknown>;
let cur: NetSegment;
if (netInfo && d0.status !== 'no_active_interface') {
const kind: SegmentKind = d0.connectionType === 'WiFi' ? 'wifi' : 'ethernet';
cur = {
start: t0,
end: t1,
kind,
gateway: str(d0.gateways),
linkSpeedMbps: num(d0.linkSpeedMbps),
adapterDescription: str(d0.adapterDescription),
identity: '',
};
} else {
cur = { start: t0, end: t1, kind: netInfo ? 'offline' : 'unknown', identity: '' };
}
const changes = events
.filter((e) => e.eventType === 'network_state_change')
.map((e) => ({ t: Date.parse(e.timestamp), data: (e.data ?? {}) as Record<string, unknown> }))
.filter((c) => !isNaN(c.t))
.sort((a, b) => a.t - b.t);
for (const { t, data } of changes) {
const tc = Math.min(Math.max(t, cur.start), t1);
cur.end = tc;
segs.push(cur);
if (data.hasNetwork === false) {
cur = { start: tc, end: t1, kind: 'offline', identity: '' };
} else {
cur = {
start: tc,
end: t1,
kind: data.after_connectionType === 'WiFi' ? 'wifi' : 'ethernet',
ssid: str(data.after_wifiSsid),
ip: str(data.after_ipAddress),
gateway: str(data.after_gateway),
linkSpeedMbps: num(data.after_linkSpeedMbps),
identity: '',
};
}
}
segs.push(cur);
// Enrich WiFi segments with signal samples (SSID often only known from
// wifi_signal_info — the NIC info event has no SSID).
for (const e of events.filter((x) => x.eventType === 'wifi_signal_info')) {
const t = Date.parse(e.timestamp);
const data = (e.data ?? {}) as Record<string, unknown>;
const seg = segs.find((s) => s.kind === 'wifi' && t >= s.start && t < s.end);
if (!seg) continue;
if (!seg.ssid && str(data.wifiSsid)) seg.ssid = str(data.wifiSsid);
if (typeof data.wifiSignalPercent === 'number') seg.signalPercent = data.wifiSignalPercent;
if (str(data.wifiRadioType)) seg.radioType = str(data.wifiRadioType);
}
// Infer missing SSIDs from a sibling WiFi segment on the same gateway
// (right after reconnect the WLAN API frequently has no SSID yet).
for (const seg of segs) {
if (seg.kind === 'wifi' && !seg.ssid && seg.gateway) {
const twin = segs.find((s) => s !== seg && s.kind === 'wifi' && s.ssid && s.gateway === seg.gateway);
if (twin) {
seg.ssid = twin.ssid;
seg.ssidInferred = true;
}
}
}
// Identity = color key, assigned in first-seen order (fixed, never cycled).
for (const seg of segs) {
seg.hotspot = detectHotspot(seg);
if (seg.kind === 'ethernet') seg.identity = 'ethernet';
else if (seg.kind === 'wifi') seg.identity = seg.ssid ? `wifi:${seg.ssid}` : `wifi:@${seg.gateway ?? seg.ip ?? '?'}`;
else seg.identity = seg.kind;
}
return segs.filter((s) => s.end > s.start);
}
export function buildColorMap(segs: NetSegment[]): Map<string, string> {
const map = new Map<string, string>();
let wifiIdx = 0;
for (const seg of segs) {
if (map.has(seg.identity)) continue;
if (seg.kind === 'ethernet') map.set(seg.identity, ETHERNET_COLOR);
else if (seg.kind === 'wifi') {
map.set(seg.identity, wifiIdx < WIFI_COLORS.length ? WIFI_COLORS[wifiIdx] : WIFI_OVERFLOW_COLOR);
wifiIdx++;
} else if (seg.kind === 'offline') map.set(seg.identity, OFFLINE_COLOR);
else if (seg.kind === 'asleep') map.set(seg.identity, ASLEEP_COLOR);
else map.set(seg.identity, UNKNOWN_COLOR);
}
return map;
}
// ── Piecewise time→x mapping ─────────────────────────────────────────────────
// Linear between segment boundaries; tiny segments get a minimum width so
// short offline gaps stay visible. Boundary ticks always show REAL times, so
// the axis stays honest despite the local stretch.
export const W = 1200;
// Drawing content is inset 64 units on BOTH sides: the right inset keeps the
// slanted lifecycle-marker labels inside the SVG, the left inset mirrors it
// for symmetry (and gives a left-edge marker label the same room).
export const CONTENT_LEFT = 64;
export const CONTENT_W = W - 64;
const CONTENT_SPAN = CONTENT_W - CONTENT_LEFT;
const MIN_SEG_W = 28;
export function buildScale(segs: NetSegment[]): NetworkModel['scale'] {
const widths = segs.map((s) => s.end - s.start);
const total = widths.reduce((a, b) => a + b, 0) || 1;
let px = widths.map((w) => Math.max((w / total) * CONTENT_SPAN, MIN_SEG_W));
const sum = px.reduce((a, b) => a + b, 0);
px = px.map((w) => (w / sum) * CONTENT_SPAN);
const tB: number[] = [segs[0]?.start ?? 0];
const xB: number[] = [CONTENT_LEFT];
segs.forEach((s, i) => {
tB.push(s.end);
xB.push(xB[i] + px[i]);
});
const x = (t: number): number => {
if (t <= tB[0]) return CONTENT_LEFT;
for (let i = 1; i < tB.length; i++) {
if (t <= tB[i]) {
const f = (t - tB[i - 1]) / Math.max(tB[i] - tB[i - 1], 1);
return xB[i - 1] + f * (xB[i] - xB[i - 1]);
}
}
return CONTENT_W;
};
return { tB, xB, x };
}
// ── Clock-era correction ─────────────────────────────────────────────────────
// system_clock_changed: data.timeDeltaMs is authoritative (the event's own
// Timestamp may be clamped server-side). Steps below 30s (NTP nudges) are
// ignored. Only live-observed steps rebase the axis — backfilled ones happened
// before the agent's event stream and would shift events that never saw the
// old clock; they get a marker only.
const MIN_CLOCK_STEP_MS = 30_000;
interface ClockChange {
sequence: number;
deltaMs: number;
oldTime?: string;
newTime?: string;
reason?: string;
backfilled: boolean;
rawT: number;
timeCreated?: string;
}
function extractClockChanges(events: EnrollmentEvent[]): ClockChange[] {
return events
.filter((e) => e.eventType === 'system_clock_changed')
.map((e) => {
const d = (e.data ?? {}) as Record<string, unknown>;
return {
sequence: e.sequence,
deltaMs: typeof d.timeDeltaMs === 'number' ? d.timeDeltaMs : NaN,
oldTime: typeof d.oldTime === 'string' ? d.oldTime : undefined,
newTime: typeof d.newTime === 'string' ? d.newTime : undefined,
reason: typeof d.reasonText === 'string' && d.reasonText ? d.reasonText : undefined,
backfilled: d.backfilled === true || d.backfilled === 'true' || d.backfilled === 'True',
rawT: Date.parse(e.timestamp),
timeCreated: typeof d.timeCreated === 'string' ? d.timeCreated : undefined,
};
})
.filter((c) => isFinite(c.deltaMs) && Math.abs(c.deltaMs) >= MIN_CLOCK_STEP_MS && !isNaN(c.rawT))
.sort((a, b) => a.sequence - b.sequence);
}
/** Cumulative live-step delta that applies to an event at this sequence. */
function cumDeltaAt(sequence: number, liveChanges: ClockChange[]): number {
let cum = 0;
for (const c of liveChanges) if (c.sequence <= sequence) cum += c.deltaMs;
return cum;
}
function correctTimestamps(events: EnrollmentEvent[], liveChanges: ClockChange[]): EnrollmentEvent[] {
if (liveChanges.length === 0) return events;
return events.map((e) => {
const t = Date.parse(e.timestamp);
if (isNaN(t)) return e;
const cum = cumDeltaAt(e.sequence, liveChanges);
return cum === 0 ? e : { ...e, timestamp: new Date(t - cum).toISOString() };
});
}
// ── Sleep episodes ───────────────────────────────────────────────────────────
// system_sleep_episode is emitted retroactively at wake; enteredAt/exitedAt are
// wall-clock payload fields, so they get the same era correction as the event
// that carried them (a clock step DURING the sleep is thereby approximated to
// the post-wake era — acceptable, both boundaries stay consistent).
interface SleepEpisode {
start: number;
end: number;
kind?: string;
wakeSourceText?: string;
onAcPower?: boolean;
}
function extractSleepEpisodes(events: EnrollmentEvent[], liveChanges: ClockChange[]): SleepEpisode[] {
const episodes: SleepEpisode[] = [];
for (const e of events.filter((x) => x.eventType === 'system_sleep_episode')) {
const d = (e.data ?? {}) as Record<string, unknown>;
const cum = cumDeltaAt(e.sequence, liveChanges);
const start = typeof d.enteredAt === 'string' ? Date.parse(d.enteredAt) - cum : NaN;
const end = typeof d.exitedAt === 'string' ? Date.parse(d.exitedAt) - cum : NaN;
if (isNaN(start) || isNaN(end) || !(end > start)) continue;
episodes.push({
start,
end,
kind: typeof d.kind === 'string' ? d.kind : undefined,
wakeSourceText:
typeof d.wakeSourceText === 'string'
? d.wakeSourceText
: typeof d.wakeSourceType === 'string'
? d.wakeSourceType
: undefined,
onAcPower: typeof d.onAcPower === 'boolean' ? d.onAcPower : undefined,
});
}
return episodes.sort((a, b) => a.start - b.start);
}
/** Carve sleep episodes out of the network segments as 'asleep' segments. */
function overlaySleep(segs: NetSegment[], episodes: SleepEpisode[], t0: number, t1: number): NetSegment[] {
let out = segs;
for (const ep of episodes) {
const s = Math.max(ep.start, t0);
const e = Math.min(ep.end, t1);
if (!(e > s)) continue;
const next: NetSegment[] = [];
for (const seg of out) {
if (e <= seg.start || s >= seg.end) {
next.push(seg);
continue;
}
if (s > seg.start) next.push({ ...seg, end: s });
if (e < seg.end) next.push({ ...seg, start: e });
}
next.push({
start: s,
end: e,
kind: 'asleep',
identity: 'asleep',
sleepKind: ep.kind,
wakeSourceText: ep.wakeSourceText,
onAcPower: ep.onAcPower,
});
next.sort((a, b) => a.start - b.start);
out = next;
}
return out.filter((x) => x.end > x.start);
}
// ── Full model ───────────────────────────────────────────────────────────────
export function buildNetworkModel(session: Session, rawEvents: EnrollmentEvent[]): NetworkModel | null {
if (rawEvents.length === 0) return null;
const clockChanges = extractClockChanges(rawEvents);
const liveChanges = clockChanges.filter((c) => !c.backfilled);
const events = correctTimestamps(rawEvents, liveChanges);
const evTimes = events.map((e) => Date.parse(e.timestamp)).filter((t) => !isNaN(t));
if (evTimes.length === 0) return null;
const t0 = Math.min(Date.parse(session.startedAt), ...evTimes);
// completedAt is server wall clock — only usable as the right edge when the
// axis was not rebased (after a rebase it lives in a different clock era).
const completedT = session.completedAt && liveChanges.length === 0 ? Date.parse(session.completedAt) : NaN;
const t1 = Math.max(isNaN(completedT) ? 0 : completedT, ...evTimes);
if (!(t1 > t0)) return null;
const sleepEpisodes = extractSleepEpisodes(rawEvents, liveChanges);
const segments = overlaySleep(buildSegments(events, t0, t1), sleepEpisodes, t0, t1);
if (segments.length === 0) return null;
const colors = buildColorMap(segments);
const scale = buildScale(segments);
const phaseNames = session.enrollmentType === 'v2' ? V2_PHASE_NAMES : V1_PHASE_NAMES;
const transitions = events
.filter((e) => e.eventType === 'phase_transition')
.map((e) => ({ t: Date.parse(e.timestamp), name: phaseNames[e.phase] ?? e.phaseName ?? `Phase ${e.phase}` }))
.filter((p) => !isNaN(p.t))
.sort((a, b) => a.t - b.t);
const phases: PhaseBand[] = [];
let prev = { t: t0, name: 'Start' };
for (const tr of transitions) {
if (tr.t > prev.t) phases.push({ start: prev.t, end: tr.t, name: prev.name });
prev = tr;
}
phases.push({ start: prev.t, end: t1, name: prev.name });
const checks: CheckMarker[] = events
.filter((e) => e.eventType === 'network_connectivity_check')
.map((e) => {
const d = (e.data ?? {}) as Record<string, unknown>;
const results = Array.isArray(d.results) ? (d.results as CheckMarker['results']) : [];
return {
t: Date.parse(e.timestamp),
ok: d.allReachable === true,
reachable: typeof d.reachableCount === 'number' ? d.reachableCount : results.filter((r) => r.reachable).length,
total: typeof d.totalCount === 'number' ? d.totalCount : results.length,
results,
};
})
.filter((c) => !isNaN(c.t));
const lifeMarkers: LifeMarker[] = events
.filter((e) =>
['system_reboot_detected', 'desktop_arrived', 'enrollment_complete', 'enrollment_failed'].includes(e.eventType),
)
.map((e) => ({
t: Date.parse(e.timestamp),
label:
e.eventType === 'system_reboot_detected'
? 'Reboot'
: e.eventType === 'desktop_arrived'
? 'Desktop'
: e.eventType === 'enrollment_complete'
? 'Completed'
: 'Failed',
}))
.filter((m) => !isNaN(m.t));
// Clock-step markers: live steps sit at their corrected changepoint;
// backfilled steps (pre-agent) use their original event-log time when known.
for (const c of clockChanges) {
const t = c.backfilled
? (c.timeCreated ? Date.parse(c.timeCreated) : NaN)
: c.rawT - cumDeltaAt(c.sequence, liveChanges);
if (isNaN(t) || t < t0 || t > t1) continue;
const sign = c.deltaMs >= 0 ? '+' : '−';
const oldT = c.oldTime ? Date.parse(c.oldTime) : NaN;
const newT = c.newTime ? Date.parse(c.newTime) : NaN;
const range = !isNaN(oldT) && !isNaN(newT) ? `${fmtTime(oldT)} → ${fmtTime(newT)}` : '';
lifeMarkers.push({
t,
label: `Clock ${sign}${fmtDuration(Math.abs(c.deltaMs))}`,
detail: [range, c.reason, c.backfilled ? 'before agent start (not rebased)' : 'axis rebased']
.filter(Boolean)
.join(' · '),
});
}
lifeMarkers.sort((a, b) => a.t - b.t);
const networkEvents = events.filter((e) => NETWORK_EVENT_TYPES.has(e.eventType));
const offlineMs = segments.filter((s) => s.kind === 'offline').reduce((a, s) => a + (s.end - s.start), 0);
const asleepMs = segments.filter((s) => s.kind === 'asleep').reduce((a, s) => a + (s.end - s.start), 0);
const distinctNetworks = new Set(
segments.filter((s) => s.kind === 'wifi' || s.kind === 'ethernet').map((s) => s.identity),
);
const switchCount = events.filter((e) => e.eventType === 'network_state_change').length;
const hotspotDetected = segments.some((s) => s.hotspot);
return {
t0,
t1,
segments,
colors,
scale,
phases,
checks,
lifeMarkers,
networkEvents,
offlineMs,
asleepMs,
clockChangeCount: clockChanges.length,
distinctNetworks,
switchCount,
hotspotDetected,
};
}
@@ -0,0 +1,249 @@
'use client';
// ─────────────────────────────────────────────────────────────────────────────
// Network timeline.
//
// Standalone full-analysis view of the network timeline for one session:
// stats row, the NetworkBand chart, and the raw network event list. Reached
// via the "Network timeline" deep link in the session detail page's
// DeviceDetailsCard (Network section).
//
// Open at: /sessions/network-timeline?id=<sessionId>&tenantId=<tenantId>
// ─────────────────────────────────────────────────────────────────────────────
import { Suspense, useCallback, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { ProtectedRoute } from '@/components/ProtectedRoute';
import { useAuth } from '@/contexts/AuthContext';
import { api } from '@/lib/api';
import { authenticatedFetch } from '@/lib/authenticatedFetch';
import { extractContinuation, MAX_EAGER_PAGES } from '@/lib/paginationLink';
import { sessionUrl } from '@/lib/routes';
import NetworkBand from './NetworkBand';
import { buildNetworkModel, fmtDuration, NetworkModel } from './networkTimelineModel';
import type { EnrollmentEvent, Session } from '@/types';
// ── Data loading ─────────────────────────────────────────────────────────────
function useSessionData(sessionId: string | null, tenantIdOverride: string | undefined) {
const { getAccessToken } = useAuth();
const [session, setSession] = useState<Session | null>(null);
const [events, setEvents] = useState<EnrollmentEvent[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
if (!sessionId) return;
setLoading(true);
setError(null);
try {
const sResp = await authenticatedFetch(api.sessions.get(sessionId, tenantIdOverride), getAccessToken);
if (!sResp.ok) throw new Error(`Failed to load session (${sResp.status})`);
const sData = await sResp.json();
const found: Session | undefined =
sData.session ?? sData.sessions?.find((s: Session) => s.sessionId === sessionId);
if (!found) throw new Error('Session not found');
setSession(found);
const tenantId = tenantIdOverride ?? found.tenantId;
const all: EnrollmentEvent[] = [];
let url = api.sessions.events(sessionId, tenantId, { pageSize: 200 });
for (let page = 0; page < MAX_EAGER_PAGES; page++) {
const resp = await authenticatedFetch(url, getAccessToken);
if (!resp.ok) throw new Error(`Failed to load events (${resp.status})`);
const data = await resp.json();
if (Array.isArray(data.events)) all.push(...data.events);
const cont = extractContinuation(data.nextLink);
if (!cont) break;
url = api.sessions.events(sessionId, tenantId, { pageSize: 200, continuation: cont });
}
all.sort((a, b) => a.sequence - b.sequence);
setEvents(all);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, [sessionId, tenantIdOverride, getAccessToken]);
useEffect(() => {
const run = async () => {
await load();
};
void run();
}, [load]);
return { session, events, loading, error };
}
// ── Page ─────────────────────────────────────────────────────────────────────
export default function NetworkTimelinePage() {
return (
<ProtectedRoute>
<Suspense fallback={<div className="p-8 text-gray-500">Loading…</div>}>
<Content />
</Suspense>
</ProtectedRoute>
);
}
function Content() {
const searchParams = useSearchParams();
const sessionId = searchParams?.get('id') ?? null;
const tenantIdOverride = searchParams?.get('tenantId') ?? undefined;
const { session, events, loading, error } = useSessionData(sessionId, tenantIdOverride);
const model = useMemo(() => (session ? buildNetworkModel(session, events) : null), [session, events]);
if (!sessionId) {
return (
<div className="max-w-2xl mx-auto p-8">
<h1 className="text-2xl font-bold mb-2 text-gray-900">Network Timeline</h1>
<p className="text-gray-600 mb-4">
Provide the session ID via query parameters:{' '}
<code className="bg-gray-100 px-1 rounded">/sessions/network-timeline?id=&lt;sessionId&gt;&amp;tenantId=&lt;tenantId&gt;</code>
</p>
</div>
);
}
return (
<div className="max-w-7xl mx-auto p-4 sm:p-8">
<h1 className="text-2xl font-bold text-gray-900 mb-1">Network Timeline</h1>
{session && (
<p className="text-sm text-gray-600 mb-6">
{session.deviceName} · {session.model} · SN {session.serialNumber} ·{' '}
<span className="font-medium">{session.status}</span> ·{' '}
<Link
href={sessionUrl(session.sessionId, { tenantId: session.tenantId })}
className="text-blue-600 hover:underline"
>
Open session detail
</Link>
</p>
)}
{loading && <div className="text-gray-500 py-12 text-center">Loading session data…</div>}
{error && <div className="text-red-600 py-6">{error}</div>}
{model && session && (
<>
<StatsRow model={model} />
<div className="bg-white border border-gray-200 rounded-lg p-4 mb-6">
<NetworkBand model={model} />
{model.hotspotDetected && (
<p className="mt-1 text-[11px] text-amber-700">
Hotspot classification is heuristic: &quot;Hotspot?&quot; marks subnet-only evidence (e.g. 172.20.10.x
on iOS); without the &quot;?&quot; the SSID itself matches a typical phone hotspot name.
</p>
)}
</div>
<EventList events={model.networkEvents} />
</>
)}
</div>
);
}
// ── Stats row ────────────────────────────────────────────────────────────────
function StatsRow({ model }: { model: NetworkModel }) {
const wifiSegs = model.segments.filter((s) => s.kind === 'wifi' && s.signalPercent != null);
const avgSignal = wifiSegs.length
? Math.round(wifiSegs.reduce((a, s) => a + (s.signalPercent ?? 0), 0) / wifiSegs.length)
: null;
const lastCheck = model.checks[model.checks.length - 1];
const items: { label: string; value: string; tone?: 'bad' | 'good' }[] = [
{ label: 'Duration', value: fmtDuration(model.t1 - model.t0) },
{ label: 'Networks', value: String(model.distinctNetworks.size) },
{ label: 'Network change events', value: String(model.switchCount) },
{
label: 'Offline total',
value: model.offlineMs > 0 ? fmtDuration(model.offlineMs) : '—',
tone: model.offlineMs > 0 ? 'bad' : undefined,
},
];
if (model.asleepMs > 0) items.push({ label: 'Asleep total', value: fmtDuration(model.asleepMs) });
if (model.clockChangeCount > 0) items.push({ label: 'Clock changes', value: String(model.clockChangeCount) });
if (avgSignal != null) items.push({ label: 'Avg WiFi signal', value: `${avgSignal}%` });
if (lastCheck)
items.push({
label: 'Last connectivity check',
value: `${lastCheck.reachable}/${lastCheck.total} reachable`,
tone: lastCheck.ok ? 'good' : 'bad',
});
return (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3 mb-6">
{items.map((it) => (
<div key={it.label} className="bg-white border border-gray-200 rounded-lg px-3 py-2">
<div className="text-[11px] uppercase tracking-wide text-gray-500">{it.label}</div>
<div
className={`text-lg font-semibold ${
it.tone === 'bad' ? 'text-red-600' : it.tone === 'good' ? 'text-green-600' : 'text-gray-900'
}`}
>
{it.value}
</div>
</div>
))}
</div>
);
}
// ── Event list ───────────────────────────────────────────────────────────────
const CHANGE_TYPE_BADGES: Record<string, string> = {
network_lost: 'bg-red-100 text-red-800',
network_restored: 'bg-green-100 text-green-800',
type_change: 'bg-amber-100 text-amber-800',
ssid_change: 'bg-violet-100 text-violet-800',
adapter_change: 'bg-cyan-100 text-cyan-800',
ip_change: 'bg-gray-100 text-gray-800',
};
function EventList({ events }: { events: EnrollmentEvent[] }) {
if (events.length === 0) {
return <div className="text-gray-500 py-6">No network or system events in this session.</div>;
}
return (
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
<div className="px-4 py-3 border-b border-gray-200 font-semibold text-gray-900">
Network &amp; system events ({events.length})
</div>
<ul className="divide-y divide-gray-100">
{events.map((e) => {
const d = (e.data ?? {}) as Record<string, unknown>;
const changeType = typeof d.changeType === 'string' ? d.changeType : null;
const rest = Object.fromEntries(Object.entries(d).filter(([k]) => k !== 'decisionState'));
return (
<li key={e.eventId || `${e.sessionId}-${e.sequence}`} className="px-4 py-2.5">
<details>
<summary className="cursor-pointer flex flex-wrap items-center gap-2 text-sm">
<span className="font-mono text-xs text-gray-500 w-20 shrink-0">
{new Date(e.timestamp).toLocaleTimeString(undefined, { hour12: false })}
</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-50 text-blue-700 font-mono">{e.eventType}</span>
{changeType && (
<span
className={`text-xs px-1.5 py-0.5 rounded font-medium ${CHANGE_TYPE_BADGES[changeType] ?? 'bg-gray-100 text-gray-700'}`}
>
{changeType}
</span>
)}
<span className="text-gray-800">{e.message}</span>
</summary>
<pre className="mt-2 text-xs bg-gray-50 rounded p-3 overflow-x-auto text-gray-700">
{JSON.stringify(rest, null, 2)}
</pre>
</details>
</li>
);
})}
</ul>
</div>
);
}
@@ -191,6 +191,7 @@ function SessionDetailContent() {
isGatherRulesSession,
displayStatus,
enrollmentDurationFromEvents,
standbySeconds,
isSkipUserStatusPage,
isWhiteGloveSession,
whiteGloveSplitSequence,
@@ -565,6 +566,7 @@ function SessionDetailContent() {
isGatherRulesSession={isGatherRulesSession}
ntpOffset={ntpOffset}
configMgrDetected={configMgrDetected}
standbySeconds={standbySeconds}
/>
</div>
)}
@@ -189,6 +189,8 @@ interface TenantConfigContextValue {
setEnableGeoLocation: (v: boolean) => void;
enableTimezoneAutoSet: boolean;
setEnableTimezoneAutoSet: (v: boolean) => void;
enableDoGroupIdAutoSet: boolean;
setEnableDoGroupIdAutoSet: (v: boolean) => void;
enableImeMatchLog: boolean;
setEnableImeMatchLog: (v: boolean) => void;
enableGatherRuleDebugLog: boolean;
@@ -410,6 +412,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
const [contactEmail, setContactEmail] = useState("");
const [enableGeoLocation, setEnableGeoLocation] = useState(true);
const [enableTimezoneAutoSet, setEnableTimezoneAutoSet] = useState(false);
const [enableDoGroupIdAutoSet, setEnableDoGroupIdAutoSet] = useState(false);
const [enableImeMatchLog, setEnableImeMatchLog] = useState(false);
const [enableGatherRuleDebugLog, setEnableGatherRuleDebugLog] = useState(false);
const [logLevel, setLogLevel] = useState("Info");
@@ -546,6 +549,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
setContactEmail(data.contactEmail ?? "");
setEnableGeoLocation(data.enableGeoLocation ?? true);
setEnableTimezoneAutoSet(data.enableTimezoneAutoSet ?? false);
setEnableDoGroupIdAutoSet(data.enableDoGroupIdAutoSet ?? false);
setEnableImeMatchLog(data.enableImeMatchLog ?? false);
setEnableGatherRuleDebugLog(data.enableGatherRuleDebugLog ?? false);
setLogLevel(data.logLevel ?? "Info");
@@ -755,6 +759,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
contactEmail: contactEmail.trim(),
enableGeoLocation,
enableTimezoneAutoSet,
enableDoGroupIdAutoSet,
enableImeMatchLog,
enableGatherRuleDebugLog,
logLevel,
@@ -858,7 +863,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
manufacturerWhitelist, modelWhitelist, webhookNotifyOnHardwareRejection, validateAutopilotDevice, validateCorporateIdentifier, validateDeviceAssociation, validateCloudPcDevice,
dataRetentionDays, sessionTimeoutHours, enablePerformanceCollector, performanceCollectorInterval,
helloWaitTimeoutSeconds, selfDestructOnComplete, keepLogFile, rebootOnComplete, rebootDelaySeconds,
contactEmail, enableGeoLocation, enableTimezoneAutoSet, enableImeMatchLog, enableGatherRuleDebugLog, logLevel, showScriptOutput, showEnrollmentSummary,
contactEmail, enableGeoLocation, enableTimezoneAutoSet, enableDoGroupIdAutoSet, enableImeMatchLog, enableGatherRuleDebugLog, logLevel, showScriptOutput, showEnrollmentSummary,
enrollmentSummaryTimeoutSeconds, enrollmentSummaryBrandingImageUrl, enrollmentSummaryLaunchRetrySeconds,
notificationChannels,
slaTargetSuccessRate, slaTargetMaxDurationMinutes, slaTargetAppInstallSuccessRate,
@@ -1246,6 +1251,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
// (resetting it from the Agent Settings card silently discarded unsaved Contact edits).
setEnableGeoLocation(config.enableGeoLocation ?? true);
setEnableTimezoneAutoSet(config.enableTimezoneAutoSet ?? false);
setEnableDoGroupIdAutoSet(config.enableDoGroupIdAutoSet ?? false);
setEnableImeMatchLog(config.enableImeMatchLog ?? false);
setEnableGatherRuleDebugLog(config.enableGatherRuleDebugLog ?? false);
setLogLevel(config.logLevel ?? "Info");
@@ -1696,6 +1702,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
handleSaveContact, handleResetContact,
enableGeoLocation, setEnableGeoLocation,
enableTimezoneAutoSet, setEnableTimezoneAutoSet,
enableDoGroupIdAutoSet, setEnableDoGroupIdAutoSet,
enableImeMatchLog, setEnableImeMatchLog,
enableGatherRuleDebugLog, setEnableGatherRuleDebugLog,
logLevel, setLogLevel,
@@ -1786,7 +1793,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
enablePerformanceCollector, performanceCollectorInterval, helloWaitTimeoutSeconds,
selfDestructOnComplete, keepLogFile, rebootOnComplete, rebootDelaySeconds,
contactEmail, handleSaveContact, handleResetContact,
enableGeoLocation, enableTimezoneAutoSet, enableImeMatchLog, enableGatherRuleDebugLog,
enableGeoLocation, enableTimezoneAutoSet, enableDoGroupIdAutoSet, enableImeMatchLog, enableGatherRuleDebugLog,
logLevel, showScriptOutput, showEnrollmentSummary, enrollmentSummaryTimeoutSeconds,
enrollmentSummaryBrandingImageUrl, enrollmentSummaryLaunchRetrySeconds,
handleSaveAgentSettings, handleResetAgentSettings,
@@ -34,7 +34,7 @@ const EXPECTED_OWNED_FIELDS = [
// agentSettings
"enablePerformanceCollector", "performanceCollectorIntervalSeconds", "helloWaitTimeoutSeconds",
"selfDestructOnComplete", "keepLogFile", "rebootOnComplete", "rebootDelaySeconds",
"enableGeoLocation", "enableTimezoneAutoSet", "enableImeMatchLog", "enableGatherRuleDebugLog",
"enableGeoLocation", "enableTimezoneAutoSet", "enableDoGroupIdAutoSet", "enableImeMatchLog", "enableGatherRuleDebugLog",
"logLevel", "showScriptOutput", "showEnrollmentSummary", "enrollmentSummaryTimeoutSeconds",
"enrollmentSummaryBrandingImageUrl", "enrollmentSummaryLaunchRetrySeconds",
// agentAnalyzers
@@ -16,6 +16,7 @@ export function SectionAgentSettings() {
rebootDelaySeconds, setRebootDelaySeconds,
enableGeoLocation, setEnableGeoLocation,
enableTimezoneAutoSet, setEnableTimezoneAutoSet,
enableDoGroupIdAutoSet, setEnableDoGroupIdAutoSet,
enableImeMatchLog, setEnableImeMatchLog,
enableGatherRuleDebugLog, setEnableGatherRuleDebugLog,
logLevel, setLogLevel,
@@ -50,6 +51,8 @@ export function SectionAgentSettings() {
setEnableGeoLocation={setEnableGeoLocation}
enableTimezoneAutoSet={enableTimezoneAutoSet}
setEnableTimezoneAutoSet={setEnableTimezoneAutoSet}
enableDoGroupIdAutoSet={enableDoGroupIdAutoSet}
setEnableDoGroupIdAutoSet={setEnableDoGroupIdAutoSet}
enableImeMatchLog={enableImeMatchLog}
setEnableImeMatchLog={setEnableImeMatchLog}
enableGatherRuleDebugLog={enableGatherRuleDebugLog}
@@ -22,6 +22,8 @@ interface AgentSettingsSectionProps {
setEnableGeoLocation: (value: boolean) => void;
enableTimezoneAutoSet: boolean;
setEnableTimezoneAutoSet: (value: boolean) => void;
enableDoGroupIdAutoSet: boolean;
setEnableDoGroupIdAutoSet: (value: boolean) => void;
enableImeMatchLog: boolean;
setEnableImeMatchLog: (value: boolean) => void;
enableGatherRuleDebugLog: boolean;
@@ -64,6 +66,8 @@ export default function AgentSettingsSection({
setEnableGeoLocation,
enableTimezoneAutoSet,
setEnableTimezoneAutoSet,
enableDoGroupIdAutoSet,
setEnableDoGroupIdAutoSet,
enableImeMatchLog,
setEnableImeMatchLog,
enableGatherRuleDebugLog,
@@ -188,6 +192,20 @@ export default function AgentSettingsSection({
</div>
)}
{/* Delivery Optimization Group ID */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
<div>
<p className="font-medium text-gray-900">Set Delivery Optimization Group ID</p>
<p className="text-sm text-gray-500">
Set the DOGroupId policy from a network fingerprint — a deterministic GUID derived from the default gateway&apos;s IP and MAC address, so devices on the same local network peer with each other. Takes effect with DO Download Mode &quot;Group (2)&quot;; an existing DOGroupId or DOGroupIdSource policy (Intune/GPO) is never overwritten.
</p>
</div>
<button onClick={() => setEnableDoGroupIdAutoSet(!enableDoGroupIdAutoSet)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${enableDoGroupIdAutoSet ? 'bg-violet-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enableDoGroupIdAutoSet ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* IME Match Log */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
<div>
@@ -76,6 +76,7 @@ export const SECTION_FIELD_MAP = {
"rebootDelaySeconds",
"enableGeoLocation",
"enableTimezoneAutoSet",
"enableDoGroupIdAutoSet",
"enableImeMatchLog",
"enableGatherRuleDebugLog",
"logLevel",
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useState } from "react";
import { useAuth } from "../../../../contexts/AuthContext";
import { useTenant } from "../../../../contexts/TenantContext";
import { api } from "@/lib/api";
import { AGENT_DOWNLOAD_URL } from "@/utils/config";
import { AGENT_DOWNLOAD_URL, DOCS_URL } from "@/utils/config";
import { authenticatedFetch } from "@/lib/authenticatedFetch";
import { trackEvent } from "@/lib/appInsights";
@@ -116,10 +116,13 @@ export function SectionOptionalGraphCapabilities() {
const featureRows = status?.features ?? [];
const allPermissions = Array.from(new Set(featureRows.flatMap(f => f.requiredPermissions)));
const allPermissionsLine = allPermissions.map(p => `"${p}"`).join(",");
const featureNames = featureRows.map(f => f.name);
const exampleFeature = featureNames[0] ?? "ScriptDisplayNames";
const scriptDownloadUrl = `${AGENT_DOWNLOAD_URL}/Grant-AutopilotMonitorAddOn.ps1`;
const docsUrl = `${DOCS_URL}/reference/optional-graph-permissions`;
const psCommand = status?.clientId
? `irm '${scriptDownloadUrl}' -OutFile .\\Grant-AutopilotMonitorAddOn.ps1\n.\\Grant-AutopilotMonitorAddOn.ps1 \`\n -ClientId "${status.clientId}" \`\n -Permissions ${allPermissionsLine} \`\n -TenantId "${tenantId ?? "<your-tenant-id>"}"`
? `irm '${scriptDownloadUrl}' -OutFile .\\Grant-AutopilotMonitorAddOn.ps1\n.\\Grant-AutopilotMonitorAddOn.ps1 \`\n -ClientId "${status.clientId}" \`\n -Features ${exampleFeature} \`\n -TenantId "${tenantId ?? "<your-tenant-id>"}"`
: "";
return (
@@ -133,7 +136,16 @@ export function SectionOptionalGraphCapabilities() {
additional permissions on the Autopilot Monitor service principal in your tenant. Grant them at any
time by running the small <span className="font-mono">Grant-AutopilotMonitorAddOn.ps1</span>{" "}script
with a Global Administrator (or Privileged Role Administrator) sign-in — the script does not change
the published app manifest, only your tenant&apos;s local grant.
the published app manifest, only your tenant&apos;s local grant. See the{" "}
<a
href={docsUrl}
target="_blank"
rel="noopener noreferrer"
className="text-green-700 underline hover:text-green-800"
>
Optional Graph Permissions documentation
</a>{" "}
for the full walkthrough.
</p>
</header>
@@ -246,10 +258,20 @@ export function SectionOptionalGraphCapabilities() {
)}
</div>
<p className="mt-3 text-xs text-gray-500">
The command above grants the <span className="font-mono">{exampleFeature}</span>{" "}capability.
Swap the <span className="font-mono">-Features</span> value as needed — available:{" "}
<span className="font-mono">{[...featureNames, "All"].join(", ")}</span>, where{" "}
<span className="font-mono">All</span> grants every optional capability in one go and{" "}
<span className="font-mono">-Features All -Revoke</span> removes them all again. If you prefer raw
permission strings, pass <span className="font-mono">-Permissions</span> with the list from{" "}
<span className="font-mono">Copy permissions</span>.
</p>
<details className="mt-4 text-sm text-gray-600">
<summary className="cursor-pointer text-gray-700 font-medium">What this script does (no surprises)</summary>
<ul className="mt-2 list-disc list-inside space-y-1">
<li>Signs in to Microsoft Graph with delegated permissions <span className="font-mono">AppRoleAssignment.ReadWrite.All</span> + <span className="font-mono">Application.Read.All</span>.</li>
<li>Signs in to Microsoft Graph — in Azure Cloud Shell via the ambient identity of the signed-in admin (no device-code prompt), elsewhere interactively with delegated permissions <span className="font-mono">AppRoleAssignment.ReadWrite.All</span> + <span className="font-mono">Application.Read.All</span>.</li>
<li>Finds the Autopilot Monitor service principal in your tenant (by ClientId above).</li>
<li>Adds an <span className="font-mono">appRoleAssignment</span> for each listed Graph permission, only if missing.</li>
<li>Never touches the published app manifest. Revoke at any time via <span className="font-mono">-Revoke</span>.</li>
@@ -72,6 +72,7 @@ export interface TenantConfiguration {
contactEmail?: string;
enableGeoLocation?: boolean;
enableTimezoneAutoSet?: boolean;
enableDoGroupIdAutoSet?: boolean;
enableImeMatchLog?: boolean;
enableGatherRuleDebugLog?: boolean;
logLevel?: string;
@@ -28,6 +28,11 @@ import {
* the target shapes.
*/
// Real static pages under /sessions/ — never legacy session ids. Every new
// /sessions/<subpage> route MUST be added here, or the legacy rewrite below
// hijacks it into /sessions?id=<subpage>.
const SESSIONS_STATIC_SIBLINGS = new Set(["inspector", "network-timeline"]);
function legacyTarget(pathname: string, search: URLSearchParams, hash: string): Route | null {
const seg = pathname.replace(/\/+$/, "").split("/").filter(Boolean).map(decodeURIComponent);
@@ -35,8 +40,8 @@ function legacyTarget(pathname: string, search: URLSearchParams, hash: string):
if (seg.length === 3 && seg[0] === "sessions" && seg[2] === "inspector") {
return inspectorUrl(seg[1], { tab: search.get("tab") ?? undefined });
}
// /sessions/{id} — "inspector" as an id cannot occur (the static sibling wins)
if (seg.length === 2 && seg[0] === "sessions" && seg[1] !== "inspector") {
// /sessions/{id} — a static sibling as an id cannot occur (the static page wins)
if (seg.length === 2 && seg[0] === "sessions" && !SESSIONS_STATIC_SIBLINGS.has(seg[1])) {
return sessionUrl(seg[1], {
tenantId: search.get("tenantId") ?? undefined,
hash: hash || undefined,
@@ -53,6 +53,7 @@ describe("legacyTarget", () => {
it("leaves canonical and unrelated paths alone", () => {
expect(legacyTarget("/sessions", q("id=abc"), "")).toBeNull();
expect(legacyTarget("/sessions/inspector", q("id=abc"), "")).toBeNull();
expect(legacyTarget("/sessions/network-timeline", q("id=abc&tenantId=t"), "")).toBeNull();
expect(legacyTarget("/apps/detail", q("name=x"), "")).toBeNull();
expect(legacyTarget("/admin/backups/detail", q("id=x"), "")).toBeNull();
expect(legacyTarget("/admin/customs-archive/detail", q(""), "")).toBeNull();
@@ -2,6 +2,8 @@ import { describe, it, expect } from "vitest";
import {
readTimeProvenance,
classifyTimeJump,
readClockChangeDeltaMs,
findExplainingClockChange,
BACKWARD_JUMP_THRESHOLD_MS,
type TimeJumpInput,
} from "../timeProvenance";
@@ -115,4 +117,45 @@ describe("classifyTimeJump", () => {
it("respects a caller-supplied threshold", () => {
expect(classifyTimeJump(input(T0), input(minutes(-3)), 120_000)).toEqual({ deltaMs: 180_000, cause: null });
});
it("names a recorded OS clock set (ground truth) matching the jump magnitude", () => {
// A −60 min system_clock_changed step explains a ~60 min backwards display step.
const jump = classifyTimeJump(input(T0), input(minutes(-60)), undefined, [-60 * 60 * 1000]);
expect(jump!.cause).toBe("clock-set");
});
it("clock-set (ground truth) wins over derived-timestamp inference (precedence)", () => {
const curr = input(minutes(-60), { derivedTimestamp: "true" });
const jump = classifyTimeJump(input(T0), curr, undefined, [-60 * 60 * 1000]);
expect(jump!.cause).toBe("clock-set");
});
it("stays unexplained when the recorded clock deltas do not match the jump", () => {
// Forward set, and a backward set of a very different magnitude — neither explains −60 min.
const jump = classifyTimeJump(input(T0), input(minutes(-60)), undefined, [60 * 60 * 1000, -5 * 60 * 1000]);
expect(jump!.cause).toBeNull();
});
});
describe("clock-change ground truth helpers", () => {
it("reads the signed payload delta (number or numeric string)", () => {
expect(readClockChangeDeltaMs({ timeDeltaMs: -3_600_000 })).toBe(-3_600_000);
expect(readClockChangeDeltaMs({ timeDeltaMs: "7200000" })).toBe(7_200_000);
expect(readClockChangeDeltaMs({ time_delta_ms: "-1000" })).toBe(-1000);
expect(readClockChangeDeltaMs({})).toBeNull();
expect(readClockChangeDeltaMs(undefined)).toBeNull();
});
it("matches within max(60 s, 20 %) tolerance — the display step includes elapsed real time", () => {
const hour = 60 * 60 * 1000;
expect(findExplainingClockChange(hour, [-hour])).toBe(-hour);
// 10 min drift on a 60 min jump is inside the 20 % band …
expect(findExplainingClockChange(hour, [-(hour - 10 * 60 * 1000)])).toBe(-(hour - 10 * 60 * 1000));
// … 20 min is outside.
expect(findExplainingClockChange(hour, [-(hour - 20 * 60 * 1000)])).toBeNull();
// Small jumps use the absolute 60 s floor.
expect(findExplainingClockChange(6 * 60 * 1000, [-(5 * 60 * 1000)])).toBe(-(5 * 60 * 1000));
// A forward set never explains a backward step.
expect(findExplainingClockChange(hour, [hour])).toBeNull();
});
});
@@ -39,6 +39,13 @@ export function inspectorUrl(sessionId: string, opts?: { tab?: string }): Route
return withQuery("/sessions/inspector", { id: sessionId, tab: opts?.tab });
}
export function networkTimelineUrl(
sessionId: string,
opts?: { tenantId?: string },
): Route {
return withQuery("/sessions/network-timeline", { id: sessionId, tenantId: opts?.tenantId });
}
export function diagnosisUrl(sessionId: string): Route {
return withQuery("/diagnosis", { id: sessionId });
}
@@ -80,7 +80,7 @@ export function readTimeProvenance(data: Record<string, unknown> | undefined | n
*/
export const BACKWARD_JUMP_THRESHOLD_MS = 5 * 60 * 1000;
export type TimeJumpCause = "era-offset" | "derived-timestamp" | "rejected-source" | null;
export type TimeJumpCause = "clock-set" | "era-offset" | "derived-timestamp" | "rejected-source" | null;
export interface TimeJump {
/** Positive magnitude of the backwards step in milliseconds. */
@@ -93,24 +93,55 @@ export interface TimeJumpInput {
provenance: TimeProvenance | null;
}
/**
* Reads the signed step of a `system_clock_changed` event's payload — the OS-level ground
* truth for "the clock was set from X to Y" (Kernel-General EventID 1, forwarded by the
* agent's system timeline watcher). Returns null for any other payload.
*/
export function readClockChangeDeltaMs(data: Record<string, unknown> | undefined | null): number | null {
if (!data || typeof data !== "object") return null;
return readNumber(data, "timeDeltaMs", "time_delta_ms");
}
/**
* Matches an observed backwards display step against the session's recorded clock sets.
* A backwards step of J ms is explained by a clock set of −D ms when the magnitudes agree
* within max(60 s, 20 % of the jump) — the display step includes whatever real time
* elapsed between the two rows, so exact equality never holds. Returns the matching
* (negative) clock delta, or null.
*/
export function findExplainingClockChange(backwardJumpMs: number, clockDeltasMs: readonly number[]): number | null {
for (const delta of clockDeltasMs) {
if (delta >= 0) continue; // only a backward set explains a backward display step
const tolerance = Math.max(60_000, backwardJumpMs * 0.2);
if (Math.abs(-delta - backwardJumpMs) <= tolerance) return delta;
}
return null;
}
/**
* Detects a backwards step of the displayed time versus the previous RENDERED row and
* names its cause when the provenance identifies one. Cause precedence: a clock-fallback
* (derived) timestamp explains the row by itself; a rejected source timestamp likewise;
* only then is an offset difference between the two rows read as an era-mixed log.
* names its cause when the provenance identifies one. Cause precedence: a recorded OS
* clock set (ground truth, when the session's `system_clock_changed` deltas are passed)
* beats every inference; then a clock-fallback (derived) timestamp explains the row by
* itself; a rejected source timestamp likewise; only then is an offset difference between
* the two rows read as an era-mixed log.
* Callers pass prev = null at phase-section starts (jump detection never spans sections).
*/
export function classifyTimeJump(
prev: TimeJumpInput | null,
curr: TimeJumpInput,
thresholdMs: number = BACKWARD_JUMP_THRESHOLD_MS,
clockDeltasMs?: readonly number[],
): TimeJump | null {
if (!prev) return null;
const deltaMs = prev.displayTime.getTime() - curr.displayTime.getTime();
if (!Number.isFinite(deltaMs) || deltaMs < thresholdMs) return null;
let cause: TimeJumpCause = null;
if (curr.provenance?.derivedTimestamp) {
if (clockDeltasMs && findExplainingClockChange(deltaMs, clockDeltasMs) !== null) {
cause = "clock-set";
} else if (curr.provenance?.derivedTimestamp) {
cause = "derived-timestamp";
} else if (curr.provenance?.rejectedSourceTimestamp) {
cause = "rejected-source";
@@ -26,6 +26,7 @@
{ "route": "/sessions/index.txt", "rewrite": "/sessions/index.txt" },
{ "route": "/sessions/inspector/index.txt", "rewrite": "/sessions/inspector/index.txt" },
{ "route": "/sessions/network-timeline/index.txt", "rewrite": "/sessions/network-timeline/index.txt" },
{ "route": "/diagnosis/index.txt", "rewrite": "/diagnosis/index.txt" },
{ "route": "/apps/index.txt", "rewrite": "/apps/index.txt" },
{ "route": "/apps/detail/index.txt", "rewrite": "/apps/detail/index.txt" },
@@ -36,6 +37,7 @@
{ "route": "/sessions", "rewrite": "/sessions/index.html" },
{ "route": "/sessions/inspector", "rewrite": "/sessions/inspector/index.html" },
{ "route": "/sessions/network-timeline", "rewrite": "/sessions/network-timeline/index.html" },
{ "route": "/sessions/*", "rewrite": "/sessions/index.html" },
{ "route": "/diagnosis", "rewrite": "/diagnosis/index.html" },
{ "route": "/diagnosis/*", "rewrite": "/diagnosis/index.html" },
@@ -105,6 +105,7 @@ export const SHARED_MANIFEST = {
"enableGeoLocation",
"ntpServer",
"enableTimezoneAutoSet",
"enableDoGroupIdAutoSet",
"enableImeMatchLog",
"enableGatherRuleDebugLog",
"enableEspContinueAnywayObservation",
@@ -517,6 +518,7 @@ export const SHARED_MANIFEST = {
"diagnostics_uploaded",
"disk_space_low",
"dns_configuration",
"do_group_id_auto_set",
"do_telemetry",
"download_progress",
"enrollment_complete",
@@ -633,7 +635,9 @@ export const SHARED_MANIFEST = {
"stall_probe_check",
"stall_probe_result",
"state_quarantine_recovered",
"system_clock_changed",
"system_reboot_detected",
"system_sleep_episode",
"telemetry_upload_blocked",
"telemetry_upload_poisoned",
"timezone_auto_set",
@@ -101,6 +101,7 @@
"enableGeoLocation",
"ntpServer",
"enableTimezoneAutoSet",
"enableDoGroupIdAutoSet",
"enableImeMatchLog",
"enableGatherRuleDebugLog",
"enableEspContinueAnywayObservation",
@@ -513,6 +514,7 @@
"diagnostics_uploaded",
"disk_space_low",
"dns_configuration",
"do_group_id_auto_set",
"do_telemetry",
"download_progress",
"enrollment_complete",
@@ -629,7 +631,9 @@
"stall_probe_check",
"stall_probe_result",
"state_quarantine_recovered",
"system_clock_changed",
"system_reboot_detected",
"system_sleep_episode",
"telemetry_upload_blocked",
"telemetry_upload_poisoned",
"timezone_auto_set",