Compare commits

...

3 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
37 changed files with 1322 additions and 86 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`
+1
View File
@@ -9,6 +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.
+1
View File
@@ -32,6 +32,7 @@ https://docs.autopilotmonitor.com (separate repository).
* [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
+2
View File
@@ -2,6 +2,8 @@
## 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).
@@ -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,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));
}
}
}
@@ -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,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);
}
}
}
}
@@ -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,
@@ -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()
{
@@ -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 = 39, // Collectors.EnableSystemTimelineWatcher + SystemEventBackfillLookbackMinutes (clock changes + sleep episodes)
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,
@@ -108,6 +108,7 @@ 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',
@@ -560,6 +560,7 @@ 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";
@@ -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
@@ -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.
@@ -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} />
@@ -42,8 +42,10 @@ export default function NetworkBand({ model }: { model: NetworkModel }) {
const { segments, colors, scale, phases, checks, lifeMarkers } = model;
// Headroom above the phase lane for the slanted lifecycle-marker labels
// (Reboot / Desktop / Completed …) written directly at their dots.
const MARKER_LABEL_H = 32;
// (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;
@@ -208,6 +210,13 @@ export default function NetworkBand({ model }: { model: NetworkModel }) {
</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}`}
@@ -270,13 +279,15 @@ export default function NetworkBand({ model }: { model: NetworkModel }) {
{lifeMarkers.map((m, i) => {
const x = markerXs[i];
const showLabel = markerLabelVisible[i];
const nearRightEdge = x > W - 90;
const lx = nearRightEdge ? x - 7 : x + 7;
// 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 (
@@ -303,7 +314,7 @@ export default function NetworkBand({ model }: { model: NetworkModel }) {
<text
x={lx}
y={ly}
textAnchor={nearRightEdge ? 'end' : 'start'}
textAnchor="start"
fontSize="10"
transform={`rotate(-30 ${lx} ${ly})`}
className="fill-gray-500"
@@ -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);
});
});
@@ -6,11 +6,14 @@
// piecewise time→x scale and classifies probable smartphone hotspots from
// well-known vendor subnets.
//
// Planned enrichment once the agent emits the corresponding events:
// - clock-change events → split the axis into clock eras and rebase each era
// by the cumulative delta (true elapsed-time axis, change point marked)
// - standby/Modern-Standby events → render sleep periods as their own
// segment kind instead of attributing them to the active network
// 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';
@@ -18,7 +21,7 @@ import type { EnrollmentEvent, Session } from '@/types';
// ── Types ────────────────────────────────────────────────────────────────────
export type SegmentKind = 'ethernet' | 'wifi' | 'offline' | 'unknown';
export type SegmentKind = 'ethernet' | 'wifi' | 'offline' | 'asleep' | 'unknown';
export interface HotspotHint {
vendor: 'Apple' | 'Windows' | 'Android';
@@ -44,6 +47,10 @@ export interface NetSegment {
signalPercent?: number;
radioType?: string;
hotspot?: HotspotHint;
/** 'asleep' segments only: sleep/hibernate/modern_standby + wake details. */
sleepKind?: string;
wakeSourceText?: string;
onAcPower?: boolean;
identity: string;
}
@@ -64,6 +71,8 @@ export interface CheckMarker {
export interface LifeMarker {
t: number;
label: string;
/** Optional extra tooltip line (clock markers: old→new time, reason). */
detail?: string;
}
export interface NetworkModel {
@@ -77,6 +86,8 @@ export interface NetworkModel {
lifeMarkers: LifeMarker[];
networkEvents: EnrollmentEvent[];
offlineMs: number;
asleepMs: number;
clockChangeCount: number;
distinctNetworks: Set<string>;
switchCount: number;
hotspotDetected: boolean;
@@ -90,6 +101,7 @@ 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([
@@ -102,6 +114,8 @@ export const NETWORK_EVENT_TYPES = new Set([
'dns_configuration',
'proxy_configuration',
'outbound_ip',
'system_clock_changed',
'system_sleep_episode',
]);
// ── Formatting helpers ───────────────────────────────────────────────────────
@@ -121,6 +135,7 @@ export function fmtDuration(ms: number): string {
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';
@@ -269,6 +284,7 @@ export function buildColorMap(segs: NetSegment[]): Map<string, string> {
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;
@@ -280,47 +296,187 @@ export function buildColorMap(segs: NetSegment[]): Map<string, string> {
// 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) * W, MIN_SEG_W));
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) * W);
px = px.map((w) => (w / sum) * CONTENT_SPAN);
const tB: number[] = [segs[0]?.start ?? 0];
const xB: number[] = [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 0;
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 W;
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, events: EnrollmentEvent[]): NetworkModel | null {
if (events.length === 0) return null;
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);
const t1 = Math.max(session.completedAt ? Date.parse(session.completedAt) : 0, ...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 segments = buildSegments(events, t0, t1);
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);
@@ -371,9 +527,31 @@ export function buildNetworkModel(session: Session, events: EnrollmentEvent[]):
}))
.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),
);
@@ -391,6 +569,8 @@ export function buildNetworkModel(session: Session, events: EnrollmentEvent[]):
lifeMarkers,
networkEvents,
offlineMs,
asleepMs,
clockChangeCount: clockChanges.length,
distinctNetworks,
switchCount,
hotspotDetected,
@@ -166,6 +166,8 @@ function StatsRow({ model }: { model: NetworkModel }) {
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({
@@ -205,12 +207,12 @@ const CHANGE_TYPE_BADGES: Record<string, string> = {
function EventList({ events }: { events: EnrollmentEvent[] }) {
if (events.length === 0) {
return <div className="text-gray-500 py-6">No network events in this session.</div>;
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 events ({events.length})
Network &amp; system events ({events.length})
</div>
<ul className="divide-y divide-gray-100">
{events.map((e) => {
@@ -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",
@@ -72,6 +72,7 @@ export interface TenantConfiguration {
contactEmail?: string;
enableGeoLocation?: boolean;
enableTimezoneAutoSet?: boolean;
enableDoGroupIdAutoSet?: boolean;
enableImeMatchLog?: boolean;
enableGatherRuleDebugLog?: boolean;
logLevel?: string;
@@ -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",
@@ -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",