mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 977c5fb447 | |||
| 27fdd19568 | |||
| 72f7adcb3d |
@@ -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`
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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).
|
||||
|
||||
+1
@@ -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),
|
||||
|
||||
+2
@@ -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);
|
||||
|
||||
+126
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+108
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-30
@@ -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"/>,
|
||||
|
||||
+2
-30
@@ -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 >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 <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:<guid>" 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:<guid>", a skip as
|
||||
/// "skipped:<reason>"; 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()
|
||||
{
|
||||
|
||||
+2
@@ -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"
|
||||
|
||||
+185
@@ -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);
|
||||
});
|
||||
});
|
||||
+195
-15
@@ -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 & 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's IP and MAC address, so devices on the same local network peer with each other. Takes effect with DO Download Mode "Group (2)"; 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",
|
||||
|
||||
Reference in New Issue
Block a user