mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ec481b479 | |||
| 3ec716a3ec | |||
| b0bb7233e4 | |||
| 6758dc59e4 | |||
| ffd70e0383 | |||
| c18154d76d | |||
| f524473305 | |||
| 8fe420b902 | |||
| 2e8c2cdb2f | |||
| e04b75dd53 | |||
| d55b0ff864 | |||
| 123ba0e55e | |||
| c8175ff7b1 | |||
| 0efeb0f734 | |||
| 7b975d46b6 | |||
| 7680bdff7b | |||
| 93094653a2 | |||
| ba7d14b070 | |||
| 7f7330e9e5 | |||
| 82934a3719 |
+1
-1
Submodule .claude/commands updated: af1b8468c7...997df4b5ed
@@ -41,6 +41,10 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
|
||||
* [MCP Docs Corpus](mcp/docs-corpus.md) - How the published customer documentation becomes the `search_docs` corpus: CRLF-safe heading chunking, build-time embedding as a third precomputed section, and why a docs edit needs an MCP redeploy.
|
||||
|
||||
# Web Portal
|
||||
|
||||
* [Portal Navigation Prefetch & the SWA Runtime Bottleneck](web/portal-navigation-prefetch.md) - Why every navigation Link sets `prefetch={false}`: the App Router's viewport prefetch fans out one RSC request per visible link against an HTTP/1.1 Static Web Apps runtime, and its latency tail starves the six connections that also serve the document and the route chunks. Includes where browser telemetry actually lives.
|
||||
|
||||
# Security & Identity
|
||||
|
||||
* [MCP OAuth Flow](mcp-oauth-flow.md) - Who authenticates where when connecting an AI client to the MCP server; two identities, three parties plus Entra ID.
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
## 2026-07-27
|
||||
|
||||
* **Creation**: Added `web/portal-navigation-prefetch.md` (and a `Web Portal` section to the index) — root cause of the minute-long portal freezes. `GlobalSidebar` and `Navbar` render the whole navigation on every page, so the App Router's default viewport prefetch issued one `?_rsc=` request per visible link against `portal.autopilotmonitor.com` (~10,400 in 14 days). That origin serves the document, the route chunks and the RSC payloads over **HTTP/1.1**, so the six-connection-per-origin cap applies; when the Static Web Apps managed runtime queued the prefetches (p50 91 ms, p95 896 ms, but max 234 s / 21 samples over 30 s), the in-flight prefetches held every socket and the navigation the user wanted could not get one. Measured on the 2026-07-27 17:25 CEST incident: `/admin/presence?_rsc=` 151 s, three siblings finishing within 340 ms of each other at ~37.5 s, all `200`, while the backend API answered in 1.6–2.2 s and `browserTimings` showed `networkDuration` 16 ms against `processingDuration` 225 s. Every navigation Link now sets `prefetch={false}` — these routes are client components behind `ProtectedRoute` that fetch their own data, so the prefetched RSC payload carried no benefit. The doc also records that browser telemetry lives in a **separate** App Insights resource (`AUTOPILOT_MONITOR_WEB_APPINSIGHTS_ID`) that the MCP `query_backend_logs` tool cannot reach, which is why the freezes left no trace in the backend logs.
|
||||
|
||||
* **Creation**: Added `backend/rule-regression-radar.md` — F3 regression radar (analyze rules only; gather rules wait for their per-batch stats dedup fix): per (tenant, rule) daily hit rate from `RuleStats`, firing only when the trailing 7-day window has ≥5 hit sessions and ≥20 evaluated sessions, the rate is ≥2× the prior 28-day baseline AND the Wilson 95 % intervals are disjoint (deterministic `MetricsMath` primitive with hand-computed test vectors). Suppressed for deleted rules, rules younger than baseline+window (`CreatedAt` grace), rules edited inside the window (`UpdatedAt`), empty baselines, and the `RuleRegressionRadarDisabled` app-setting kill switch. Alert episodes live as `ruleregression|{ruleId}` tracker rows (one bell + one `RuleFrequencyRegression` ops event per episode; numbers refreshed while active; re-armed under 1.5× baseline, on fire-stop, or by the 30-day tracker retention). On-fire dimension correlation over osBuild/model/agentVersion/imeVersion with the "correlated, not necessarily causal" wording contract. Surfaces: `regressions[]` in the rule-stats response (MCP inherits), the analyze-rules page's 30-day sparkline and regression badge.
|
||||
|
||||
* **Creation**: Added `backend/device-journeys.md` — F2 device history / First-Time-Right: the device key (trim + case-fold serial, junk/placeholder exclusion incl. the agent's `Unknown` WMI sentinel), journey grouping over the terminal set Succeeded/Failed/Incomplete (first success ends a journey, a >30-day gap from the previous attempt's CompletedAt starts one, WhiteGlove's single session row is ONE attempt, open sessions are never attempts), the `DeviceHistories` chain (cap 20, written inline at the terminal seam, healed by the sweep, deleted-session refs pruned tombstone-driven, deliberately not in the per-session deletion manifest) and the additive `DeviceJourneyAggregates` daily rows (window rate = ratio of sums — no rolling row needed, 180-day retention). Surfaces: the session-detail "Attempt N" banner with server-computed attempt numbers (virtual-attempt rule for live sessions), the Fleet Health First-time-right section (≥20-journey gate, attempt histogram, repeat-devices violator list), and the MCP `get_device_history` tool (serial mode / fleet FTR mode).
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Portal Navigation Prefetch & the SWA Runtime Bottleneck
|
||||
description: Why every navigation Link in the portal sets prefetch={false} — the App Router's viewport prefetch fires one RSC request per visible link against an HTTP/1.1 Static Web Apps runtime, and its latency tail starves the connection pool that serves the document and the route chunks.
|
||||
resource: src/Web/autopilot-monitor-web/components/GlobalSidebar.tsx
|
||||
tags:
|
||||
- web
|
||||
- performance
|
||||
- nextjs
|
||||
- static-web-apps
|
||||
- observability
|
||||
timestamp: 2026-07-27T18:00:00+02:00
|
||||
---
|
||||
|
||||
# Context
|
||||
|
||||
The portal is a Next.js 15 App Router application hosted on Azure Static Web Apps. Every page under
|
||||
the authenticated shell is a client component behind `ProtectedRoute` that fetches its own data from
|
||||
the Function App (`autopilotmonitor-api-eu`) after mount. The RSC payload a route returns on
|
||||
navigation therefore carries no page data — only the shell.
|
||||
|
||||
`GlobalSidebar` and `Navbar` render the full navigation on every page. With the App Router's default
|
||||
prefetch behaviour, each visible `<Link>` triggers a background `GET /<route>?_rsc=<hash>` as soon as
|
||||
it enters the viewport.
|
||||
|
||||
# Mechanism
|
||||
|
||||
Three properties combine into a portal-wide freeze:
|
||||
|
||||
1. **Fan-out.** The navigation is fully rendered on each page load, so a single navigation issues one
|
||||
RSC request per visible link — measured at ~10,400 requests over 14 days.
|
||||
2. **Shared origin over HTTP/1.1.** `portal.autopilotmonitor.com` serves the document, the JS route
|
||||
chunks *and* the RSC payloads, and it negotiates HTTP/1.1 — not HTTP/2. Browsers cap concurrent
|
||||
connections per origin at six.
|
||||
3. **A heavy latency tail on the SSR runtime.** RSC responses are prerendered and edge-cached
|
||||
(`x-nextjs-cache: HIT`, `s-maxage=31536000`), so the median is fast. On a cache miss or cold start
|
||||
the Static Web Apps managed runtime queues them.
|
||||
|
||||
When the tail hits, the in-flight prefetches hold all six connections. The document request and the
|
||||
route chunks of the page the user actually wants then have no socket available, and the tab shows a
|
||||
loading spinner or a blank page until the queue drains. Nothing fails — the requests eventually
|
||||
return `200`, which is why the failure leaves no error trace anywhere.
|
||||
|
||||
# Evidence
|
||||
|
||||
Incident of 2026-07-27, 17:25–17:29 CEST. Browser-side dependencies during the freeze:
|
||||
|
||||
| Request | Duration | Status |
|
||||
| --- | --- | --- |
|
||||
| `/admin/presence?_rsc=…` | 151,461 ms | 200 |
|
||||
| `/gather-rules?_rsc=…` | 52,283 ms | 200 |
|
||||
| `/progress?_rsc=…` | 37,878 ms | 200 |
|
||||
| `/analyze-rules?_rsc=…` | 37,559 ms | 200 |
|
||||
| `/audit?_rsc=…` | 37,539 ms | 200 |
|
||||
| Backend API, same window | 1,600–2,200 ms | 200 |
|
||||
|
||||
The three ~37.5 s responses complete within 340 ms of each other — a queue draining, not independent
|
||||
slow requests. The corresponding `browserTimings` row for `/geographic-performance` reports
|
||||
`networkDuration` = 16 ms against `processingDuration` = 225,095 ms: the network delivered the
|
||||
document immediately and the browser then waited nearly four minutes for the `load` event.
|
||||
|
||||
Fourteen-day distribution of RSC prefetches: n = 10,428, p50 = 91 ms, p95 = 896 ms, max = 234,230 ms,
|
||||
38 over 10 s, 21 over 30 s.
|
||||
|
||||
# Diagnosing this class of problem
|
||||
|
||||
The backend Application Insights resource is the wrong place to look — a freeze appears there as an
|
||||
*absence* of requests, never as an error. Browser telemetry lives in a **separate** Application
|
||||
Insights resource addressed by `AUTOPILOT_MONITOR_WEB_APPINSIGHTS_ID`, which the MCP
|
||||
`query_backend_logs` tool does not reach. Query it through
|
||||
`.claude/commands/scripts/query-appinsights.sh` with that ID exported, and read `dependencies`
|
||||
(client-side fetches, including RSC prefetches) and `browserTimings` (the network/processing split of
|
||||
a document load).
|
||||
|
||||
# Contract
|
||||
|
||||
Navigation `<Link>` elements in `GlobalSidebar` and `Navbar` set `prefetch={false}`. The prefetched
|
||||
RSC payload buys nothing for these routes, so the only remaining cost of a click is the route's JS
|
||||
chunk, which the browser caches after first use.
|
||||
|
||||
Do not remove the prop to "speed up navigation" without first re-measuring the tail in the web
|
||||
Application Insights resource — the prefetch is cheap only while the runtime is warm.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Web/autopilot-monitor-web/components/GlobalSidebar.tsx` — nav link rendering, rationale comment
|
||||
* `src/Web/autopilot-monitor-web/components/Navbar.tsx` — top-bar and notification links
|
||||
* [SWA and Oryx node pinning](../architecture.md) — hosting model for the web app
|
||||
+79
@@ -1,5 +1,7 @@
|
||||
using System;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Termination;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
|
||||
@@ -70,5 +72,82 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
|
||||
Assert.Equal(0, bootToStart, 0);
|
||||
Assert.Equal(10, uptime, 0);
|
||||
}
|
||||
|
||||
// --- DescribeLateStart: outcome-calibrated severity + phrasing (fleet 2026-07-28) ---
|
||||
|
||||
private static void Describe(
|
||||
EnrollmentTerminationOutcome outcome, string oobeState,
|
||||
out EventSeverity severity, out string message, out string note)
|
||||
=> ObservationCoverage.DescribeLateStart(
|
||||
outcome, oobeState, bootToStartSeconds: 34 * 60, uptimeSeconds: 30,
|
||||
out severity, out message, out note);
|
||||
|
||||
[Fact]
|
||||
public void Failed_outcome_is_a_warning_with_the_hung_script_post_mortem_note()
|
||||
{
|
||||
Describe(EnrollmentTerminationOutcome.Failed, "in_progress",
|
||||
out var severity, out var message, out var note);
|
||||
|
||||
Assert.Equal(EventSeverity.Warning, severity);
|
||||
Assert.Contains("low coverage of the enrollment window", message);
|
||||
Assert.Contains("post-mortem", note);
|
||||
Assert.Contains("hung ahead of the bootstrap", note);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TimedOut_outcome_is_a_warning_like_a_failure()
|
||||
{
|
||||
Describe(EnrollmentTerminationOutcome.TimedOut, "in_progress",
|
||||
out var severity, out _, out var note);
|
||||
|
||||
Assert.Equal(EventSeverity.Warning, severity);
|
||||
Assert.Contains("post-mortem", note);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Succeeded_outcome_is_an_info_about_tail_only_coverage_without_alarm_framing()
|
||||
{
|
||||
// The 659c3a90 shape: healthy enrollment, bootstrap merely ran late in the ESP queue.
|
||||
Describe(EnrollmentTerminationOutcome.Succeeded, "in_progress",
|
||||
out var severity, out _, out var note);
|
||||
|
||||
Assert.Equal(EventSeverity.Info, severity);
|
||||
Assert.Contains("succeeded", note);
|
||||
Assert.Contains("IME log replay", note);
|
||||
Assert.DoesNotContain("post-mortem", note);
|
||||
Assert.DoesNotContain("hung", note);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Oobe_completed_at_start_phrases_a_pure_post_mortem()
|
||||
{
|
||||
Describe(EnrollmentTerminationOutcome.Failed, "completed",
|
||||
out _, out var message, out _);
|
||||
|
||||
Assert.Contains("OOBE was already completed when the agent started", message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Oobe_in_progress_at_start_phrases_the_live_tail()
|
||||
{
|
||||
Describe(EnrollmentTerminationOutcome.Failed, "in_progress",
|
||||
out _, out var message, out _);
|
||||
|
||||
Assert.Contains("OOBE was still in progress at agent start", message);
|
||||
Assert.Contains("final 30s live", message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("not_started")] // flips in after a mid-OOBE reboot — not interpretable
|
||||
[InlineData("unavailable")] // WinRT contract absent / read failed
|
||||
[InlineData("unknown_3")] // unexpected enum value
|
||||
public void Uninterpretable_oobe_states_add_no_claim_to_the_message(string oobeState)
|
||||
{
|
||||
Describe(EnrollmentTerminationOutcome.Failed, oobeState,
|
||||
out _, out var message, out _);
|
||||
|
||||
Assert.DoesNotContain("OOBE", message);
|
||||
Assert.EndsWith("low coverage of the enrollment window.", message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+9
-5
@@ -77,11 +77,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
|
||||
protected override void Collect()
|
||||
{
|
||||
// Full path writes 18 keys: agent_version + process metrics (5: cpu, ws, private,
|
||||
// threads, handles) + spool stats (4) + network delta (8: requests, failures,
|
||||
// bytes_up/down, avg_latency, total_up/down/requests). cap=18 → HashHelpers.GetPrime(18)=23
|
||||
// buckets → no resize on the 18th key (cap=16 would land at 17 buckets and resize).
|
||||
var data = new Dictionary<string, object>(capacity: 18, StringComparer.Ordinal)
|
||||
// Full path writes 19 keys: agent_version + process metrics (5: cpu, ws, private,
|
||||
// threads, handles) + spool stats (4) + network delta (9: requests, failures,
|
||||
// bytes_up/down, avg_latency, total_up/down/requests/latency). cap=19 →
|
||||
// HashHelpers.GetPrime(19)=23 buckets → no resize on the 19th key.
|
||||
var data = new Dictionary<string, object>(capacity: 19, StringComparer.Ordinal)
|
||||
{
|
||||
{ "agent_version", _agentVersion }
|
||||
};
|
||||
@@ -164,6 +164,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
data["net_total_bytes_up"] = currentNet.TotalBytesUp;
|
||||
data["net_total_bytes_down"] = currentNet.TotalBytesDown;
|
||||
data["net_total_requests"] = currentNet.RequestCount;
|
||||
// Cumulative latency sum: total/requests = session-wide average HTTP
|
||||
// round-trip. The backend projects that average onto the Session row
|
||||
// (AvgApiLatencyMs) — cumulative counters make the write idempotent.
|
||||
data["net_total_latency_ms"] = currentNet.TotalLatencyMs;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
+14
-8
@@ -623,12 +623,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits a one-shot <c>agent_late_start</c> Warning when the agent had low observation
|
||||
/// Emits a one-shot <c>agent_late_start</c> event when the agent had low observation
|
||||
/// coverage: it started a long time after device boot AND lived only briefly before this
|
||||
/// terminal outcome (gate in <see cref="ObservationCoverage"/>). Such a session looks like a
|
||||
/// normal multi-minute failure in the list (StartedAt is back-dated to the earliest replayed
|
||||
/// IME-log event) but the agent actually observed only the end-state — so the operator should
|
||||
/// read the diagnosis as a post-mortem reconstruction, not live evidence. No state mutation.
|
||||
/// IME-log event) but the agent actually observed only the end-state. Severity and phrasing
|
||||
/// are outcome-calibrated via <see cref="ObservationCoverage.DescribeLateStart"/>: a failure
|
||||
/// stays a Warning with the post-mortem/hung-script note, a success is an Info about
|
||||
/// tail-only coverage (fleet 2026-07-28: tenant 659c3a90 collected 12 Warnings on healthy
|
||||
/// enrollments whose bootstrap merely ran late). No state mutation.
|
||||
/// </summary>
|
||||
private void MaybeEmitAgentLateStart(EnrollmentTerminatedEventArgs args)
|
||||
{
|
||||
@@ -642,21 +645,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
out var bootToStartSeconds, out var uptimeSeconds))
|
||||
return;
|
||||
|
||||
ObservationCoverage.DescribeLateStart(
|
||||
args.Outcome, _oobeStateAtAgentStart, bootToStartSeconds, uptimeSeconds,
|
||||
out var severity, out var message, out var note);
|
||||
|
||||
_logger.Info(
|
||||
$"EnrollmentTerminationHandler: low observation coverage — agent started " +
|
||||
$"{bootToStartSeconds / 60.0:F1} min after boot and lived {uptimeSeconds:F0}s; " +
|
||||
$"emitting agent_late_start.");
|
||||
$"emitting agent_late_start ({severity}, outcome {args.Outcome}).");
|
||||
|
||||
_post.Emit(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _configuration.SessionId,
|
||||
TenantId = _configuration.TenantId,
|
||||
EventType = Constants.EventTypes.AgentLateStart,
|
||||
Severity = EventSeverity.Warning,
|
||||
Severity = severity,
|
||||
Source = "EnrollmentTerminationHandler",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"Agent started {bootToStartSeconds / 60.0:F0} min after boot and observed only " +
|
||||
$"{uptimeSeconds:F0}s before terminating ({args.Outcome}) — low coverage of the enrollment window.",
|
||||
Message = message,
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
{ "bootToAgentStartSeconds", Math.Round(bootToStartSeconds, 1) },
|
||||
@@ -665,7 +671,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
{ "agentStartUtc", _agentStartTimeUtc.ToString("o") },
|
||||
{ "oobeStateAtAgentStart", _oobeStateAtAgentStart },
|
||||
{ "outcome", args.Outcome.ToString() },
|
||||
{ "note", "The agent's bootstrap (an Intune platform script) ran only near the end of the enrollment, so the agent observed the already-decided end-state rather than the failure window. Treat the diagnosis as a post-mortem; check for a platform/remediation script that hung ahead of the bootstrap (see script_timeout_suspected)." },
|
||||
{ "note", note },
|
||||
},
|
||||
ImmediateUpload = true,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
{
|
||||
@@ -76,5 +78,57 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
uptimeSeconds = Math.Max(0, uptime.TotalSeconds);
|
||||
return bootToStart >= LateStartBootThreshold && uptime <= LowCoverageUptime;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Severity + operator-facing phrasing for the <c>agent_late_start</c> event, calibrated
|
||||
/// by fleet data (2026-07-28): the gate alone is outcome-blind and attached a
|
||||
/// "post-mortem / check for a hung script" Warning to sessions that in fact succeeded —
|
||||
/// there the late bootstrap is deployment hygiene, not an alarm. The OOBE state refines
|
||||
/// the phrasing but must never gate: the canonical hung-script failure terminates on the
|
||||
/// still-shown ESP screen, i.e. with OOBE reading <c>in_progress</c>.
|
||||
/// </summary>
|
||||
/// <param name="outcome">Terminal outcome the enrollment reached.</param>
|
||||
/// <param name="oobeStateAtAgentStart"><see cref="Monitoring.Interop.OobeStateReader"/>
|
||||
/// sample taken at THIS process's start. Only <c>completed</c> / <c>in_progress</c> are
|
||||
/// interpreted; <c>not_started</c> flips in after mid-OOBE reboots and <c>completed</c> is
|
||||
/// routine for post-desktop restarts, so no claim is phrased from the other values.</param>
|
||||
/// <param name="bootToStartSeconds">From <see cref="IsLowObservationCoverage"/>.</param>
|
||||
/// <param name="uptimeSeconds">From <see cref="IsLowObservationCoverage"/>.</param>
|
||||
internal static void DescribeLateStart(
|
||||
EnrollmentTerminationOutcome outcome,
|
||||
string oobeStateAtAgentStart,
|
||||
double bootToStartSeconds,
|
||||
double uptimeSeconds,
|
||||
out EventSeverity severity,
|
||||
out string message,
|
||||
out string note)
|
||||
{
|
||||
var succeeded = outcome == EnrollmentTerminationOutcome.Succeeded;
|
||||
severity = succeeded ? EventSeverity.Info : EventSeverity.Warning;
|
||||
|
||||
message = $"Agent started {bootToStartSeconds / 60.0:F0} min after boot and observed only " +
|
||||
$"{uptimeSeconds:F0}s before terminating ({outcome}) — low coverage of the enrollment window.";
|
||||
switch (oobeStateAtAgentStart)
|
||||
{
|
||||
case "completed":
|
||||
message += " OOBE was already completed when the agent started — the diagnosis is a " +
|
||||
"post-mortem reconstruction, not live observation.";
|
||||
break;
|
||||
case "in_progress":
|
||||
message += $" OOBE was still in progress at agent start — the agent did observe the " +
|
||||
$"final {uptimeSeconds:F0}s live.";
|
||||
break;
|
||||
}
|
||||
|
||||
note = succeeded
|
||||
? "The agent's bootstrap (an Intune platform script) ran late in the enrollment, so only the " +
|
||||
"final stretch was observed live; earlier timeline entries are reconstructed from IME log " +
|
||||
"replay. The enrollment itself succeeded. If this recurs across sessions, review which " +
|
||||
"platform/remediation scripts run ahead of the bootstrap (see script_timeout_suspected)."
|
||||
: "The agent's bootstrap (an Intune platform script) ran only near the end of the enrollment, " +
|
||||
"so the agent observed the already-decided end-state rather than the failure window. Treat " +
|
||||
"the diagnosis as a post-mortem; check for a platform/remediation script that hung ahead of " +
|
||||
"the bootstrap (see script_timeout_suspected).";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,14 @@ public class AppsProjectionEquivalenceTests
|
||||
["AppType"] = "Win32",
|
||||
["AppVersion"] = "2.1.0",
|
||||
["Status"] = "Failed",
|
||||
// PR0/PR1 classification columns. The fixture MUST populate every column a Build*
|
||||
// method reads: a column absent here passes the equivalence assert trivially (both
|
||||
// sides map to default) — exactly how the missing TerminalState projection slipped
|
||||
// through and zeroed totalSkipped in production (2026-07-27).
|
||||
["TerminalState"] = "Skipped",
|
||||
["AppId"] = "9e8d7c6b-5a49-4832-9182-736451234567",
|
||||
["EspBlocking"] = true,
|
||||
["AppIdCollision"] = true,
|
||||
["StartedAt"] = new DateTimeOffset(DateTime.UtcNow.AddHours(-6)),
|
||||
["CompletedAt"] = new DateTimeOffset(DateTime.UtcNow.AddHours(-5)),
|
||||
["DurationSeconds"] = 340,
|
||||
@@ -83,6 +91,9 @@ public class AppsProjectionEquivalenceTests
|
||||
|
||||
Assert.Equal(fromFull.AppName, fromProjected.AppName);
|
||||
Assert.Equal(fromFull.Status, fromProjected.Status);
|
||||
// IsSkipTerminalState / skip classification input — see the fixture comment.
|
||||
Assert.Equal(fromFull.TerminalState, fromProjected.TerminalState);
|
||||
Assert.Equal(fromFull.AppIdCollision, fromProjected.AppIdCollision);
|
||||
Assert.Equal(fromFull.StartedAt, fromProjected.StartedAt);
|
||||
Assert.Equal(fromFull.DurationSeconds, fromProjected.DurationSeconds);
|
||||
Assert.Equal(fromFull.DownloadBytes, fromProjected.DownloadBytes);
|
||||
@@ -122,6 +133,10 @@ public class AppsProjectionEquivalenceTests
|
||||
Assert.Equal(fromFull.FailureMessage, fromProjected.FailureMessage);
|
||||
Assert.Equal(fromFull.ExitCode, fromProjected.ExitCode);
|
||||
Assert.Equal(fromFull.DetectionResult, fromProjected.DetectionResult);
|
||||
Assert.Equal(fromFull.TerminalState, fromProjected.TerminalState);
|
||||
Assert.Equal(fromFull.AppId, fromProjected.AppId);
|
||||
Assert.Equal(fromFull.EspBlocking, fromProjected.EspBlocking);
|
||||
Assert.Equal(fromFull.AppIdCollision, fromProjected.AppIdCollision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
using AutopilotMonitor.Functions.Functions.Infrastructure;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the pure helpers of <see cref="AzureMonitorAlertWebhookFunction"/> — the anonymous,
|
||||
/// secret-gated bridge that turns Azure Monitor Common Alert Schema notifications into ops
|
||||
/// events. Load-bearing bits: the secret gate is fail-closed (an unset app setting rejects
|
||||
/// everything), severity mapping can never escalate on schema drift (unknown → Info), and
|
||||
/// parsing is defensive (only the alert rule name is mandatory).
|
||||
/// </summary>
|
||||
public class AzureMonitorAlertWebhookFunctionTests
|
||||
{
|
||||
// ── SecretMatches ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void SecretMatches_accepts_equal_secrets()
|
||||
{
|
||||
Assert.True(AzureMonitorAlertWebhookFunction.SecretMatches("s3cret-value", "s3cret-value"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("wrong", "right")]
|
||||
[InlineData("right-but-longer", "right")]
|
||||
[InlineData("", "configured")]
|
||||
[InlineData(null, "configured")]
|
||||
public void SecretMatches_rejects_mismatch_or_missing_provided(string? provided, string configured)
|
||||
{
|
||||
Assert.False(AzureMonitorAlertWebhookFunction.SecretMatches(provided, configured));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
public void SecretMatches_fails_closed_when_not_configured(string? configured)
|
||||
{
|
||||
// While the app setting is unset, NOTHING matches — not even an empty provided value.
|
||||
Assert.False(AzureMonitorAlertWebhookFunction.SecretMatches("anything", configured));
|
||||
Assert.False(AzureMonitorAlertWebhookFunction.SecretMatches("", configured));
|
||||
Assert.False(AzureMonitorAlertWebhookFunction.SecretMatches(null, configured));
|
||||
}
|
||||
|
||||
// ── MapSeverity ────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("Sev0", OpsEventSeverity.Critical)]
|
||||
[InlineData("Sev1", OpsEventSeverity.Error)]
|
||||
[InlineData("Sev2", OpsEventSeverity.Warning)]
|
||||
[InlineData("Sev3", OpsEventSeverity.Info)]
|
||||
[InlineData("Sev4", OpsEventSeverity.Info)]
|
||||
public void MapSeverity_maps_azure_scale_to_ops_scale(string azure, string expected)
|
||||
{
|
||||
Assert.Equal(expected, AzureMonitorAlertWebhookFunction.MapSeverity(azure));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("Sev5")]
|
||||
[InlineData("Critical")] // someone posting the ops scale directly must not pass through
|
||||
public void MapSeverity_falls_back_to_info_on_unknown_values(string? azure)
|
||||
{
|
||||
Assert.Equal(OpsEventSeverity.Info, AzureMonitorAlertWebhookFunction.MapSeverity(azure));
|
||||
}
|
||||
|
||||
// ── TryParse ───────────────────────────────────────────────────────────
|
||||
|
||||
private const string FullPayload = """
|
||||
{
|
||||
"schemaId": "azureMonitorCommonAlertSchema",
|
||||
"data": {
|
||||
"essentials": {
|
||||
"alertId": "/subscriptions/xxx/providers/Microsoft.AlertsManagement/alerts/abc",
|
||||
"alertRule": "Portal RSC prefetch tail",
|
||||
"severity": "Sev2",
|
||||
"signalType": "Log",
|
||||
"monitorCondition": "Fired",
|
||||
"monitoringService": "Log Alerts V2",
|
||||
"alertTargetIDs": [
|
||||
"/subscriptions/xxx/resourcegroups/rg-autopilotmonitor-prd-gwc/providers/microsoft.insights/components/autopilotmonitorweb"
|
||||
],
|
||||
"description": "RSC prefetches exceeding 10s",
|
||||
"firedDateTime": "2026-07-27T15:30:00.000Z"
|
||||
},
|
||||
"alertContext": {
|
||||
"condition": {
|
||||
"windowSize": "PT1H",
|
||||
"allOf": [
|
||||
{
|
||||
"searchQuery": "dependencies | where name contains '_rsc='",
|
||||
"metricValue": 18.0,
|
||||
"operator": "GreaterThan",
|
||||
"threshold": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
public void TryParse_extracts_essentials_and_metric_value()
|
||||
{
|
||||
var alert = AzureMonitorAlertWebhookFunction.TryParse(FullPayload);
|
||||
|
||||
Assert.NotNull(alert);
|
||||
Assert.Equal("Portal RSC prefetch tail", alert!.AlertRule);
|
||||
Assert.Equal("Sev2", alert.Severity);
|
||||
Assert.Equal("Fired", alert.MonitorCondition);
|
||||
Assert.Equal("RSC prefetches exceeding 10s", alert.Description);
|
||||
Assert.Equal("Log Alerts V2", alert.MonitoringService);
|
||||
// No targetResourceName in the payload → resource name segment of the first target ID.
|
||||
Assert.Equal("autopilotmonitorweb", alert.TargetResource);
|
||||
Assert.Equal(18.0, alert.MetricValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_prefers_explicit_target_resource_name()
|
||||
{
|
||||
const string payload = """
|
||||
{"data":{"essentials":{"alertRule":"r","targetResourceName":"explicit-name",
|
||||
"alertTargetIDs":["/sub/x/other-name"]}}}
|
||||
""";
|
||||
var alert = AzureMonitorAlertWebhookFunction.TryParse(payload);
|
||||
Assert.Equal("explicit-name", alert!.TargetResource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_minimal_payload_needs_only_the_alert_rule()
|
||||
{
|
||||
var alert = AzureMonitorAlertWebhookFunction.TryParse(
|
||||
"""{"data":{"essentials":{"alertRule":"minimal"}}}""");
|
||||
|
||||
Assert.NotNull(alert);
|
||||
Assert.Equal("minimal", alert!.AlertRule);
|
||||
Assert.Null(alert.Severity);
|
||||
Assert.Equal("Fired", alert.MonitorCondition); // default when absent
|
||||
Assert.Null(alert.Description);
|
||||
Assert.Null(alert.MetricValue);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("not json at all")]
|
||||
[InlineData("{}")] // no data
|
||||
[InlineData("""{"data":{}}""")] // no essentials
|
||||
[InlineData("""{"data":{"essentials":{}}}""")] // no alertRule
|
||||
[InlineData("""{"data":{"essentials":{"alertRule":""}}}""")] // empty alertRule
|
||||
[InlineData("""{"data":{"essentials":{"alertRule":123}}}""")] // wrong type
|
||||
public void TryParse_rejects_unusable_payloads(string? body)
|
||||
{
|
||||
Assert.Null(AzureMonitorAlertWebhookFunction.TryParse(body!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_tolerates_malformed_alert_context()
|
||||
{
|
||||
// alertContext with unexpected shapes must not break essentials extraction.
|
||||
const string payload = """
|
||||
{"data":{"essentials":{"alertRule":"r","severity":"Sev0"},
|
||||
"alertContext":{"condition":{"allOf":"not-an-array"}}}}
|
||||
""";
|
||||
var alert = AzureMonitorAlertWebhookFunction.TryParse(payload);
|
||||
Assert.NotNull(alert);
|
||||
Assert.Equal("Sev0", alert!.Severity);
|
||||
Assert.Null(alert.MetricValue);
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,8 @@ public class GeoMetricsProjectionEquivalenceTests
|
||||
["GeoCity"] = "Frankfurt",
|
||||
["GeoLoc"] = "50.11,8.68",
|
||||
["IsPreProvisioned"] = false,
|
||||
["AvgApiLatencyMs"] = 217.4,
|
||||
["ApiRequestCount"] = 270,
|
||||
// Noise the geo aggregation never reads.
|
||||
["SerialNumber"] = "SN-FULL",
|
||||
["Manufacturer"] = "Contoso",
|
||||
@@ -76,6 +78,8 @@ public class GeoMetricsProjectionEquivalenceTests
|
||||
Assert.Equal(fromFull.GeoRegion, fromProjected.GeoRegion);
|
||||
Assert.Equal(fromFull.GeoCity, fromProjected.GeoCity);
|
||||
Assert.Equal(fromFull.GeoLoc, fromProjected.GeoLoc);
|
||||
Assert.Equal(fromFull.AvgApiLatencyMs, fromProjected.AvgApiLatencyMs);
|
||||
Assert.Equal(fromFull.ApiRequestCount, fromProjected.ApiRequestCount);
|
||||
|
||||
// The grouping key must be identical too — it is composed from the Geo* fields.
|
||||
Assert.Equal(
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Functions.Functions.Metrics;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the API-latency aggregation of
|
||||
/// <see cref="GetGeographicMetricsFunction.ComputeGeographicMetrics"/> (2026-07-28): both the
|
||||
/// per-location and the global figure are REQUEST-WEIGHTED averages —
|
||||
/// Σ(sessionAvg·requests)/Σ(requests) — not averages of session averages, so a long session
|
||||
/// with many uploads counts proportionally. Sessions without latency data (agents predating
|
||||
/// <c>AvgApiLatencyMs</c>) drop out of the weighting entirely.
|
||||
/// </summary>
|
||||
public class GeographicMetricsApiLatencyTests
|
||||
{
|
||||
private static SessionSummary S(string country, string city, double? avgLatencyMs, int? requestCount)
|
||||
=> new()
|
||||
{
|
||||
TenantId = "00000000-0000-0000-0000-000000000fe0",
|
||||
SessionId = Guid.NewGuid().ToString(),
|
||||
Status = SessionStatus.Succeeded,
|
||||
StartedAt = DateTime.UtcNow.AddHours(-2),
|
||||
GeoCountry = country,
|
||||
GeoRegion = country,
|
||||
GeoCity = city,
|
||||
GeoLoc = "0,0",
|
||||
AvgApiLatencyMs = avgLatencyMs,
|
||||
ApiRequestCount = requestCount,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void LocationLatency_IsRequestWeighted_NotAverageOfAverages()
|
||||
{
|
||||
var sessions = new List<SessionSummary>
|
||||
{
|
||||
S("ID", "Jakarta", 1000, 300),
|
||||
S("ID", "Jakarta", 500, 100),
|
||||
};
|
||||
|
||||
var result = GetGeographicMetricsFunction.ComputeGeographicMetrics(
|
||||
sessions, new List<AppInstallSummary>(), "country");
|
||||
|
||||
var loc = Assert.Single(result.Locations);
|
||||
// (1000·300 + 500·100) / 400 = 875 — an unweighted mean would say 750.
|
||||
Assert.Equal(875d, loc.AvgApiLatencyMs);
|
||||
Assert.Equal(2, loc.ApiLatencySessionCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionsWithoutLatencyData_DropOutOfWeighting_ButStayInSessionCount()
|
||||
{
|
||||
var sessions = new List<SessionSummary>
|
||||
{
|
||||
S("DE", "Frankfurt", 200, 100),
|
||||
S("DE", "Frankfurt", avgLatencyMs: null, requestCount: null), // pre-feature agent
|
||||
S("DE", "Frankfurt", avgLatencyMs: 0, requestCount: 50), // zero = no signal
|
||||
};
|
||||
|
||||
var result = GetGeographicMetricsFunction.ComputeGeographicMetrics(
|
||||
sessions, new List<AppInstallSummary>(), "country");
|
||||
|
||||
var loc = Assert.Single(result.Locations);
|
||||
Assert.Equal(3, loc.SessionCount);
|
||||
Assert.Equal(1, loc.ApiLatencySessionCount);
|
||||
Assert.Equal(200d, loc.AvgApiLatencyMs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoLatencyDataAnywhere_YieldsZeroes_NotDivisionByZero()
|
||||
{
|
||||
var sessions = new List<SessionSummary> { S("DE", "Frankfurt", null, null) };
|
||||
|
||||
var result = GetGeographicMetricsFunction.ComputeGeographicMetrics(
|
||||
sessions, new List<AppInstallSummary>(), "country");
|
||||
|
||||
var loc = Assert.Single(result.Locations);
|
||||
Assert.Equal(0d, loc.AvgApiLatencyMs);
|
||||
Assert.Equal(0, loc.ApiLatencySessionCount);
|
||||
Assert.Equal(0d, loc.ApiLatencyVsGlobalPct);
|
||||
Assert.Equal(0d, result.GlobalAverages.AvgApiLatencyMs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GlobalLatency_WeightsAcrossLocations_AndVsGlobalPctCompares()
|
||||
{
|
||||
var sessions = new List<SessionSummary>
|
||||
{
|
||||
S("DE", "Frankfurt", 200, 100),
|
||||
S("ID", "Jakarta", 800, 100),
|
||||
};
|
||||
|
||||
var result = GetGeographicMetricsFunction.ComputeGeographicMetrics(
|
||||
sessions, new List<AppInstallSummary>(), "country");
|
||||
|
||||
// Equal request weights → global = (200 + 800) / 2 = 500.
|
||||
Assert.Equal(500d, result.GlobalAverages.AvgApiLatencyMs);
|
||||
|
||||
var de = result.Locations.Single(l => l.Country == "DE");
|
||||
var id = result.Locations.Single(l => l.Country == "ID");
|
||||
Assert.Equal(-60d, de.ApiLatencyVsGlobalPct); // 200 vs 500
|
||||
Assert.Equal(60d, id.ApiLatencyVsGlobalPct); // 800 vs 500
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LocationWithoutData_KeepsZeroPct_EvenWhenGlobalHasData()
|
||||
{
|
||||
var sessions = new List<SessionSummary>
|
||||
{
|
||||
S("DE", "Frankfurt", 200, 100),
|
||||
S("FR", "Paris", null, null),
|
||||
};
|
||||
|
||||
var result = GetGeographicMetricsFunction.ComputeGeographicMetrics(
|
||||
sessions, new List<AppInstallSummary>(), "country");
|
||||
|
||||
var fr = result.Locations.Single(l => l.Country == "FR");
|
||||
Assert.Equal(0d, fr.AvgApiLatencyMs);
|
||||
Assert.Equal(0d, fr.ApiLatencyVsGlobalPct); // no data ≠ "0 ms fast"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Functions.Metrics;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Guards the cross-tenant redaction on GET metrics/ime-versions.
|
||||
///
|
||||
/// The route is <c>MemberRead</c> (EndpointAccessPolicyCatalog) and the archive itself is a
|
||||
/// single global partition — every tenant member reads the SAME platform-wide rows. The only
|
||||
/// thing standing between an ordinary tenant user and another tenant's identifiers is the
|
||||
/// projection in <see cref="GetImeVersionHistoryFunction.BuildResponsePayload"/>. That made it
|
||||
/// worth pinning: the redaction had no test at all, and a boundary nobody asserts is one a
|
||||
/// later refactor can open without a single failure.
|
||||
///
|
||||
/// The JSON tests are the ones that matter — a property dropped from an anonymous type is only
|
||||
/// a real redaction once it is also absent from the wire, and the wire is what the customer's
|
||||
/// MCP client receives. camelCase mirrors Program.cs's PropertyNamingPolicy.
|
||||
/// </summary>
|
||||
public class GetImeVersionHistoryRedactionTests
|
||||
{
|
||||
private static readonly DateTime First = new(2026, 7, 8, 3, 18, 13, DateTimeKind.Utc);
|
||||
private static readonly DateTime Last = new(2026, 7, 27, 16, 59, 34, DateTimeKind.Utc);
|
||||
|
||||
private static readonly JsonSerializerOptions CamelCase =
|
||||
new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
||||
|
||||
// =========================================================================
|
||||
// Non-global caller (ordinary tenant member) — the redaction
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void NonGlobalCaller_DropsForeignTenantAndSessionIds()
|
||||
{
|
||||
var json = SerializeFor(hasGlobalScope: false, MakeEntry());
|
||||
|
||||
Assert.DoesNotContain("firstSeenTenantId", json, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("firstSeenSessionId", json, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The values, not just the property names: a rename of the model property must not let the
|
||||
/// identifier survive under a different key. Asserting on the GUIDs catches that.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void NonGlobalCaller_ForeignIdentifierValuesAppearNowhereInThePayload()
|
||||
{
|
||||
var json = SerializeFor(hasGlobalScope: false, MakeEntry());
|
||||
|
||||
Assert.DoesNotContain("57f34dd1-6a42-47f9-9bc0-b921fa6caa30", json, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("baae0453-6d19-47b5-aa58-8bed5a572fc8", json, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonGlobalCaller_ExposesExactlyTheFourAllowedProperties()
|
||||
{
|
||||
using var doc = JsonDocument.Parse(SerializeFor(hasGlobalScope: false, MakeEntry()));
|
||||
|
||||
var keys = doc.RootElement[0].EnumerateObject().Select(p => p.Name).OrderBy(n => n).ToArray();
|
||||
|
||||
Assert.Equal(new[] { "firstSeenAt", "lastSeenAt", "sessionCount", "version" }, keys);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SessionCount stays on purpose — it is a platform-wide total, and the route exists so any
|
||||
/// tenant member can track Microsoft's IME rollout. Pinned so that "the counts are global"
|
||||
/// stays a decision someone made rather than something that quietly drifts either way.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void NonGlobalCaller_KeepsVersionDatesAndGlobalSessionCount()
|
||||
{
|
||||
using var doc = JsonDocument.Parse(SerializeFor(hasGlobalScope: false, MakeEntry()));
|
||||
|
||||
var row = doc.RootElement[0];
|
||||
Assert.Equal("1.103.101.0", row.GetProperty("version").GetString());
|
||||
Assert.Equal(First, row.GetProperty("firstSeenAt").GetDateTime());
|
||||
Assert.Equal(Last, row.GetProperty("lastSeenAt").GetDateTime());
|
||||
Assert.Equal(3109, row.GetProperty("sessionCount").GetInt32());
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Global caller (Global Admin or Global Reader) — full archive
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void GlobalCaller_KeepsTenantAndSessionIds()
|
||||
{
|
||||
using var doc = JsonDocument.Parse(SerializeFor(hasGlobalScope: true, MakeEntry()));
|
||||
|
||||
var row = doc.RootElement[0];
|
||||
Assert.Equal("57f34dd1-6a42-47f9-9bc0-b921fa6caa30", row.GetProperty("firstSeenTenantId").GetString());
|
||||
Assert.Equal("baae0453-6d19-47b5-aa58-8bed5a572fc8", row.GetProperty("firstSeenSessionId").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GlobalCaller_ExposesTheFullEntry()
|
||||
{
|
||||
using var doc = JsonDocument.Parse(SerializeFor(hasGlobalScope: true, MakeEntry()));
|
||||
|
||||
var keys = doc.RootElement[0].EnumerateObject().Select(p => p.Name).OrderBy(n => n).ToArray();
|
||||
|
||||
Assert.Equal(
|
||||
new[] { "firstSeenAt", "firstSeenSessionId", "firstSeenTenantId", "lastSeenAt", "sessionCount", "version" },
|
||||
keys);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Shape-preserving properties of the projection
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void BothScopes_EmitAJsonArray_EvenForASingleRow()
|
||||
{
|
||||
foreach (var scope in new[] { true, false })
|
||||
{
|
||||
using var doc = JsonDocument.Parse(SerializeFor(scope, MakeEntry()));
|
||||
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
|
||||
Assert.Equal(1, doc.RootElement.GetArrayLength());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BothScopes_EmptyArchive_SerializesAsEmptyArray()
|
||||
{
|
||||
foreach (var scope in new[] { true, false })
|
||||
{
|
||||
using var doc = JsonDocument.Parse(SerializeFor(scope));
|
||||
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
|
||||
Assert.Equal(0, doc.RootElement.GetArrayLength());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The repository orders by FirstSeenAt descending; the projection must not reshuffle, or a
|
||||
/// redacted caller would see a different sequence than a Global Admin for the same archive.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void NonGlobalCaller_PreservesRowOrderAndCount()
|
||||
{
|
||||
var newest = MakeEntry("1.103.101.0", First);
|
||||
var older = MakeEntry("1.101.111.0", First.AddDays(-48));
|
||||
var oldest = MakeEntry("1.99.101.0", First.AddDays(-131));
|
||||
|
||||
using var doc = JsonDocument.Parse(SerializeFor(false, newest, older, oldest));
|
||||
|
||||
Assert.Equal(3, doc.RootElement.GetArrayLength());
|
||||
Assert.Equal("1.103.101.0", doc.RootElement[0].GetProperty("version").GetString());
|
||||
Assert.Equal("1.101.111.0", doc.RootElement[1].GetProperty("version").GetString());
|
||||
Assert.Equal("1.99.101.0", doc.RootElement[2].GetProperty("version").GetString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// BuildResponsePayload returns `object`; the branch that keeps everything hands back the
|
||||
/// entries themselves. System.Text.Json resolves an `object` declared type via the runtime
|
||||
/// type, which is what keeps the wire format identical to the pre-extraction inline code —
|
||||
/// assert it rather than trust it, since a regression here would silently emit "{}" rows.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GlobalPayload_SerializedAsObject_StillEmitsPopulatedRows()
|
||||
{
|
||||
var payload = GetImeVersionHistoryFunction.BuildResponsePayload([MakeEntry()], hasGlobalScope: true);
|
||||
|
||||
var json = JsonSerializer.Serialize(payload, CamelCase);
|
||||
|
||||
Assert.Contains("1.103.101.0", json, StringComparison.Ordinal);
|
||||
Assert.NotEqual("[{}]", json);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helpers
|
||||
// =========================================================================
|
||||
|
||||
private static string SerializeFor(bool hasGlobalScope, params ImeVersionHistoryEntry[] versions) =>
|
||||
JsonSerializer.Serialize(
|
||||
GetImeVersionHistoryFunction.BuildResponsePayload(versions, hasGlobalScope),
|
||||
CamelCase);
|
||||
|
||||
private static ImeVersionHistoryEntry MakeEntry(string version = "1.103.101.0", DateTime? firstSeenAt = null) =>
|
||||
new()
|
||||
{
|
||||
Version = version,
|
||||
FirstSeenAt = firstSeenAt ?? First,
|
||||
FirstSeenSessionId = "baae0453-6d19-47b5-aa58-8bed5a572fc8",
|
||||
FirstSeenTenantId = "57f34dd1-6a42-47f9-9bc0-b921fa6caa30",
|
||||
LastSeenAt = Last,
|
||||
SessionCount = 3109,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the per-session API-latency projection (2026-07-28): the V2 agent emits cumulative
|
||||
/// counters <c>net_total_latency_ms</c> / <c>net_total_requests</c> in every
|
||||
/// <c>agent_metrics_snapshot</c>; ingest derives the session-wide average from the LAST
|
||||
/// snapshot of the batch and persists it via <c>UpdateSessionNetworkLatencyAsync</c>
|
||||
/// (last-write-wins on cumulative counters → replay-idempotent).
|
||||
/// Wire-format note: the ingest body is parsed with Newtonsoft into
|
||||
/// <c>Dictionary<string, object></c>, so integer JSON numbers arrive as boxed
|
||||
/// <c>long</c> — fixtures mirror that, not the agent's in-memory types.
|
||||
/// </summary>
|
||||
public class IngestApiLatencyProjectionTests
|
||||
{
|
||||
private static EnrollmentEvent Snapshot(object? totalLatencyMs, object? totalRequests)
|
||||
{
|
||||
var evt = new EnrollmentEvent
|
||||
{
|
||||
EventType = "agent_metrics_snapshot",
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
["agent_cpu_percent"] = 3.62,
|
||||
["net_requests"] = 50L,
|
||||
["net_avg_latency_ms"] = 513.0,
|
||||
},
|
||||
};
|
||||
if (totalLatencyMs != null) evt.Data["net_total_latency_ms"] = totalLatencyMs;
|
||||
if (totalRequests != null) evt.Data["net_total_requests"] = totalRequests;
|
||||
return evt;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CumulativeCounters_YieldSessionAverage()
|
||||
{
|
||||
var events = new List<EnrollmentEvent> { Snapshot(51_300L, 100L) };
|
||||
|
||||
Assert.True(EventIngestProcessor.TryComputeSessionApiLatency(events, out var avg, out var count));
|
||||
Assert.Equal(513.0, avg);
|
||||
Assert.Equal(100, count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AverageIsRounded_ToOneDecimal()
|
||||
{
|
||||
var events = new List<EnrollmentEvent> { Snapshot(1_000L, 3L) };
|
||||
|
||||
Assert.True(EventIngestProcessor.TryComputeSessionApiLatency(events, out var avg, out _));
|
||||
Assert.Equal(333.3, avg);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LastSnapshotOfBatch_Wins()
|
||||
{
|
||||
// Cumulative counters grow monotonically — the last snapshot carries the most
|
||||
// complete session average.
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
Snapshot(51_300L, 100L),
|
||||
new EnrollmentEvent { EventType = "download_progress" },
|
||||
Snapshot(120_000L, 200L),
|
||||
};
|
||||
|
||||
Assert.True(EventIngestProcessor.TryComputeSessionApiLatency(events, out var avg, out var count));
|
||||
Assert.Equal(600.0, avg);
|
||||
Assert.Equal(200, count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OldAgentSnapshot_WithoutTotalLatencyField_DoesNotMatch()
|
||||
{
|
||||
// Agents predating net_total_latency_ms emit net_total_requests only.
|
||||
var events = new List<EnrollmentEvent> { Snapshot(totalLatencyMs: null, totalRequests: 100L) };
|
||||
|
||||
Assert.False(EventIngestProcessor.TryComputeSessionApiLatency(events, out _, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SnapshotWithLatencyButMissingRequests_DoesNotMatch()
|
||||
{
|
||||
var events = new List<EnrollmentEvent> { Snapshot(51_300L, totalRequests: null) };
|
||||
|
||||
Assert.False(EventIngestProcessor.TryComputeSessionApiLatency(events, out _, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroRequests_DoesNotMatch()
|
||||
{
|
||||
// No requests yet (first snapshot before the first upload completes) — no division.
|
||||
var events = new List<EnrollmentEvent> { Snapshot(0L, 0L) };
|
||||
|
||||
Assert.False(EventIngestProcessor.TryComputeSessionApiLatency(events, out _, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchingLastSnapshot_SkipsOlderAgentSnapshotAfterIt()
|
||||
{
|
||||
// A field-less (old-schema) snapshot later in the batch must not shadow an earlier
|
||||
// one that carries the counters — the predicate selects the last MATCHING snapshot.
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
Snapshot(51_300L, 100L),
|
||||
Snapshot(totalLatencyMs: null, totalRequests: 150L),
|
||||
};
|
||||
|
||||
Assert.True(EventIngestProcessor.TryComputeSessionApiLatency(events, out var avg, out var count));
|
||||
Assert.Equal(513.0, avg);
|
||||
Assert.Equal(100, count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BatchWithoutSnapshots_DoesNotMatch()
|
||||
{
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
new EnrollmentEvent { EventType = "esp_phase_changed" },
|
||||
new EnrollmentEvent { EventType = "app_install_started" },
|
||||
};
|
||||
|
||||
Assert.False(EventIngestProcessor.TryComputeSessionApiLatency(events, out _, out _));
|
||||
}
|
||||
|
||||
// -------- TryGetDouble coercion ladder (mirrors PlatformMetricsService.GetDouble) --------
|
||||
|
||||
[Theory]
|
||||
[InlineData(513.5)] // double — decimal JSON number
|
||||
[InlineData(513)] // int — in-memory constructed events (tests, V1 paths)
|
||||
[InlineData(513L)] // long — Newtonsoft wire form for integer JSON numbers
|
||||
[InlineData(513f)] // float
|
||||
public void TryGetDouble_CoercesBoxedNumericPrimitives(object boxed)
|
||||
{
|
||||
var data = new Dictionary<string, object> { ["k"] = boxed };
|
||||
|
||||
Assert.True(EventIngestProcessor.TryGetDouble(data, "k", out var value));
|
||||
Assert.Equal(Convert.ToDouble(boxed), value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetDouble_ParsesStrings_InvariantCulture()
|
||||
{
|
||||
var data = new Dictionary<string, object> { ["k"] = "513.5" };
|
||||
|
||||
Assert.True(EventIngestProcessor.TryGetDouble(data, "k", out var value));
|
||||
Assert.Equal(513.5, value);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("not-a-number")]
|
||||
[InlineData("")]
|
||||
public void TryGetDouble_RejectsUnparseableStrings(string raw)
|
||||
{
|
||||
var data = new Dictionary<string, object> { ["k"] = raw };
|
||||
|
||||
Assert.False(EventIngestProcessor.TryGetDouble(data, "k", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetDouble_MissingKey_ReturnsFalse()
|
||||
{
|
||||
Assert.False(EventIngestProcessor.TryGetDouble(new Dictionary<string, object>(), "k", out _));
|
||||
}
|
||||
}
|
||||
@@ -207,6 +207,34 @@ public class MetricsMathAppPayloadTests
|
||||
Assert.Equal(0, app.GetProperty("skipped").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImplausibleDurations_AreExcludedFromAverages_AndCountedAsUnmeasured()
|
||||
{
|
||||
// A duration beyond the agent's max observation lifetime is a back-stamped emission
|
||||
// artifact (CompletedAt pinned at session end — production: 60 175 s app rows inside a
|
||||
// 2 697 s session), not a slow install. It must fall out of the averages exactly like
|
||||
// an unobserved start. Boundary: exactly 6 h is still plausible/measured.
|
||||
var summaries = new List<AppInstallSummary>
|
||||
{
|
||||
new() { AppName = "App A", Status = "Succeeded", TerminalState = "Installed", DurationSeconds = 100 },
|
||||
new() { AppName = "App A", Status = "Succeeded", TerminalState = "Installed", DurationSeconds = 200 },
|
||||
new() { AppName = "App A", Status = "Succeeded", TerminalState = "Installed",
|
||||
DurationSeconds = MetricsMath.MaxPlausibleInstallDurationSeconds }, // boundary: measured
|
||||
new() { AppName = "App A", Status = "Succeeded", TerminalState = "Installed",
|
||||
DurationSeconds = MetricsMath.MaxPlausibleInstallDurationSeconds + 1 }, // artifact: unmeasured
|
||||
};
|
||||
|
||||
var root = Build(summaries);
|
||||
Assert.Equal(1, root.GetProperty("totalUnmeasured").GetInt32());
|
||||
|
||||
var slowest = root.GetProperty("slowestApps");
|
||||
Assert.Equal(1, slowest.GetArrayLength());
|
||||
Assert.Equal(3, slowest[0].GetProperty("measuredInstalls").GetInt32());
|
||||
// avg over {100, 200, 21600} — the 21601 artifact contributes nothing.
|
||||
Assert.Equal(7300d, slowest[0].GetProperty("avgDurationSeconds").GetDouble());
|
||||
Assert.Equal(MetricsMath.MaxPlausibleInstallDurationSeconds, slowest[0].GetProperty("maxDurationSeconds").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SlowestApps_GateOnMeasuredInstalls_NotOnSucceededCount()
|
||||
{
|
||||
|
||||
+27
-1
@@ -299,7 +299,7 @@ public class TimeAttributionPersistenceAndAggregationTests
|
||||
{
|
||||
var pairs = new List<(SessionSummary, SessionTimeBreakdown)>
|
||||
{
|
||||
(Session(TenantA, T0), Breakdown(flags: TimeAttributionFlags.BlockingSetUnknown)),
|
||||
(Session(TenantA, T0), Breakdown(flags: TimeAttributionFlags.ClockSkewDropped)),
|
||||
};
|
||||
|
||||
var aggregate = MaintenanceService.BuildTimeAttributionAggregates(
|
||||
@@ -312,6 +312,32 @@ public class TimeAttributionPersistenceAndAggregationTests
|
||||
Assert.Empty(aggregate.TopBlockingApps);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Aggregates_BlockingOnlyFlags_StayInSegmentStats()
|
||||
{
|
||||
// BlockingSetUnknown/Truncated limit per-app blocking EVIDENCE, not the measured spans.
|
||||
// Gating on any flag starved the fleet aggregates in production (903/1000 breakdowns
|
||||
// carried BlockingSetUnknown, 2026-07-27) — blocking-only flagged sessions must count
|
||||
// as clean; only duration-critical flags exclude.
|
||||
var pairs = new List<(SessionSummary, SessionTimeBreakdown)>
|
||||
{
|
||||
(Session(TenantA, T0), Breakdown(flags: TimeAttributionFlags.BlockingSetUnknown, espAppsSeconds: 100)),
|
||||
(Session(TenantA, T0), Breakdown(flags: TimeAttributionFlags.BlockingSetTruncated, espAppsSeconds: 300)),
|
||||
(Session(TenantA, T0), Breakdown(
|
||||
flags: TimeAttributionFlags.BlockingSetUnknown | TimeAttributionFlags.PartialObservation,
|
||||
espAppsSeconds: 99999)), // duration-critical bit set → still excluded
|
||||
};
|
||||
|
||||
var aggregate = MaintenanceService.BuildTimeAttributionAggregates(
|
||||
pairs, Array.Empty<SessionSummary>(), T0)
|
||||
.Single(a => a.TenantId == TenantA);
|
||||
|
||||
Assert.Equal(2, aggregate.CleanSessionCount);
|
||||
Assert.Equal(1, aggregate.FlaggedExcludedCount);
|
||||
// Median of {100, 300} = 100 (nearest-rank, lower value — repo convention).
|
||||
Assert.Equal(100, aggregate.SegmentStats.Single(s => s.SegmentKey == TimeAttributionSegments.EspApps).MedianSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Aggregates_MissingBreakdowns_AreCounted_NeverGuessed()
|
||||
{
|
||||
|
||||
+4
@@ -148,6 +148,10 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
public Task UpdateSessionImeAgentVersionAsync(string tenantId, string sessionId, string version)
|
||||
=> _storage.UpdateSessionImeAgentVersionAsync(tenantId, sessionId, version);
|
||||
|
||||
public Task UpdateSessionNetworkLatencyAsync(string tenantId, string sessionId,
|
||||
double avgLatencyMs, int requestCount)
|
||||
=> _storage.UpdateSessionNetworkLatencyAsync(tenantId, sessionId, avgLatencyMs, requestCount);
|
||||
|
||||
public Task<List<SessionSummary>> GetSessionsWithEventCountAboveAsync(string tenantId, int threshold)
|
||||
=> _storage.GetSessionsWithEventCountAboveAsync(tenantId, threshold);
|
||||
|
||||
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Infrastructure
|
||||
{
|
||||
/// <summary>
|
||||
/// Receives Azure Monitor alert notifications (Common Alert Schema) from an Action Group
|
||||
/// webhook receiver and re-emits them as ops events, so platform alerts flow through the
|
||||
/// same OpsEventService → OpsAlertDispatchService fan-out (Telegram/Teams/Slack providers +
|
||||
/// the portal ops-events list) as every internally raised operational event.
|
||||
///
|
||||
/// The delivery path is deliberately independent of the portal: Azure Monitor → this
|
||||
/// function → providers. A portal (SWA) outage cannot suppress these notifications — the
|
||||
/// action group's e-mail receiver additionally stays as the backend-independent fallback,
|
||||
/// because a self-hosted channel cannot alert on its own outage.
|
||||
///
|
||||
/// Security: anonymous route (Action Group webhook receivers can only call a bare URI — no
|
||||
/// custom headers), gated by a shared secret in the query string compared in constant time
|
||||
/// against the OpsAlertWebhookSecret app setting. Fail-closed: while the setting is unset,
|
||||
/// every request is rejected. Dispatch to providers only happens if an enabled ops alert
|
||||
/// rule for EventType "AzureMonitorAlert" matches the mapped severity — the event itself is
|
||||
/// always stored.
|
||||
/// </summary>
|
||||
public class AzureMonitorAlertWebhookFunction
|
||||
{
|
||||
internal const string SecretSettingName = "OpsAlertWebhookSecret";
|
||||
|
||||
// Common Alert Schema payloads for log-search alerts carry the search query, link URLs
|
||||
// and dimension sets — a few KB in practice. 64 KB is a generous shape gate, not a throttle.
|
||||
internal const int MaxContentLength = 64 * 1024;
|
||||
|
||||
private readonly OpsEventService _opsEventService;
|
||||
private readonly ILogger<AzureMonitorAlertWebhookFunction> _logger;
|
||||
|
||||
public AzureMonitorAlertWebhookFunction(
|
||||
OpsEventService opsEventService,
|
||||
ILogger<AzureMonitorAlertWebhookFunction> logger)
|
||||
{
|
||||
_opsEventService = opsEventService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[Function("AzureMonitorAlertWebhook")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "ops/alert-webhook")] HttpRequestData req)
|
||||
{
|
||||
var configuredSecret = Environment.GetEnvironmentVariable(SecretSettingName);
|
||||
var providedSecret = System.Web.HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty)["secret"];
|
||||
|
||||
if (!SecretMatches(providedSecret, configuredSecret))
|
||||
{
|
||||
// Deliberately no detail in the response; the warning makes a misconfigured
|
||||
// (unset) secret operator-visible in the logs.
|
||||
_logger.LogWarning(
|
||||
"Azure Monitor alert webhook rejected: {Reason}",
|
||||
string.IsNullOrEmpty(configuredSecret) ? "secret not configured (fail-closed)" : "secret mismatch");
|
||||
return req.CreateResponse(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
// Bounded read instead of a Body.Length check: with ASP.NET Core integration the
|
||||
// request body is a forward-only stream whose Length property throws
|
||||
// NotSupportedException. Reading at most cap+1 chars detects oversize without ever
|
||||
// needing to know the total size. (Char-based cap ≈ byte cap for a shape gate.)
|
||||
string body;
|
||||
using (var reader = new StreamReader(req.Body, Encoding.UTF8))
|
||||
{
|
||||
var buffer = new char[MaxContentLength + 1];
|
||||
var read = await reader.ReadBlockAsync(buffer, 0, buffer.Length);
|
||||
if (read > MaxContentLength)
|
||||
{
|
||||
_logger.LogWarning("Azure Monitor alert webhook rejected: payload exceeds {Cap} char cap", MaxContentLength);
|
||||
return req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
}
|
||||
body = new string(buffer, 0, read);
|
||||
}
|
||||
|
||||
var alert = TryParse(body);
|
||||
if (alert == null)
|
||||
{
|
||||
_logger.LogWarning("Azure Monitor alert webhook rejected: body is not a Common Alert Schema payload");
|
||||
return req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
await _opsEventService.RecordAzureMonitorAlertAsync(
|
||||
alert.AlertRule,
|
||||
MapSeverity(alert.Severity),
|
||||
alert.MonitorCondition,
|
||||
alert.Description,
|
||||
alert.Severity,
|
||||
alert.MonitoringService,
|
||||
alert.TargetResource,
|
||||
alert.MetricValue);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Azure Monitor alert '{AlertRule}' {Condition} ({Severity}) recorded as ops event",
|
||||
alert.AlertRule, alert.MonitorCondition, alert.Severity);
|
||||
|
||||
return req.CreateResponse(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Constant-time secret comparison. Fail-closed: an unset/empty configured secret never
|
||||
/// matches anything, so the endpoint stays dark until the app setting is provisioned.
|
||||
/// </summary>
|
||||
internal static bool SecretMatches(string? provided, string? configured)
|
||||
{
|
||||
if (string.IsNullOrEmpty(configured) || string.IsNullOrEmpty(provided))
|
||||
return false;
|
||||
|
||||
return CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(provided),
|
||||
Encoding.UTF8.GetBytes(configured));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps Azure Monitor severities (Sev0 = most severe … Sev4 = verbose) onto the ops event
|
||||
/// severity scale. Unknown values fall back to Info so a schema drift can never escalate.
|
||||
/// </summary>
|
||||
internal static string MapSeverity(string? azureSeverity) => azureSeverity switch
|
||||
{
|
||||
"Sev0" => Shared.DataAccess.OpsEventSeverity.Critical,
|
||||
"Sev1" => Shared.DataAccess.OpsEventSeverity.Error,
|
||||
"Sev2" => Shared.DataAccess.OpsEventSeverity.Warning,
|
||||
_ => Shared.DataAccess.OpsEventSeverity.Info,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Parsed subset of the Common Alert Schema. Only <see cref="AlertRule"/> is mandatory;
|
||||
/// everything else is best-effort so a partial payload still produces a usable ops event.
|
||||
/// </summary>
|
||||
internal sealed record ParsedAlert(
|
||||
string AlertRule,
|
||||
string? Severity,
|
||||
string MonitorCondition,
|
||||
string? Description,
|
||||
string? MonitoringService,
|
||||
string? TargetResource,
|
||||
double? MetricValue);
|
||||
|
||||
/// <summary>
|
||||
/// Defensively extracts data.essentials (+ the first condition's metricValue) from a
|
||||
/// Common Alert Schema payload. Returns null when the body is not JSON or carries no
|
||||
/// alert rule name — the two states in which no meaningful ops event can be formed.
|
||||
/// </summary>
|
||||
internal static ParsedAlert? TryParse(string body)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(body))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
if (!doc.RootElement.TryGetProperty("data", out var data) ||
|
||||
!data.TryGetProperty("essentials", out var essentials))
|
||||
return null;
|
||||
|
||||
var alertRule = GetString(essentials, "alertRule");
|
||||
if (string.IsNullOrWhiteSpace(alertRule))
|
||||
return null;
|
||||
|
||||
var targetResource = GetString(essentials, "targetResourceName");
|
||||
if (string.IsNullOrEmpty(targetResource) &&
|
||||
essentials.TryGetProperty("alertTargetIDs", out var targets) &&
|
||||
targets.ValueKind == JsonValueKind.Array && targets.GetArrayLength() > 0 &&
|
||||
targets[0].ValueKind == JsonValueKind.String)
|
||||
{
|
||||
// Fall back to the resource name segment of the first target resource ID.
|
||||
var id = targets[0].GetString() ?? string.Empty;
|
||||
targetResource = id[(id.LastIndexOf('/') + 1)..];
|
||||
}
|
||||
|
||||
// Log-search alerts carry the observed value under alertContext.condition.allOf[0].
|
||||
double? metricValue = null;
|
||||
if (data.TryGetProperty("alertContext", out var ctx) &&
|
||||
ctx.ValueKind == JsonValueKind.Object &&
|
||||
ctx.TryGetProperty("condition", out var condition) &&
|
||||
condition.ValueKind == JsonValueKind.Object &&
|
||||
condition.TryGetProperty("allOf", out var allOf) &&
|
||||
allOf.ValueKind == JsonValueKind.Array && allOf.GetArrayLength() > 0 &&
|
||||
allOf[0].ValueKind == JsonValueKind.Object &&
|
||||
allOf[0].TryGetProperty("metricValue", out var mv) &&
|
||||
mv.ValueKind == JsonValueKind.Number)
|
||||
{
|
||||
metricValue = mv.GetDouble();
|
||||
}
|
||||
|
||||
return new ParsedAlert(
|
||||
alertRule!,
|
||||
GetString(essentials, "severity"),
|
||||
GetString(essentials, "monitorCondition") ?? "Fired",
|
||||
GetString(essentials, "description"),
|
||||
GetString(essentials, "monitoringService"),
|
||||
targetResource,
|
||||
metricValue);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? GetString(JsonElement element, string property)
|
||||
=> element.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.String
|
||||
? value.GetString()
|
||||
: null;
|
||||
}
|
||||
}
|
||||
+30
@@ -153,6 +153,18 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
.Where(s => appsBySession.ContainsKey(s.SessionId))
|
||||
.Count(s => appsBySession[s.SessionId].Any(a => a.DoDownloadMode >= 0));
|
||||
|
||||
// Agent→backend API latency: weight each session's stored average by its request
|
||||
// count so the location figure equals Σ(latency·requests)/Σ(requests) — the exact
|
||||
// whole-population average, not an average of session averages. Sessions from
|
||||
// agents predating the feature carry no data and drop out.
|
||||
var latencySessions = group
|
||||
.Where(s => s.AvgApiLatencyMs is > 0 && s.ApiRequestCount is > 0)
|
||||
.ToList();
|
||||
var latencyRequestSum = latencySessions.Sum(s => (double)s.ApiRequestCount!.Value);
|
||||
var avgApiLatency = latencyRequestSum > 0
|
||||
? latencySessions.Sum(s => s.AvgApiLatencyMs!.Value * s.ApiRequestCount!.Value) / latencyRequestSum
|
||||
: 0;
|
||||
|
||||
locations.Add(new LocationMetrics
|
||||
{
|
||||
LocationKey = group.Key,
|
||||
@@ -171,6 +183,8 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
MinutesPerApp = Math.Round(minutesPerApp, 2),
|
||||
AvgThroughputBytesPerSec = Math.Round(avgThroughput, 0),
|
||||
TotalDownloadBytes = totalBytes,
|
||||
AvgApiLatencyMs = Math.Round(avgApiLatency, 0),
|
||||
ApiLatencySessionCount = latencySessions.Count,
|
||||
// Delivery Optimization
|
||||
DoSessionCount = doSessionCount,
|
||||
AvgDoPercentPeerCaching = Math.Round(doAgg.PercentPeerCaching, 1),
|
||||
@@ -202,6 +216,17 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
.Select(l => l.AvgThroughputBytesPerSec).ToList();
|
||||
var globalAvgThroughput = allThroughputs.Count > 0 ? allThroughputs.Average() : 0;
|
||||
|
||||
// Global API latency: request-weighted over ALL geo sessions (not location averages),
|
||||
// so big sites weigh in proportionally — same Σ(latency·requests)/Σ(requests) math as
|
||||
// the per-location figure.
|
||||
var globalLatencySessions = geoSessions
|
||||
.Where(s => s.AvgApiLatencyMs is > 0 && s.ApiRequestCount is > 0)
|
||||
.ToList();
|
||||
var globalLatencyRequestSum = globalLatencySessions.Sum(s => (double)s.ApiRequestCount!.Value);
|
||||
var globalAvgApiLatency = globalLatencyRequestSum > 0
|
||||
? globalLatencySessions.Sum(s => s.AvgApiLatencyMs!.Value * s.ApiRequestCount!.Value) / globalLatencyRequestSum
|
||||
: 0;
|
||||
|
||||
// Global DO metrics (weighted by bytes, not simple average)
|
||||
var globalDoPeers = locations.Sum(l => l.TotalDoBytesFromPeers);
|
||||
var globalDoHttp = locations.Sum(l => l.TotalDoBytesFromHttp);
|
||||
@@ -219,6 +244,10 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
loc.ThroughputVsGlobalPct = globalAvgThroughput > 0
|
||||
? Math.Round((loc.AvgThroughputBytesPerSec - globalAvgThroughput) / globalAvgThroughput * 100, 1) : 0;
|
||||
|
||||
// API latency vs global (positive = slower/farther from the backend region)
|
||||
loc.ApiLatencyVsGlobalPct = globalAvgApiLatency > 0 && loc.AvgApiLatencyMs > 0
|
||||
? Math.Round((loc.AvgApiLatencyMs - globalAvgApiLatency) / globalAvgApiLatency * 100, 1) : 0;
|
||||
|
||||
// AppLoadScore: normalize minutesPerApp to global median = 100
|
||||
loc.AppLoadScore = globalMedianMinutesPerApp > 0
|
||||
? Math.Round(loc.MinutesPerApp / globalMedianMinutesPerApp * 100, 0) : 0;
|
||||
@@ -243,6 +272,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
AvgMinutesPerApp = Math.Round(globalAvgMinutesPerApp, 2),
|
||||
AvgThroughputBytesPerSec = Math.Round(globalAvgThroughput, 0),
|
||||
StdDevDurationMinutes = Math.Round(globalStdDev, 1),
|
||||
AvgApiLatencyMs = Math.Round(globalAvgApiLatency, 0),
|
||||
AvgDoPercentPeerCaching = Math.Round(globalDoPct, 1),
|
||||
TotalDoBytesFromPeers = globalDoPeers,
|
||||
TotalDoBytesFromHttp = globalDoHttp
|
||||
|
||||
+32
-18
@@ -1,6 +1,7 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -34,24 +35,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
var versions = await _sessionRepo.GetImeVersionHistoryAsync();
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
|
||||
if (requestCtx.HasGlobalScope)
|
||||
{
|
||||
await response.WriteAsJsonAsync(versions);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Strip sensitive cross-tenant data for non-Global Admins
|
||||
var redacted = versions.Select(v => new
|
||||
{
|
||||
v.Version,
|
||||
v.FirstSeenAt,
|
||||
v.LastSeenAt,
|
||||
v.SessionCount
|
||||
});
|
||||
await response.WriteAsJsonAsync(redacted);
|
||||
}
|
||||
|
||||
await response.WriteAsJsonAsync(BuildResponsePayload(versions, requestCtx.HasGlobalScope));
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -62,5 +46,35 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
return errorResponse;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Projects the archive for the caller's scope.
|
||||
///
|
||||
/// Extracted from <see cref="Run"/> so the cross-tenant redaction boundary is
|
||||
/// directly testable: this suite has no HTTP-level harness, so an inline projection
|
||||
/// is unreachable from a test — and an unguarded redaction is exactly what a later
|
||||
/// refactor opens silently.
|
||||
///
|
||||
/// FirstSeenTenantId and FirstSeenSessionId name ANOTHER tenant's device and are
|
||||
/// dropped for non-global callers. Version, dates and SessionCount deliberately stay:
|
||||
/// the archive is a platform-wide view of Microsoft's IME rollout, and the route is
|
||||
/// MemberRead precisely so any tenant member can read that view.
|
||||
///
|
||||
/// Returned as <see cref="object"/> on purpose — System.Text.Json serializes an
|
||||
/// `object` declared type using the RUNTIME type, so both branches keep the exact
|
||||
/// wire format they had while the projection was inline.
|
||||
/// </summary>
|
||||
public static object BuildResponsePayload(
|
||||
IEnumerable<ImeVersionHistoryEntry> versions, bool hasGlobalScope)
|
||||
{
|
||||
if (hasGlobalScope)
|
||||
{
|
||||
return versions.ToList();
|
||||
}
|
||||
|
||||
return versions
|
||||
.Select(v => new { v.Version, v.FirstSeenAt, v.LastSeenAt, v.SessionCount })
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,6 +210,8 @@ public class SearchSessionsFunction
|
||||
if (fields.Contains("imeAgentVersion")) dict["imeAgentVersion"] = s.ImeAgentVersion;
|
||||
if (fields.Contains("geoCountry")) dict["geoCountry"] = s.GeoCountry;
|
||||
if (fields.Contains("rebootCount")) dict["rebootCount"] = s.RebootCount;
|
||||
if (fields.Contains("avgApiLatencyMs")) dict["avgApiLatencyMs"] = s.AvgApiLatencyMs;
|
||||
if (fields.Contains("apiRequestCount")) dict["apiRequestCount"] = s.ApiRequestCount;
|
||||
|
||||
if (dict.Count == 0)
|
||||
{
|
||||
|
||||
@@ -32,15 +32,29 @@ public static class MetricsMath
|
||||
public static bool IsSkipTerminalState(AppInstallSummary s)
|
||||
=> s.TerminalState == "Skipped" || s.TerminalState == "Postponed";
|
||||
|
||||
/// <summary>
|
||||
/// Upper plausibility bound for an observed install duration: 6 h, the agent's default
|
||||
/// max observation lifetime (AgentMaxLifetimeMinutes = 360) — nothing longer can have
|
||||
/// been continuously watched. Rows above it are emission artifacts, not slow installs:
|
||||
/// a batch emission at session end pins CompletedAt far past the real finish (verified
|
||||
/// in production 2026-07-27: app rows carrying 60 175 s in a session whose own wall
|
||||
/// clock was 2 697 s; 490 rows > 6 h overall, worst 117 days — dominating the
|
||||
/// slowest-apps ranking with six-figure averages).
|
||||
/// </summary>
|
||||
public const int MaxPlausibleInstallDurationSeconds = 6 * 3600;
|
||||
|
||||
/// <summary>
|
||||
/// True for a succeeded row whose install duration was actually observed. Zero /
|
||||
/// absent duration on a non-skip succeeded row means the START was never observed
|
||||
/// (agent attach window — audit finding, §0.5 of the insights spec): the duration is
|
||||
/// UNKNOWN, not zero — averaging it in would understate real install times (measured
|
||||
/// −18 % in production). Duration statistics must only read rows where this is true.
|
||||
/// (agent attach window — audit finding, §0.5 of the insights spec); a duration above
|
||||
/// <see cref="MaxPlausibleInstallDurationSeconds"/> means the END was never observed
|
||||
/// (completion back-stamped at session end). Either way the duration is UNKNOWN —
|
||||
/// averaging zeros understates (measured −18 % in production), averaging back-stamps
|
||||
/// inflates. Duration statistics must only read rows where this is true; everything
|
||||
/// else is reported as "unmeasured".
|
||||
/// </summary>
|
||||
public static bool HasMeasuredDuration(AppInstallSummary s)
|
||||
=> s.DurationSeconds > 0;
|
||||
=> s.DurationSeconds > 0 && s.DurationSeconds <= MaxPlausibleInstallDurationSeconds;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the complete app-metrics response object from a (pre-time-filtered) set of app
|
||||
|
||||
@@ -170,6 +170,11 @@ public static class EndpointAccessPolicyCatalog
|
||||
// ticket is the sole authority — authz happened at mint time (diagnostics/download-ticket,
|
||||
// MemberRead). See DiagnosticsTicketDownloadFunction.
|
||||
new("GET", "diagnostics/download", EndpointPolicy.PublicAnonymous),
|
||||
// Azure Monitor action-group webhook (Common Alert Schema → ops event). Action groups
|
||||
// cannot send custom headers, so the shared secret travels as ?secret= and is compared
|
||||
// in constant time against the OpsAlertWebhookSecret app setting (fail-closed while
|
||||
// unset). See AzureMonitorAlertWebhookFunction.
|
||||
new("POST", "ops/alert-webhook", EndpointPolicy.PublicAnonymous),
|
||||
|
||||
// ── DeviceOrBootstrapAuth ───────────────────────────────────────
|
||||
new("POST", "agent/register-session", EndpointPolicy.DeviceOrBootstrapAuth),
|
||||
|
||||
@@ -143,6 +143,14 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
}
|
||||
|
||||
if (TryComputeSessionApiLatency(request.Events, out var avgLatencyMs, out var apiRequestCount))
|
||||
{
|
||||
_ = _sessionRepo.UpdateSessionNetworkLatencyAsync(
|
||||
request.TenantId, request.SessionId, avgLatencyMs, apiRequestCount)
|
||||
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException,
|
||||
"Fire-and-forget UpdateSessionNetworkLatencyAsync failed"), TaskContinuationOptions.OnlyOnFaulted);
|
||||
}
|
||||
|
||||
var classification = ClassifyEvents(storedEvents);
|
||||
|
||||
foreach (var summary in classification.AppInstallUpdates.Values)
|
||||
@@ -395,6 +403,58 @@ namespace AutopilotMonitor.Functions.Services
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Session-wide API latency projection: the agent emits cumulative counters
|
||||
/// (net_total_latency_ms / net_total_requests) in every agent_metrics_snapshot, so
|
||||
/// the LAST snapshot of the batch carries the whole-session average up to now and a
|
||||
/// plain overwrite is idempotent against replays. Agents predating the field simply
|
||||
/// never match (net_total_latency_ms absent) — no fallback, fleet turns over
|
||||
/// per-enrollment. Exposed as internal for unit testing.
|
||||
/// </summary>
|
||||
internal static bool TryComputeSessionApiLatency(
|
||||
List<EnrollmentEvent> events, out double avgLatencyMs, out int requestCount)
|
||||
{
|
||||
avgLatencyMs = 0;
|
||||
requestCount = 0;
|
||||
var snapshot = events.LastOrDefault(e =>
|
||||
e.EventType == Shared.Constants.EventTypes.AgentMetricsSnapshot &&
|
||||
e.Data?.ContainsKey("net_total_latency_ms") == true);
|
||||
if (snapshot?.Data == null ||
|
||||
!TryGetDouble(snapshot.Data, "net_total_latency_ms", out var totalLatencyMs) ||
|
||||
!TryGetDouble(snapshot.Data, "net_total_requests", out var totalRequests) ||
|
||||
totalRequests <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
avgLatencyMs = Math.Round(totalLatencyMs / totalRequests, 1);
|
||||
requestCount = (int)totalRequests;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Numeric reader for agent event Data values, which arrive as boxed Newtonsoft
|
||||
/// primitives (integer → long, decimal → double) — same coercion ladder as
|
||||
/// PlatformMetricsService.GetDouble (the proven parser for these snapshot fields),
|
||||
/// plus invariant-culture string parsing. Exposed as internal for unit testing.
|
||||
/// </summary>
|
||||
internal static bool TryGetDouble(Dictionary<string, object> data, string key, out double value)
|
||||
{
|
||||
value = 0;
|
||||
if (!data.TryGetValue(key, out var raw))
|
||||
return false;
|
||||
switch (raw)
|
||||
{
|
||||
case double d: value = d; return true;
|
||||
case int i: value = i; return true;
|
||||
case long l: value = l; return true;
|
||||
case float f: value = f; return true;
|
||||
}
|
||||
return double.TryParse(raw?.ToString(),
|
||||
System.Globalization.NumberStyles.Float,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stamps authoritative server-side fields onto all events before storage.
|
||||
/// TenantId and SessionId always come from the validated request metadata,
|
||||
|
||||
@@ -394,8 +394,11 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// <summary>
|
||||
/// Pure aggregation core behind <see cref="SweepTimeAttributionAsync"/> — internal static
|
||||
/// so the bucketing/gating contract is pinned by unit tests. Per (tenant × class × date)
|
||||
/// bucket plus mirrored "global" rows: only CLEAN breakdowns (QualityFlags == None) form
|
||||
/// the statistics; flagged and missing ones are counted, never silently dropped (rule 7).
|
||||
/// bucket plus mirrored "global" rows: only breakdowns without DURATION-critical flags
|
||||
/// (<see cref="TimeAttributionFlagQuality.DurationCriticalFlags"/>) form the statistics —
|
||||
/// blocking-set-only flags stay in (their spans are sound; unknown blocking simply
|
||||
/// contributes no per-app intervals); excluded and missing ones are counted, never
|
||||
/// silently dropped (rule 7).
|
||||
/// Rows are written even below the ≥20 UI gate — the UI needs the n (rule 4). Segment
|
||||
/// stats always carry the five canonical segments + unattributed (a session without a
|
||||
/// span of a segment contributes 0 — the honest "per enrollment of this class" answer).
|
||||
@@ -427,7 +430,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
foreach (var tenantKey in new[] { session.TenantId, "global" })
|
||||
{
|
||||
var key = (tenantKey, date, cls);
|
||||
if (breakdown.QualityFlags != TimeAttributionFlags.None)
|
||||
if (TimeAttributionFlagQuality.ExcludesFromFleetStats(breakdown.QualityFlags))
|
||||
{
|
||||
Bump(flaggedCounts, key);
|
||||
// Flagged sessions still materialize the bucket so a day whose sessions
|
||||
|
||||
@@ -594,6 +594,24 @@ namespace AutopilotMonitor.Functions.Services
|
||||
null, "System.SlaEvaluation",
|
||||
new { tenantsEvaluated, breachesDetected, notificationsSent, durationMs });
|
||||
|
||||
// ── Platform (Azure Monitor) ───────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Records an Azure Monitor alert notification delivered via the ops alert webhook
|
||||
/// (AzureMonitorAlertWebhookFunction). severity is already mapped onto the ops scale;
|
||||
/// azureSeverity keeps the original Sev0–Sev4 value for the details payload.
|
||||
/// </summary>
|
||||
public Task RecordAzureMonitorAlertAsync(string alertRule, string severity, string monitorCondition,
|
||||
string? description, string? azureSeverity, string? monitoringService, string? targetResource,
|
||||
double? metricValue)
|
||||
{
|
||||
var suffix = string.IsNullOrWhiteSpace(description) ? string.Empty : $": {description}";
|
||||
return WriteAsync(OpsEventCategory.Platform, "AzureMonitorAlert", severity,
|
||||
$"Azure Monitor alert '{alertRule}' {monitorCondition}{suffix}",
|
||||
null, "System.AzureMonitor",
|
||||
new { alertRule, monitorCondition, azureSeverity, monitoringService, targetResource, metricValue });
|
||||
}
|
||||
|
||||
// ── Core write method ──────────────────────────────────────────────────
|
||||
|
||||
private async Task WriteAsync(string category, string eventType, string severity,
|
||||
|
||||
@@ -213,6 +213,15 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
var lastSnapshot = snapshots.Last();
|
||||
|
||||
// Prefer the exact session average from the cumulative counters (agents ≥ 2026-07-28
|
||||
// emit net_total_latency_ms); fall back to the unweighted mean of the per-interval
|
||||
// delta averages for older sessions.
|
||||
var totalLatencyMs = GetDouble(lastSnapshot, "net_total_latency_ms");
|
||||
var totalLatencyRequests = GetDouble(lastSnapshot, "net_total_requests");
|
||||
var sessionAvgLatency = totalLatencyMs > 0 && totalLatencyRequests > 0
|
||||
? totalLatencyMs / totalLatencyRequests
|
||||
: (latValues.Count > 0 ? latValues.Average() : 0);
|
||||
|
||||
// Resolve agent version: prefer from snapshot, fallback to session
|
||||
var agentVersion = snapshots
|
||||
.Select(s => GetString(s, "agent_version"))
|
||||
@@ -239,7 +248,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
AvgWorkingSet = wsValues.Count > 0 ? wsValues.Average() : 0,
|
||||
MaxWorkingSet = wsValues.Count > 0 ? wsValues.Max() : 0,
|
||||
AvgPrivateBytes = pbValues.Count > 0 ? pbValues.Average() : 0,
|
||||
AvgLatency = latValues.Count > 0 ? latValues.Average() : 0,
|
||||
AvgLatency = sessionAvgLatency,
|
||||
AvgSpoolDepth = spoolValues.Count > 0 ? spoolValues.Average() : 0,
|
||||
MaxSpoolDepth = spoolValues.Count > 0 ? spoolValues.Max() : 0,
|
||||
PeakSpoolDepth = peakValues.Count > 0 ? peakValues.Last() : 0,
|
||||
|
||||
@@ -586,7 +586,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
internal static readonly string[] GeoMetricsSessionProjection =
|
||||
{
|
||||
"PartitionKey", "RowKey", "StartedAt", "CompletedAt", "Status", "DurationSeconds",
|
||||
"IsPreProvisioned", "ResumedAt", "GeoCountry", "GeoRegion", "GeoCity", "GeoLoc"
|
||||
"IsPreProvisioned", "ResumedAt", "GeoCountry", "GeoRegion", "GeoCity", "GeoLoc",
|
||||
"AvgApiLatencyMs", "ApiRequestCount"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -538,12 +538,14 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
/// <summary>
|
||||
/// Columns MetricsMath.BuildAppMetricsPayload consumes (slowest/failing ranking + DO rollup):
|
||||
/// grouping key, status/duration/bytes, failure code and every counter DoAggregator sums.
|
||||
/// grouping key, status + terminal state (PR0 skip classification — dropping TerminalState
|
||||
/// silently zeroed totalSkipped in production, found 2026-07-27), duration/bytes, failure
|
||||
/// code and every counter DoAggregator sums.
|
||||
/// internal so AppsProjectionEquivalenceTests derives its keep-set from this array.
|
||||
/// </summary>
|
||||
internal static readonly string[] AppMetricsProjection =
|
||||
{
|
||||
"PartitionKey", "RowKey", "AppName", "Status", "StartedAt",
|
||||
"PartitionKey", "RowKey", "AppName", "Status", "TerminalState", "StartedAt",
|
||||
"DurationSeconds", "DownloadBytes", "FailureCode",
|
||||
"DoDownloadMode", "DoTotalBytesDownloaded", "DoBytesFromPeers", "DoBytesFromHttp",
|
||||
"DoBytesFromLanPeers", "DoBytesFromGroupPeers", "DoBytesFromInternetPeers",
|
||||
|
||||
@@ -215,6 +215,16 @@ namespace AutopilotMonitor.Functions.Services
|
||||
if (stalledAt.HasValue)
|
||||
indexEntity["StalledAt"] = EnsureUtc(stalledAt.Value);
|
||||
|
||||
// Agent→backend HTTP latency projection (UpdateSessionNetworkLatencyAsync merges
|
||||
// these) — must be in this superset or a StartedAt-shift full upsert drops them.
|
||||
var avgApiLatencyMs = sessionEntity.GetDouble("AvgApiLatencyMs");
|
||||
if (avgApiLatencyMs.HasValue)
|
||||
indexEntity["AvgApiLatencyMs"] = avgApiLatencyMs.Value;
|
||||
|
||||
var apiRequestCount = sessionEntity.GetInt32("ApiRequestCount");
|
||||
if (apiRequestCount.HasValue)
|
||||
indexEntity["ApiRequestCount"] = apiRequestCount.Value;
|
||||
|
||||
return indexEntity;
|
||||
}
|
||||
|
||||
@@ -2655,6 +2665,55 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stores the session-wide average agent→backend HTTP latency (Merge-mode, two fields).
|
||||
/// Derived from cumulative counters, so a plain last-write-wins overwrite is idempotent
|
||||
/// against batch replays and monotonically converges to the whole-session average.
|
||||
/// Same UpdateEntity-not-Upsert rationale as <see cref="UpdateSessionImeAgentVersionAsync"/>:
|
||||
/// a tombstoned Sessions row must stay tombstoned (404 → no-op).
|
||||
/// Non-fatal: failures are logged as warnings and do not block ingest.
|
||||
/// </summary>
|
||||
public async Task UpdateSessionNetworkLatencyAsync(string tenantId, string sessionId,
|
||||
double avgLatencyMs, int requestCount)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
|
||||
var update = new TableEntity(tenantId, sessionId)
|
||||
{
|
||||
["AvgApiLatencyMs"] = avgLatencyMs,
|
||||
["ApiRequestCount"] = requestCount
|
||||
};
|
||||
await tableClient.UpdateEntityAsync(update, ETag.All, TableUpdateMode.Merge);
|
||||
|
||||
// Mirror to SessionsIndex — the index is a full denormalized mirror and serves the
|
||||
// session list / GA cross-tenant views where per-geo latency aggregation happens.
|
||||
var idxRef = await tableClient.GetEntityAsync<TableEntity>(
|
||||
tenantId, sessionId, select: new[] { "IndexRowKey" });
|
||||
var indexRowKey = idxRef.Value.GetString("IndexRowKey");
|
||||
if (!string.IsNullOrEmpty(indexRowKey))
|
||||
{
|
||||
await MergeSessionIndexAsync(tenantId, indexRowKey,
|
||||
new TableEntity(tenantId, indexRowKey)
|
||||
{
|
||||
["AvgApiLatencyMs"] = avgLatencyMs,
|
||||
["ApiRequestCount"] = requestCount
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"UpdateSessionNetworkLatency no-op: Sessions row {Tenant}/{Session} is absent (tombstoned or never registered)",
|
||||
tenantId, sessionId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Non-fatal — don't block ingest
|
||||
_logger.LogWarning(ex, "Failed to update network latency for session {SessionId}", sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== SESSION/EVENT MAPPING HELPERS =====
|
||||
|
||||
/// <summary>
|
||||
@@ -2748,6 +2807,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
PendingActionsQueuedAt = SafeGetDateTime(entity, "PendingActionsQueuedAt"),
|
||||
EventCount = SafeGetInt32(entity, "EventCount") ?? 0,
|
||||
DurationSeconds = ComputeEffectiveDuration(entity, status, startedAt, completedAt),
|
||||
AvgApiLatencyMs = SafeGetDouble(entity, "AvgApiLatencyMs"),
|
||||
ApiRequestCount = SafeGetInt32(entity, "ApiRequestCount"),
|
||||
EnrollmentType = entity.GetString("EnrollmentType") ?? "v1",
|
||||
DiagnosticsBlobName = entity.GetString("DiagnosticsBlobName"),
|
||||
// Legacy rows that predate this field surface as null → download path treats
|
||||
|
||||
@@ -159,6 +159,37 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Safely reads a Double property from a TableEntity.
|
||||
/// Returns null instead of throwing when the property has a different type (legacy data).
|
||||
/// Also accepts Int32/Int64-typed cells — Table Storage stores a whole-number double
|
||||
/// as Int when written via JSON paths that drop the decimal point.
|
||||
/// </summary>
|
||||
private double? SafeGetDouble(TableEntity entity, string key)
|
||||
{
|
||||
try
|
||||
{
|
||||
return entity.GetDouble(key);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
if (entity.TryGetValue(key, out var raw))
|
||||
{
|
||||
switch (raw)
|
||||
{
|
||||
case int i: return i;
|
||||
case long l: return l;
|
||||
case string s when double.TryParse(s,
|
||||
System.Globalization.NumberStyles.Float,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var parsed):
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
_logger.LogWarning("Property '{Key}' on entity {PK}/{RK} is not Double and could not be coerced", key, entity.PartitionKey, entity.RowKey);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Safely reads a DateTime property from a TableEntity.
|
||||
/// Returns null instead of throwing when the property has a different type (legacy data).
|
||||
|
||||
@@ -70,7 +70,20 @@ ENV NODE_ENV=production
|
||||
ENV HF_CACHE_DIR=/app/.hf-cache
|
||||
|
||||
COPY --from=build /app/package.json /app/package-lock.json ./
|
||||
RUN npm ci --omit=dev --ignore-scripts && npm cache clean --force
|
||||
# npm is a BUILD-time tool only: the runtime is `node dist/index.js`, the
|
||||
# HEALTHCHECK is `node -e`, and the server never shells out (no child_process
|
||||
# anywhere in src/). But the npm that node:22-slim bundles carries its own
|
||||
# vendored dependency tree under /usr/local/lib/node_modules/npm, and a
|
||||
# container image scanner reports every CVE in it against this image —
|
||||
# @sigstore/core, brace-expansion, tar, sigstore, picomatch, ip-address, plus
|
||||
# npm itself, none of which appear in our package-lock and none of which
|
||||
# Dependabot can see.
|
||||
#
|
||||
# Removing npm clears all of them; upgrading it does not (npm 11.x still
|
||||
# vendors @sigstore/core 3.1.0, inside the vulnerable <= 3.2.0 range). The rm
|
||||
# must share this RUN so the deletion lands in the same layer as the install.
|
||||
RUN npm ci --omit=dev --ignore-scripts && npm cache clean --force && \
|
||||
rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
||||
|
||||
COPY --from=build /app/dist/ ./dist/
|
||||
COPY --from=build /app/.hf-cache/ ./.hf-cache/
|
||||
|
||||
+11
-8
@@ -9,7 +9,7 @@
|
||||
"version": "1.5.1",
|
||||
"dependencies": {
|
||||
"@huggingface/transformers": "^3.8.1",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"compression": "^1.8.1",
|
||||
"express": "^5.2.1",
|
||||
"fuse.js": "^7.5.0",
|
||||
@@ -25,6 +25,9 @@
|
||||
"tsx": "^4.23.1",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
@@ -1066,12 +1069,12 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
||||
"integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==",
|
||||
"version": "1.30.0",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz",
|
||||
"integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.9",
|
||||
"@hono/node-server": "^1.19.9 || ^2.0.5",
|
||||
"ajv": "^8.17.1",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"content-type": "^1.0.5",
|
||||
@@ -3772,9 +3775,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "7.5.20",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.20.tgz",
|
||||
"integrity": "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==",
|
||||
"version": "7.5.22",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz",
|
||||
"integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/fs-minipass": "^4.0.0",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@huggingface/transformers": "^3.8.1",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"compression": "^1.8.1",
|
||||
"express": "^5.2.1",
|
||||
"fuse.js": "^7.5.0",
|
||||
|
||||
@@ -295,7 +295,9 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
title: 'Geographic Metrics',
|
||||
description:
|
||||
'Get geographic distribution of enrollments — where devices are enrolling from, with performance comparisons. ' +
|
||||
'Shows per-location: session counts, success rates, avg/median/p95 duration, throughput, and outlier detection. ' +
|
||||
'Shows per-location: session counts, success rates, avg/median/p95 duration, throughput, outlier detection, and ' +
|
||||
'avgApiLatencyMs (agent→backend HTTP round-trip, request-weighted — the metric for judging backend-region distance, ' +
|
||||
'e.g. when evaluating a new backend region; apiLatencyVsGlobalPct compares against the global weighted average). ' +
|
||||
(ga ? 'Omit tenantId for cross-tenant view (Global Admin). ' : '') +
|
||||
'Use get_geographic_sessions to drill into a specific location. ' +
|
||||
'days accepts any value 1-365 (e.g. 5, 7, 12, 30, 90).',
|
||||
|
||||
@@ -144,7 +144,9 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
'Use for counting / aggregation to avoid the response cap. Available: sessionId, tenantId, status, ' +
|
||||
'serialNumber, manufacturer, model, deviceName, osBuild, osName, startedAt, completedAt, ' +
|
||||
'durationSeconds, currentPhase, failureReason, eventCount, enrollmentType, isPreProvisioned, ' +
|
||||
'isUserDriven, isHybridJoin, isSelfDeployingProfile, agentVersion, imeAgentVersion, geoCountry, rebootCount.'),
|
||||
'isUserDriven, isHybridJoin, isSelfDeployingProfile, agentVersion, imeAgentVersion, geoCountry, rebootCount, ' +
|
||||
'avgApiLatencyMs, apiRequestCount (agent→backend HTTP round-trip; weight latency by apiRequestCount when aggregating, ' +
|
||||
'e.g. fields=geoCountry,avgApiLatencyMs,apiRequestCount for a per-country latency sweep; null on sessions from agents predating the field).'),
|
||||
deviceProperties: z.record(z.string(), z.string()).optional().describe(
|
||||
'Dynamic device property filters. Keys use "eventType.propertyName" dot notation. ' +
|
||||
'See the device_properties catalog (call get_resource(name="device_properties")) for all available keys and types. ' +
|
||||
|
||||
@@ -45,6 +45,8 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
public const string Tenant = "Tenant";
|
||||
public const string Agent = "Agent";
|
||||
public const string Sla = "SLA";
|
||||
/// <summary>Platform infrastructure alerts relayed from Azure Monitor (ops alert webhook).</summary>
|
||||
public const string Platform = "Platform";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -107,6 +107,14 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
Task UpdateSessionGeoAsync(string tenantId, string sessionId,
|
||||
string? country, string? region, string? city, string? loc);
|
||||
Task UpdateSessionImeAgentVersionAsync(string tenantId, string sessionId, string version);
|
||||
/// <summary>
|
||||
/// Persists the session-wide average agent→backend HTTP latency derived from the
|
||||
/// cumulative counters of the latest <c>agent_metrics_snapshot</c>. Last-write-wins
|
||||
/// (cumulative counters make replays idempotent); fail-soft like the other
|
||||
/// supplementary-field updaters.
|
||||
/// </summary>
|
||||
Task UpdateSessionNetworkLatencyAsync(string tenantId, string sessionId,
|
||||
double avgLatencyMs, int requestCount);
|
||||
|
||||
// --- Server→Agent Actions ---
|
||||
/// <summary>
|
||||
|
||||
@@ -54,6 +54,16 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// <summary>Percentage difference from global avg throughput (positive = faster)</summary>
|
||||
public double ThroughputVsGlobalPct { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Average agent→backend HTTP round-trip (ms) at this location, weighted per session by
|
||||
/// its request count. 0 when no session here carries latency data (pre-feature agents).
|
||||
/// </summary>
|
||||
public double AvgApiLatencyMs { get; set; }
|
||||
/// <summary>Sessions at this location that carry API-latency data</summary>
|
||||
public int ApiLatencySessionCount { get; set; }
|
||||
/// <summary>Percentage difference from global avg API latency (positive = slower/farther)</summary>
|
||||
public double ApiLatencyVsGlobalPct { get; set; }
|
||||
|
||||
public bool IsOutlier { get; set; }
|
||||
/// <summary>"fast", "slow", or null</summary>
|
||||
public string? OutlierDirection { get; set; }
|
||||
@@ -142,6 +152,8 @@ namespace AutopilotMonitor.Shared.Models
|
||||
GeoRegion = s.GeoRegion,
|
||||
GeoCity = s.GeoCity,
|
||||
GeoLoc = s.GeoLoc,
|
||||
AvgApiLatencyMs = s.AvgApiLatencyMs,
|
||||
ApiRequestCount = s.ApiRequestCount,
|
||||
PlatformScriptCount = s.PlatformScriptCount,
|
||||
RemediationScriptCount = s.RemediationScriptCount,
|
||||
PendingActionsJson = s.PendingActionsJson,
|
||||
@@ -173,6 +185,8 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public double AvgMinutesPerApp { get; set; }
|
||||
public double AvgThroughputBytesPerSec { get; set; }
|
||||
public double StdDevDurationMinutes { get; set; }
|
||||
/// <summary>Global request-weighted average agent→backend API latency (ms); 0 = no data yet</summary>
|
||||
public double AvgApiLatencyMs { get; set; }
|
||||
/// <summary>Global weighted average peer caching percentage</summary>
|
||||
public double AvgDoPercentPeerCaching { get; set; }
|
||||
/// <summary>Total peer bytes across all locations with DO data</summary>
|
||||
|
||||
@@ -64,6 +64,34 @@ namespace AutopilotMonitor.Shared.Models
|
||||
WhiteGloveAnchorsIncomplete = 16
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interpretation helpers for <see cref="TimeAttributionFlags"/>. The duration mask lives
|
||||
/// here as a const rather than as a composite enum member on purpose: the wire format
|
||||
/// serializes flag combinations as comma-joined member names (the web splits that string),
|
||||
/// and a composite member would hijack ToString for combinations that happen to equal it.
|
||||
/// </summary>
|
||||
public static class TimeAttributionFlagQuality
|
||||
{
|
||||
/// <summary>
|
||||
/// Flags that make the SEGMENT DURATIONS themselves untrustworthy — dropped anchors,
|
||||
/// underobserved early phases, best-effort WhiteGlove window boundaries. Only these
|
||||
/// exclude a breakdown from fleet segment statistics. The blocking-set flags
|
||||
/// (<see cref="TimeAttributionFlags.BlockingSetUnknown"/> / BlockingSetTruncated) say
|
||||
/// nothing about the measured spans — they only limit per-app blocking evidence, which
|
||||
/// is positive-evidence-based anyway (unknown contributes no intervals). Gating fleet
|
||||
/// stats on ANY flag starved the aggregates in production (2026-07-27: 903/1000
|
||||
/// breakdowns carried BlockingSetUnknown → CleanSessionCount 12 vs 331 excluded).
|
||||
/// </summary>
|
||||
public const TimeAttributionFlags DurationCriticalFlags =
|
||||
TimeAttributionFlags.ClockSkewDropped |
|
||||
TimeAttributionFlags.PartialObservation |
|
||||
TimeAttributionFlags.WhiteGloveAnchorsIncomplete;
|
||||
|
||||
/// <summary>True when the breakdown's segment durations are unfit for fleet statistics.</summary>
|
||||
public static bool ExcludesFromFleetStats(TimeAttributionFlags flags)
|
||||
=> (flags & DurationCriticalFlags) != TimeAttributionFlags.None;
|
||||
}
|
||||
|
||||
/// <summary>One contiguous attributed span inside an observation window.</summary>
|
||||
public class TimeAttributionSpan
|
||||
{
|
||||
|
||||
@@ -257,6 +257,24 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public int EventCount { get; set; }
|
||||
public int? DurationSeconds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Session-wide average HTTP round-trip latency (ms) of the agent's calls to the
|
||||
/// backend API, as measured on the device (includes network RTT + TLS + server
|
||||
/// processing). Projected at ingest from the cumulative counters of the latest
|
||||
/// <c>agent_metrics_snapshot</c> (<c>net_total_latency_ms / net_total_requests</c>).
|
||||
/// Null for V1 sessions and sessions from agents that predate the field.
|
||||
/// Aggregate across sessions weighted by <see cref="ApiRequestCount"/> — e.g. per
|
||||
/// GeoCountry to compare regional backend reachability.
|
||||
/// </summary>
|
||||
public double? AvgApiLatencyMs { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of HTTP requests behind <see cref="AvgApiLatencyMs"/> (the cumulative
|
||||
/// <c>net_total_requests</c> at the latest snapshot). Needed as the weight when
|
||||
/// averaging latency across sessions. Null whenever <see cref="AvgApiLatencyMs"/> is.
|
||||
/// </summary>
|
||||
public int? ApiRequestCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Enrollment type: "v1" (Autopilot Classic/ESP) or "v2" (Windows Device Preparation).
|
||||
/// Defaults to "v1" for sessions that predate this field.
|
||||
|
||||
@@ -110,6 +110,15 @@ const OPS_EVENT_TYPES: Record<string, string[]> = {
|
||||
// feedback_ops_event_types_dual_register.
|
||||
"AgentEmergencyBreak",
|
||||
],
|
||||
Platform: [
|
||||
// Azure Monitor alerts relayed through POST ops/alert-webhook (Common Alert Schema →
|
||||
// AzureMonitorAlertWebhookFunction → RecordAzureMonitorAlertAsync). One event type for ALL
|
||||
// Azure Monitor alert rules — the rule name travels in the message/details. Severity is
|
||||
// mapped Sev0→Critical, Sev1→Error, Sev2→Warning, Sev3/4→Info; both Fired and Resolved
|
||||
// notifications carry the same mapped severity, so a rule here delivers recovery pings too.
|
||||
// Dual-register per memory feedback_ops_event_types_dual_register.
|
||||
"AzureMonitorAlert",
|
||||
],
|
||||
};
|
||||
|
||||
const SEVERITIES = ["Info", "Warning", "Error", "Critical"];
|
||||
@@ -120,6 +129,7 @@ const CATEGORY_COLORS: Record<string, string> = {
|
||||
Security: "text-orange-700 dark:text-orange-300",
|
||||
Tenant: "text-teal-700 dark:text-teal-300",
|
||||
Agent: "text-cyan-700 dark:text-cyan-300",
|
||||
Platform: "text-sky-700 dark:text-sky-300",
|
||||
};
|
||||
|
||||
interface OpsAlertRulesSectionProps {
|
||||
|
||||
@@ -42,9 +42,10 @@ const CATEGORY_STYLES: Record<string, string> = {
|
||||
Security: "bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300",
|
||||
Tenant: "bg-teal-100 text-teal-800 dark:bg-teal-900/40 dark:text-teal-300",
|
||||
Agent: "bg-cyan-100 text-cyan-800 dark:bg-cyan-900/40 dark:text-cyan-300",
|
||||
Platform: "bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-300",
|
||||
};
|
||||
|
||||
const ALL_CATEGORIES = ["Consent", "Maintenance", "Security", "Tenant", "Agent"];
|
||||
const ALL_CATEGORIES = ["Consent", "Maintenance", "Security", "Tenant", "Agent", "Platform"];
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { isChunkLoadError, tryRecoverFromChunkError } from "@/utils/chunkReloadRecovery";
|
||||
|
||||
/**
|
||||
* Route-level error boundary — catches unhandled exceptions within page
|
||||
@@ -17,6 +19,15 @@ export default function Error({
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
// Stale-bundle chunk failure after a deploy: reload once instead of showing the
|
||||
// error card (utils/chunkReloadRecovery.ts — the guard prevents reload loops; a
|
||||
// repeat inside the guard window falls through to the card below).
|
||||
useEffect(() => {
|
||||
if (isChunkLoadError(error)) {
|
||||
tryRecoverFromChunkError("error-boundary");
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl p-8 max-w-md w-full text-center">
|
||||
|
||||
@@ -31,7 +31,8 @@ export interface DeviceJourneyDailyDto {
|
||||
export interface DeviceJourneyTotalsDto {
|
||||
completedJourneys: number;
|
||||
firstTimeRight: number;
|
||||
ftrRatePct: number | null;
|
||||
/** Absent (WhenWritingNull) when no completed journeys — treat null and undefined alike. */
|
||||
ftrRatePct?: number | null;
|
||||
excludedSessions: number;
|
||||
attemptHistogram: DeviceJourneyAttemptBucketDto[];
|
||||
}
|
||||
@@ -53,7 +54,12 @@ export interface DeviceJourneyResponseDto {
|
||||
windowDays: number;
|
||||
totals: DeviceJourneyTotalsDto;
|
||||
daily: DeviceJourneyDailyDto[];
|
||||
repeatDevices: RepeatDeviceDto[] | null;
|
||||
/**
|
||||
* Absent (not null) in the aggregated all-tenants view — the worker serializes with
|
||||
* WhenWritingNull, so the field is omitted entirely. Absent/null = no per-device
|
||||
* drill-down available; [] = tenant scoped but no repeat devices.
|
||||
*/
|
||||
repeatDevices?: RepeatDeviceDto[] | null;
|
||||
}
|
||||
|
||||
/** UI gate for the FTR rate (spec: ≥20 completed journeys). */
|
||||
@@ -197,7 +203,7 @@ export default function DeviceJourneySection({
|
||||
title="Devices whose current journey took 2 or more terminal attempts, newest activity first. The failure reason is from the most recent failed attempt.">
|
||||
Repeat devices
|
||||
</div>
|
||||
{repeatDevices === null ? (
|
||||
{repeatDevices == null ? (
|
||||
<div className="text-sm text-gray-400 py-4">
|
||||
Select a tenant to see per-device detail — the aggregated view has no device drill-down.
|
||||
</div>
|
||||
|
||||
@@ -104,16 +104,19 @@ export default function GatherRulesPage() {
|
||||
// Fetch unrestrictedMode from tenant config (for validation indicators).
|
||||
// The display flag lives in the member-readable feature-flags endpoint so that
|
||||
// Operators/Viewers can load this page without 403'ing on the admin-only full config.
|
||||
// GA-override path keeps using globalConfig.tenant (GA-only endpoint).
|
||||
// The SAME route serves the GA-override path: MemberRead has a global-scope bypass and
|
||||
// the route is RouteParam-scoped, so a GA reads the SELECTED tenant's flag. (The former
|
||||
// override URL /api/global/config/{tenantId} does not exist — the swallowed 404 left the
|
||||
// badge showing the GA's HOME-tenant mode for every foreign tenant.)
|
||||
useEffect(() => {
|
||||
if (!effectiveTenantId) return;
|
||||
// Reset first so a failed/forbidden fetch never carries the previous tenant's mode over.
|
||||
setUnrestrictedMode(false);
|
||||
let stale = false;
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
const url = isGlobalOverride
|
||||
? api.globalConfig.tenant(effectiveTenantId)
|
||||
: api.config.featureFlags(effectiveTenantId);
|
||||
const response = await authenticatedFetch(url, getAccessToken);
|
||||
if (response.ok) {
|
||||
const response = await authenticatedFetch(api.config.featureFlags(effectiveTenantId), getAccessToken);
|
||||
if (!stale && response.ok) {
|
||||
const data = await response.json();
|
||||
setUnrestrictedMode(data.unrestrictedMode ?? false);
|
||||
}
|
||||
@@ -122,7 +125,8 @@ export default function GatherRulesPage() {
|
||||
}
|
||||
};
|
||||
fetchConfig();
|
||||
}, [effectiveTenantId, isGlobalOverride, getAccessToken]);
|
||||
return () => { stale = true; };
|
||||
}, [effectiveTenantId, getAccessToken]);
|
||||
|
||||
const handleToggleRule = async (rule: GatherRule) => {
|
||||
setTogglingRule(rule.ruleId);
|
||||
|
||||
@@ -42,6 +42,9 @@ interface LocationMetric {
|
||||
totalDownloadBytes: number;
|
||||
durationVsGlobalPct: number;
|
||||
throughputVsGlobalPct: number;
|
||||
avgApiLatencyMs: number;
|
||||
apiLatencySessionCount: number;
|
||||
apiLatencyVsGlobalPct: number;
|
||||
isOutlier: boolean;
|
||||
outlierDirection: string | null;
|
||||
// Delivery Optimization
|
||||
@@ -60,6 +63,7 @@ interface GlobalAverages {
|
||||
avgMinutesPerApp: number;
|
||||
avgThroughputBytesPerSec: number;
|
||||
stdDevDurationMinutes: number;
|
||||
avgApiLatencyMs: number;
|
||||
avgDoPercentPeerCaching: number;
|
||||
totalDoBytesFromPeers: number;
|
||||
totalDoBytesFromHttp: number;
|
||||
@@ -76,7 +80,7 @@ interface GeographicMetricsResponse {
|
||||
}
|
||||
|
||||
type GroupBy = "city" | "region" | "country";
|
||||
type SortBy = "sessionCount" | "avgDurationMinutes" | "appLoadScore" | "avgThroughputBytesPerSec" | "avgDoPercentPeerCaching";
|
||||
type SortBy = "sessionCount" | "avgDurationMinutes" | "appLoadScore" | "avgThroughputBytesPerSec" | "avgApiLatencyMs" | "avgDoPercentPeerCaching";
|
||||
type TimeRange = "7d" | "30d" | "90d";
|
||||
|
||||
const durationColor = (value: number, globalAvg: number) => {
|
||||
@@ -96,6 +100,16 @@ const scoreColor = (score: number) => {
|
||||
return "text-red-600";
|
||||
};
|
||||
|
||||
// Absolute buckets, not relative-to-global: latency encodes physical distance to the backend
|
||||
// region, and the decision it supports ("open a closer region?") needs absolute thresholds.
|
||||
const latencyColor = (ms: number) => {
|
||||
if (ms <= 0) return "text-gray-400";
|
||||
if (ms < 250) return "bg-green-100 text-green-800";
|
||||
if (ms < 500) return "bg-yellow-100 text-yellow-800";
|
||||
if (ms < 800) return "bg-orange-100 text-orange-700";
|
||||
return "bg-red-100 text-red-800";
|
||||
};
|
||||
|
||||
const formatThroughput = (bytesPerSec: number) => {
|
||||
if (bytesPerSec <= 0) return "—";
|
||||
if (bytesPerSec >= 1024 * 1024) return `${(bytesPerSec / 1024 / 1024).toFixed(1)} MB/s`;
|
||||
@@ -376,7 +390,7 @@ export default function GeographicPerformancePage() {
|
||||
{geoMetrics && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||
<div className="text-sm font-medium text-blue-800 mb-2">Global Averages (Benchmark)</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 text-sm">
|
||||
<div className="grid grid-cols-2 md:grid-cols-6 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-blue-600 font-medium">Avg Duration:</span>{" "}
|
||||
<span className="text-blue-900">{geoMetrics.globalAverages.avgDurationMinutes} min</span>
|
||||
@@ -401,6 +415,14 @@ export default function GeographicPerformancePage() {
|
||||
: "No data"}
|
||||
</span>
|
||||
</div>
|
||||
<div title="Agent→backend HTTP round-trip, request-weighted across sessions with latency data">
|
||||
<span className="text-blue-600 font-medium">API Latency:</span>{" "}
|
||||
<span className="text-blue-900">
|
||||
{geoMetrics.globalAverages.avgApiLatencyMs > 0
|
||||
? `${Math.round(geoMetrics.globalAverages.avgApiLatencyMs)} ms`
|
||||
: "No data"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -453,6 +475,13 @@ export default function GeographicPerformancePage() {
|
||||
>
|
||||
Throughput <SortIcon col="avgThroughputBytesPerSec" />
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700"
|
||||
onClick={() => handleSort("avgApiLatencyMs")}
|
||||
title="Agent→backend HTTP round-trip measured on the devices — high values indicate network distance to the backend region"
|
||||
>
|
||||
API Latency <SortIcon col="avgApiLatencyMs" />
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700"
|
||||
onClick={() => handleSort("avgDoPercentPeerCaching")}
|
||||
@@ -553,6 +582,18 @@ export default function GeographicPerformancePage() {
|
||||
<td className="px-4 py-3 text-sm text-gray-700">
|
||||
{formatThroughput(loc.avgThroughputBytesPerSec)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{loc.avgApiLatencyMs > 0 ? (
|
||||
<div title={`${loc.apiLatencySessionCount} session(s) with latency data${loc.apiLatencyVsGlobalPct !== 0 ? ` · ${loc.apiLatencyVsGlobalPct > 0 ? "+" : ""}${loc.apiLatencyVsGlobalPct.toFixed(0)}% vs global` : ""}`}>
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${latencyColor(loc.avgApiLatencyMs)}`}>
|
||||
{Math.round(loc.avgApiLatencyMs)} ms
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 ml-1">({loc.apiLatencySessionCount})</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-300" title="No sessions with latency data yet (requires agent ≥ 2.0 with latency reporting)">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{loc.doSessionCount > 0 ? (
|
||||
<div title={
|
||||
|
||||
@@ -32,6 +32,10 @@ interface SessionSummary {
|
||||
failureReason: string | null;
|
||||
durationSeconds: number | null;
|
||||
enrollmentType: string;
|
||||
/** Session-wide average agent→backend HTTP round-trip (ms); absent for pre-feature agents. */
|
||||
avgApiLatencyMs?: number;
|
||||
/** Number of HTTP requests behind avgApiLatencyMs. */
|
||||
apiRequestCount?: number;
|
||||
// Per-session Delivery Optimization aggregate (added by geographic drilldown endpoint)
|
||||
hasDoTelemetry?: boolean;
|
||||
doAppCount?: number;
|
||||
@@ -170,6 +174,14 @@ function LocationSessionsContent() {
|
||||
.map((s) => s.durationSeconds!);
|
||||
return durations.length > 0 ? Math.round(durations.reduce((a, b) => a + b, 0) / durations.length / 60) : 0;
|
||||
})(),
|
||||
// Request-weighted API latency across sessions with latency data (matches region aggregate formula)
|
||||
latencySessions: data.sessions.filter((s) => (s.avgApiLatencyMs ?? 0) > 0 && (s.apiRequestCount ?? 0) > 0).length,
|
||||
avgApiLatency: (() => {
|
||||
const withLatency = data.sessions.filter((s) => (s.avgApiLatencyMs ?? 0) > 0 && (s.apiRequestCount ?? 0) > 0);
|
||||
const totalRequests = withLatency.reduce((a, s) => a + s.apiRequestCount!, 0);
|
||||
const weightedSum = withLatency.reduce((a, s) => a + s.avgApiLatencyMs! * s.apiRequestCount!, 0);
|
||||
return totalRequests > 0 ? Math.round(weightedSum / totalRequests) : 0;
|
||||
})(),
|
||||
// Weighted peer-caching % across sessions with DO telemetry (matches region aggregate formula)
|
||||
doSessions: data.sessions.filter((s) => s.hasDoTelemetry).length,
|
||||
avgDoPercent: (() => {
|
||||
@@ -235,11 +247,20 @@ function LocationSessionsContent() {
|
||||
<main className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
||||
{/* Summary Stats */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
|
||||
<div className="grid grid-cols-2 md:grid-cols-6 gap-4 mb-6">
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-sm font-medium text-gray-500">Sessions</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{stats.total}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4" title="Agent→backend HTTP round-trip, request-weighted across sessions with latency data">
|
||||
<div className="text-sm font-medium text-gray-500">API Latency</div>
|
||||
<div className={`text-2xl font-bold ${stats.avgApiLatency > 0 ? "text-gray-900" : "text-gray-400"}`}>
|
||||
{stats.avgApiLatency > 0 ? `${stats.avgApiLatency} ms` : "—"}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{stats.latencySessions > 0 ? `${stats.latencySessions} session(s) with data` : "requires latency-reporting agent"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-sm font-medium text-gray-500">Succeeded</div>
|
||||
<div className="text-2xl font-bold text-green-600">{stats.succeeded}</div>
|
||||
@@ -311,6 +332,9 @@ function LocationSessionsContent() {
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Duration
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" title="Agent→backend HTTP round-trip measured on the device">
|
||||
API Latency
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
DO Peers
|
||||
</th>
|
||||
@@ -341,6 +365,15 @@ function LocationSessionsContent() {
|
||||
<td className="px-4 py-3 text-sm text-gray-600">
|
||||
{formatDuration(session.durationSeconds)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">
|
||||
{(session.avgApiLatencyMs ?? 0) > 0 ? (
|
||||
<span title={`across ${session.apiRequestCount ?? "?"} requests`}>
|
||||
{Math.round(session.avgApiLatencyMs!)} ms
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-300" title="No latency data (requires latency-reporting agent)">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{session.hasDoTelemetry ? (
|
||||
<div
|
||||
|
||||
@@ -14,6 +14,7 @@ import FeedbackBubble from "../components/FeedbackBubble";
|
||||
import { SidebarProvider } from "../contexts/SidebarContext";
|
||||
import { GlobalSidebar } from "../components/GlobalSidebar";
|
||||
import AppInsightsInit from "../components/AppInsightsInit";
|
||||
import ChunkReloadRecovery from "../components/ChunkReloadRecovery";
|
||||
import { SITE_URL } from "@/utils/config";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
@@ -126,6 +127,7 @@ export default function RootLayout({
|
||||
<TenantProvider>
|
||||
<SidebarProvider>
|
||||
<AppInsightsInit />
|
||||
<ChunkReloadRecovery />
|
||||
<Navbar />
|
||||
<GlobalSidebar>
|
||||
{children}
|
||||
|
||||
+43
-3
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { EnrollmentEvent } from "@/types";
|
||||
import { EnrollmentEvent, Session } from "@/types";
|
||||
import OobeConfigModal from "./OobeConfigModal";
|
||||
import { compareVersions, stripGitHashSuffix } from "@/utils/bootstrapVersion";
|
||||
|
||||
export default function DeviceDetailsCard({ events, latestAgentVersion }: { events: EnrollmentEvent[]; latestAgentVersion?: string | null }) {
|
||||
export default function DeviceDetailsCard({ events, latestAgentVersion, session }: { events: EnrollmentEvent[]; latestAgentVersion?: string | null; session?: Session | null }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [showIpv6, setShowIpv6] = useState<Record<number, boolean>>({});
|
||||
const [showOobeModal, setShowOobeModal] = useState(false);
|
||||
@@ -161,8 +161,15 @@ export default function DeviceDetailsCard({ events, latestAgentVersion }: { even
|
||||
const deviceLocation = getEventData("device_location");
|
||||
const hwSpec = getEventData("hardware_spec");
|
||||
|
||||
// Connectivity: agent→backend measurements. Latency comes from the session row (persisted
|
||||
// at ingest from the cumulative snapshot counters); bandwidth/volume from their events.
|
||||
const bandwidthEstimate = getEventData("network_bandwidth_estimate");
|
||||
const metricsSnapshot = getEventData("agent_metrics_snapshot");
|
||||
const apiLatencyMs = session?.avgApiLatencyMs ?? 0;
|
||||
const hasConnectivity = apiLatencyMs > 0 || bandwidthEstimate || metricsSnapshot;
|
||||
|
||||
const hasData = agentStarted || bootTime || osInfo || networkAdapters || dnsConfig || proxyConfig || networkInterfaceInfo || wifiSignalInfo ||
|
||||
autopilotProfile || aadJoinStatus || imeVersion || bitLockerStatus || secureBootStatus || deviceLocation || hwSpec;
|
||||
autopilotProfile || aadJoinStatus || imeVersion || bitLockerStatus || secureBootStatus || deviceLocation || hwSpec || hasConnectivity;
|
||||
|
||||
if (!hasData) return null;
|
||||
|
||||
@@ -293,6 +300,32 @@ export default function DeviceDetailsCard({ events, latestAgentVersion }: { even
|
||||
</DetailSection>
|
||||
)}
|
||||
|
||||
{/* Connectivity: agent→backend measurements collected during the enrollment */}
|
||||
{hasConnectivity && (
|
||||
<DetailSection title="Connectivity">
|
||||
{apiLatencyMs > 0 && (
|
||||
<DetailRow
|
||||
label="API Latency (avg)"
|
||||
value={`${Math.round(apiLatencyMs)} ms${session?.apiRequestCount ? ` (${session.apiRequestCount} requests)` : ""}`}
|
||||
/>
|
||||
)}
|
||||
{bandwidthEstimate?.estimatedWanMbps !== undefined && (
|
||||
<DetailRow
|
||||
label="Est. Internet Bandwidth"
|
||||
value={`~${bandwidthEstimate.estimatedWanMbps} Mbit/s${bandwidthEstimate.confidence ? ` (${bandwidthEstimate.confidence} confidence)` : ""}`}
|
||||
/>
|
||||
)}
|
||||
{bandwidthEstimate?.doThrottleConfigured === true && (
|
||||
<DetailRow label="DO Throttle Policy" value="Configured (measured rate reflects the throttle)" />
|
||||
)}
|
||||
{metricsSnapshot?.net_total_bytes_up !== undefined && (
|
||||
<DetailRow label="Telemetry Uploaded" value={formatBytesCompact(Number(metricsSnapshot.net_total_bytes_up))} />
|
||||
)}
|
||||
{/* Outbound IP is deliberately NOT rendered here — the outbound_ip trace event
|
||||
stays available for analysis (timeline, MCP). */}
|
||||
</DetailSection>
|
||||
)}
|
||||
|
||||
{/* Security */}
|
||||
{(bitLockerStatus || secureBootStatus || tpmStatus) && (
|
||||
<DetailSection title="Security">
|
||||
@@ -543,6 +576,13 @@ function DetailSection({ title, children }: { title: string; children: React.Rea
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytesCompact(bytes: number): string {
|
||||
if (!bytes || bytes <= 0 || isNaN(bytes)) return "—";
|
||||
if (bytes >= 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;
|
||||
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
}
|
||||
|
||||
function DetailRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex justify-between text-xs py-0.5">
|
||||
|
||||
@@ -573,7 +573,7 @@ export default function SessionDetailPage() {
|
||||
)}
|
||||
|
||||
{/* Device Details Card (from enrollment tracker events) */}
|
||||
{!isGatherRulesSession && <div id="section-device-details"><DeviceDetailsCard events={events} latestAgentVersion={latestAgentVersion} /></div>}
|
||||
{!isGatherRulesSession && <div id="section-device-details"><DeviceDetailsCard events={events} latestAgentVersion={latestAgentVersion} session={session} /></div>}
|
||||
|
||||
{/* Phase Timeline */}
|
||||
{!isGatherRulesSession && session && (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useTenant } from '../../contexts/TenantContext';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useNotifications } from '../../contexts/NotificationContext';
|
||||
@@ -121,8 +121,15 @@ export default function SlaPage() {
|
||||
const scope = useGlobalAdminScope();
|
||||
const { isGlobalOverride, effectiveTenantId, selectedTenantId, tenants } = scope;
|
||||
|
||||
// Latest-wins guard: a tenant switch starts a new fetch while an older one may still be in
|
||||
// flight; only the most recently started request may write state (raw authenticatedFetch has
|
||||
// no such guard, unlike useAuthenticatedFetch).
|
||||
const fetchSeqRef = useRef(0);
|
||||
|
||||
const fetchMetrics = useCallback(async (showRefreshing = false) => {
|
||||
if (!effectiveTenantId) return;
|
||||
const seq = ++fetchSeqRef.current;
|
||||
const isCurrent = () => fetchSeqRef.current === seq;
|
||||
try {
|
||||
if (showRefreshing) setRefreshing(true);
|
||||
else setLoading(true);
|
||||
@@ -135,12 +142,16 @@ export default function SlaPage() {
|
||||
: api.metrics.sla(effectiveTenantId, months, useFresh);
|
||||
|
||||
const response = await authenticatedFetch(url, getAccessToken);
|
||||
if (!isCurrent()) return;
|
||||
if (!response.ok) {
|
||||
addNotification('error', 'Error', `Failed to load SLA metrics: ${response.statusText}`, 'sla-fetch-error');
|
||||
return;
|
||||
}
|
||||
setMetrics(await response.json());
|
||||
const data = await response.json();
|
||||
if (!isCurrent()) return;
|
||||
setMetrics(data);
|
||||
} catch (err) {
|
||||
if (!isCurrent()) return;
|
||||
if (err instanceof TokenExpiredError) {
|
||||
addNotification('error', 'Session Expired', err.message, 'session-expired-error');
|
||||
} else {
|
||||
@@ -152,8 +163,11 @@ export default function SlaPage() {
|
||||
error: err instanceof Error ? err.message : 'unknown',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
// A superseded request must not clear the newer request's loading/refreshing state.
|
||||
if (isCurrent()) {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}
|
||||
}, [effectiveTenantId, isGlobalOverride, months, getAccessToken, addNotification]);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useNotifications } from '../../contexts/NotificationContext';
|
||||
@@ -99,8 +99,15 @@ export default function UsageMetricsPage() {
|
||||
const scope = useGlobalAdminScope();
|
||||
const { isGlobalOverride, effectiveTenantId, selectedTenantId, tenants } = scope;
|
||||
|
||||
// Latest-wins guard: a tenant switch starts a new fetch while an older one may still be in
|
||||
// flight; only the most recently started request may write state (raw authenticatedFetch has
|
||||
// no such guard, unlike useAuthenticatedFetch).
|
||||
const fetchSeqRef = useRef(0);
|
||||
|
||||
const fetchMetrics = useCallback(async (showRefreshing = false) => {
|
||||
if (!effectiveTenantId) return;
|
||||
const seq = ++fetchSeqRef.current;
|
||||
const isCurrent = () => fetchSeqRef.current === seq;
|
||||
try {
|
||||
if (showRefreshing) {
|
||||
setRefreshing(true);
|
||||
@@ -114,6 +121,7 @@ export default function UsageMetricsPage() {
|
||||
: api.metrics.usage(effectiveTenantId);
|
||||
|
||||
const response = await authenticatedFetch(url, getAccessToken);
|
||||
if (!isCurrent()) return;
|
||||
|
||||
if (!response.ok) {
|
||||
addNotification('error', 'Backend Error', `Failed to load usage metrics: ${response.statusText}`, 'usage-metrics-fetch-error');
|
||||
@@ -121,8 +129,10 @@ export default function UsageMetricsPage() {
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!isCurrent()) return;
|
||||
setMetrics(data);
|
||||
} catch (err) {
|
||||
if (!isCurrent()) return;
|
||||
if (err instanceof TokenExpiredError) {
|
||||
addNotification('error', 'Session Expired', err.message, 'session-expired-error');
|
||||
} else {
|
||||
@@ -130,8 +140,11 @@ export default function UsageMetricsPage() {
|
||||
addNotification('error', 'Backend Not Reachable', 'Unable to load usage metrics. Please check your connection.', 'usage-metrics-fetch-error');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
// A superseded request must not clear the newer request's loading/refreshing state.
|
||||
if (isCurrent()) {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}
|
||||
}, [effectiveTenantId, isGlobalOverride, getAccessToken, addNotification]);
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { isChunkLoadError, tryRecoverFromChunkError } from "@/utils/chunkReloadRecovery";
|
||||
|
||||
/**
|
||||
* Global listener pair for stale-bundle chunk failures (see utils/chunkReloadRecovery.ts).
|
||||
* Two paths surface them outside any React error boundary:
|
||||
* - a rejected dynamic import that nothing awaits → unhandledrejection
|
||||
* - the chunk <script>/<link> tag itself failing to load → a capture-phase error
|
||||
* event on window whose target is the tag (these don't bubble and carry no Error)
|
||||
* Renders nothing; mounted once in the root layout.
|
||||
*/
|
||||
export default function ChunkReloadRecovery() {
|
||||
useEffect(() => {
|
||||
const onRejection = (e: PromiseRejectionEvent) => {
|
||||
if (isChunkLoadError(e.reason) && tryRecoverFromChunkError("unhandledrejection")) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const onError = (e: ErrorEvent | Event) => {
|
||||
// Resource-load failure: target is the failed tag, no error object attached.
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target && (target.tagName === "SCRIPT" || target.tagName === "LINK")) {
|
||||
const url =
|
||||
(target as HTMLScriptElement).src || (target as HTMLLinkElement).href || "";
|
||||
// Only our own immutable build assets — a blocked third-party script must not reload the app.
|
||||
if (url.includes("/_next/") && tryRecoverFromChunkError("resource-error")) return;
|
||||
}
|
||||
const error = (e as ErrorEvent).error ?? (e as ErrorEvent).message;
|
||||
if (isChunkLoadError(error)) {
|
||||
tryRecoverFromChunkError("window-error");
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("unhandledrejection", onRejection);
|
||||
window.addEventListener("error", onError, true);
|
||||
return () => {
|
||||
window.removeEventListener("unhandledrejection", onRejection);
|
||||
window.removeEventListener("error", onError, true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -312,6 +312,15 @@ export function GlobalSidebar({ children }: { children: ReactNode }) {
|
||||
: "text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200";
|
||||
};
|
||||
|
||||
// Every nav Link below sets prefetch={false} on purpose. The sidebar renders the whole
|
||||
// navigation on every page, so the App Router's default viewport prefetch fires one RSC
|
||||
// request per visible link against the SWA-hosted Next.js runtime on each page load
|
||||
// (measured: ~10k requests / 14 days). That host speaks HTTP/1.1, so those prefetches
|
||||
// occupy the browser's 6 connections to the SAME origin that serves the document and the
|
||||
// route chunks — when the runtime queues them (p95 ~0.9s but a tail reaching 150s+), the
|
||||
// portal freezes until the queue drains. Every route here is a client component behind
|
||||
// ProtectedRoute that fetches its own data after mount, so the prefetched RSC payload
|
||||
// buys nothing. See docs/web/portal-navigation-prefetch.md.
|
||||
// --- Render a global nav link ---
|
||||
const renderGlobalItem = (item: NavItem, isGlobal = false) => {
|
||||
const active = isNavActive(item.href);
|
||||
@@ -322,6 +331,7 @@ export function GlobalSidebar({ children }: { children: ReactNode }) {
|
||||
<li key={item.id}>
|
||||
<Link
|
||||
href={item.href}
|
||||
prefetch={false}
|
||||
onClick={() => setMobileDrawerOpen(false)}
|
||||
className={`${base} px-2 py-2 justify-center relative group`}
|
||||
title={item.label}
|
||||
@@ -339,7 +349,7 @@ export function GlobalSidebar({ children }: { children: ReactNode }) {
|
||||
|
||||
return (
|
||||
<li key={item.id}>
|
||||
<Link href={item.href} onClick={() => setMobileDrawerOpen(false)} className={`${base} px-3 py-1.5`}>
|
||||
<Link href={item.href} prefetch={false} onClick={() => setMobileDrawerOpen(false)} className={`${base} px-3 py-1.5`}>
|
||||
{renderIcon(item.icon, "w-4 h-4")}
|
||||
<span className="truncate">{item.label}</span>
|
||||
</Link>
|
||||
@@ -362,7 +372,7 @@ export function GlobalSidebar({ children }: { children: ReactNode }) {
|
||||
if (pageSectionsMode === "route" && item.href) {
|
||||
return (
|
||||
<li key={item.id}>
|
||||
<Link href={item.href} onClick={() => setMobileDrawerOpen(false)} className={`${base} px-2 py-2 justify-center relative group`} title={item.label}>
|
||||
<Link href={item.href} prefetch={false} onClick={() => setMobileDrawerOpen(false)} className={`${base} px-2 py-2 justify-center relative group`} title={item.label}>
|
||||
{inner}
|
||||
<span className="absolute left-full ml-2 px-2 py-1 rounded bg-gray-900 text-white text-xs whitespace-nowrap opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity z-50 dark:bg-gray-700">{item.label}</span>
|
||||
</Link>
|
||||
@@ -384,7 +394,7 @@ export function GlobalSidebar({ children }: { children: ReactNode }) {
|
||||
if (pageSectionsMode === "route" && item.href) {
|
||||
return (
|
||||
<li key={item.id}>
|
||||
<Link href={item.href} onClick={() => setMobileDrawerOpen(false)} className={`${base} px-3 py-1.5`}>
|
||||
<Link href={item.href} prefetch={false} onClick={() => setMobileDrawerOpen(false)} className={`${base} px-3 py-1.5`}>
|
||||
{renderIcon(item.icon, "w-4 h-4")}
|
||||
<span className="truncate">{item.label}</span>
|
||||
</Link>
|
||||
@@ -502,6 +512,7 @@ export function GlobalSidebar({ children }: { children: ReactNode }) {
|
||||
<li key={expandItem.id}>
|
||||
<Link
|
||||
href={firstHref}
|
||||
prefetch={false}
|
||||
onClick={() => setMobileDrawerOpen(false)}
|
||||
className={`flex items-center justify-center px-2 py-2 rounded-md text-sm transition-colors relative group ${
|
||||
itemHasActive
|
||||
@@ -567,6 +578,7 @@ export function GlobalSidebar({ children }: { children: ReactNode }) {
|
||||
<li key={sub.id}>
|
||||
<Link
|
||||
href={sub.href}
|
||||
prefetch={false}
|
||||
onClick={() => setMobileDrawerOpen(false)}
|
||||
className={`block pl-10 pr-3 py-1 rounded-md text-[13px] transition-colors ${
|
||||
subActive
|
||||
@@ -644,6 +656,7 @@ export function GlobalSidebar({ children }: { children: ReactNode }) {
|
||||
<li key={group.name}>
|
||||
<Link
|
||||
href={firstHref ?? "#"}
|
||||
prefetch={false}
|
||||
onClick={() => setMobileDrawerOpen(false)}
|
||||
className={`flex items-center justify-center px-2 py-2 rounded-md text-sm transition-colors relative group ${
|
||||
groupHasActive
|
||||
@@ -701,6 +714,7 @@ export function GlobalSidebar({ children }: { children: ReactNode }) {
|
||||
<li key={item.id}>
|
||||
<Link
|
||||
href={item.href}
|
||||
prefetch={false}
|
||||
onClick={() => setMobileDrawerOpen(false)}
|
||||
className={`block pl-10 pr-3 py-1 rounded-md text-[13px] transition-colors ${
|
||||
active
|
||||
|
||||
@@ -159,7 +159,7 @@ export default function Navbar() {
|
||||
<div className="px-3">
|
||||
<div className="flex justify-between h-14">
|
||||
<div className="flex items-center">
|
||||
<Link href="/progress" className="flex items-center space-x-2.5">
|
||||
<Link href="/progress" prefetch={false} className="flex items-center space-x-2.5">
|
||||
<svg className="w-7 h-7" viewBox="0 0 209 191" fill="none">
|
||||
<path d="M0 180.201L208.401 190.502L188.157 76.2438L5.48363e-06 0L0 180.201Z" fill="#33B161" />
|
||||
</svg>
|
||||
@@ -215,7 +215,7 @@ export default function Navbar() {
|
||||
<div className="flex justify-between h-14">
|
||||
{/* Logo and Title */}
|
||||
<div className="flex items-center">
|
||||
<Link href="/" className="flex items-center space-x-2.5">
|
||||
<Link href="/" prefetch={false} className="flex items-center space-x-2.5">
|
||||
<svg className="w-7 h-7" viewBox="0 0 209 191" fill="none">
|
||||
<path d="M0 180.201L208.401 190.502L188.157 76.2438L5.48363e-06 0L0 180.201Z" fill="#33B161" />
|
||||
</svg>
|
||||
@@ -326,6 +326,7 @@ export default function Navbar() {
|
||||
{tn.href && (
|
||||
<Link
|
||||
href={tn.href}
|
||||
prefetch={false}
|
||||
onClick={(e) => { e.stopPropagation(); }}
|
||||
className="text-[10px] text-blue-600 hover:text-blue-800 font-medium underline"
|
||||
>
|
||||
@@ -384,6 +385,7 @@ export default function Navbar() {
|
||||
{notification.href && (
|
||||
<Link
|
||||
href={notification.href}
|
||||
prefetch={false}
|
||||
onClick={(e) => { e.stopPropagation(); markAsRead(notification.id); }}
|
||||
className="text-[10px] text-blue-600 hover:text-blue-800 font-medium underline"
|
||||
>
|
||||
@@ -518,6 +520,7 @@ export default function Navbar() {
|
||||
|
||||
<Link
|
||||
href="/privacy"
|
||||
prefetch={false}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
onClick={() => setShowHelp(false)}
|
||||
>
|
||||
@@ -529,6 +532,7 @@ export default function Navbar() {
|
||||
|
||||
<Link
|
||||
href="/terms"
|
||||
prefetch={false}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
onClick={() => setShowHelp(false)}
|
||||
>
|
||||
@@ -706,11 +710,11 @@ export default function Navbar() {
|
||||
<span>Service Announcements</span>
|
||||
</a>
|
||||
<div className="border-t border-gray-100 dark:border-gray-700 my-1"></div>
|
||||
<Link href="/privacy" className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors" onClick={() => { setShowOverflow(false); setOverflowSubmenu(null); }}>
|
||||
<Link href="/privacy" prefetch={false} className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors" onClick={() => { setShowOverflow(false); setOverflowSubmenu(null); }}>
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>
|
||||
<span>Privacy Policy</span>
|
||||
</Link>
|
||||
<Link href="/terms" className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors" onClick={() => { setShowOverflow(false); setOverflowSubmenu(null); }}>
|
||||
<Link href="/terms" prefetch={false} className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors" onClick={() => { setShowOverflow(false); setOverflowSubmenu(null); }}>
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
|
||||
<span>Terms of Use</span>
|
||||
</Link>
|
||||
|
||||
@@ -50,6 +50,17 @@ export function useAuthenticatedFetch<T = unknown>(
|
||||
const optionsRef = useRef(options);
|
||||
optionsRef.current = options;
|
||||
|
||||
// Latest-wins guard: when execute() is called again while a previous request is still in
|
||||
// flight (e.g. the tenant-scope selector seeds a persisted foreign tenant right after mount,
|
||||
// firing an own-tenant fetch immediately followed by the override fetch), only the most
|
||||
// recently started request may write data/error/loading. Without this the LAST-RESOLVED
|
||||
// response wins and a stale tenant's data can overwrite the current selection's result.
|
||||
// The stale call still returns its parsed result to its direct awaiter.
|
||||
const requestSeqRef = useRef(0);
|
||||
// The loading spinner is owned by the last-started NON-silent request (a later silent
|
||||
// background refresh must not leave a superseded visible request's spinner stuck on).
|
||||
const loadingSeqRef = useRef(0);
|
||||
|
||||
const clearError = useCallback(() => setError(null), []);
|
||||
|
||||
const execute = useCallback(
|
||||
@@ -58,8 +69,11 @@ export function useAuthenticatedFetch<T = unknown>(
|
||||
init?: RequestInit,
|
||||
executeOptions?: ExecuteOptions<T>,
|
||||
): Promise<T | null> => {
|
||||
const seq = ++requestSeqRef.current;
|
||||
const isCurrent = () => requestSeqRef.current === seq;
|
||||
try {
|
||||
if (!executeOptions?.silent) {
|
||||
loadingSeqRef.current = seq;
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
@@ -86,9 +100,16 @@ export function useAuthenticatedFetch<T = unknown>(
|
||||
const result = executeOptions?.transform
|
||||
? executeOptions.transform(json)
|
||||
: (json as T);
|
||||
setData(result);
|
||||
if (isCurrent()) {
|
||||
setData(result);
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
// A superseded request's failure is irrelevant to the current view — the newer
|
||||
// request reports its own outcome. Swallow it entirely (no state, no callbacks).
|
||||
if (!isCurrent()) {
|
||||
return null;
|
||||
}
|
||||
const opts = optionsRef.current;
|
||||
if (err instanceof TokenExpiredError) {
|
||||
if (opts?.onTokenExpired) {
|
||||
@@ -112,7 +133,9 @@ export function useAuthenticatedFetch<T = unknown>(
|
||||
setError(message);
|
||||
return null;
|
||||
} finally {
|
||||
if (!executeOptions?.silent) {
|
||||
// Only the spinner's owner may clear it — a superseded request finishing early
|
||||
// must not hide the loading state of the one still in flight.
|
||||
if (!executeOptions?.silent && loadingSeqRef.current === seq) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTenant } from "@/contexts/TenantContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useAdminMode } from "@/hooks/useAdminMode";
|
||||
@@ -86,11 +86,13 @@ export function useGlobalAdminScope(): GlobalAdminScope {
|
||||
|
||||
// GA/Reader: seed the selection from the tab-persisted choice, else the user's own tenant (never empty
|
||||
// in this variant). A persisted aggregated "" resolves locally to the own tenant without clearing storage.
|
||||
useEffect(() => {
|
||||
if (isDelegatedScope || !tenantId || selectedTenantId) return;
|
||||
// Render-time (like the delegated seed below), NOT an effect: an effect-time seed exposes one commit
|
||||
// where effectiveTenantId is the caller's own tenant, so pages fire an own-tenant fetch that races the
|
||||
// override fetch — last-resolved wins and the wrong tenant's data can stick until a manual refresh.
|
||||
if (!isDelegatedScope && tenantId && !selectedTenantId) {
|
||||
const stored = readTenantScope();
|
||||
setSelectedRaw(stored ? stored : tenantId);
|
||||
}, [isDelegatedScope, tenantId, selectedTenantId]);
|
||||
}
|
||||
|
||||
// Delegated: seed from the persisted managed tenant (if still managed) or the first managed tenant once
|
||||
// the scoped list arrives; re-default if the selection falls outside the managed set. Render-time (converges).
|
||||
|
||||
@@ -151,7 +151,9 @@ export const api = {
|
||||
// ── Global Config (global admin) ──────────────────────────────────────────
|
||||
globalConfig: {
|
||||
get: () => `${API_BASE_URL}/api/global/config`,
|
||||
tenant: (tenantId: string) => `${API_BASE_URL}/api/global/config/${tenantId}`,
|
||||
// NOTE: there is deliberately no per-tenant variant — /api/global/config/{tenantId} does
|
||||
// not exist in the backend. A tenant's member-readable flags (incl. unrestrictedMode)
|
||||
// come from config.featureFlags, which a GA may call cross-tenant (MemberRead bypass).
|
||||
},
|
||||
|
||||
// ── Tenants ───────────────────────────────────────────────────────────────
|
||||
|
||||
+64
-52
@@ -17,21 +17,21 @@
|
||||
"dagre": "^0.8.5",
|
||||
"fflate": "^0.8.3",
|
||||
"leaflet": "^1.9.4",
|
||||
"next": "^15.5.21",
|
||||
"next": "^15.5.22",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"recharts": "^2.15.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@next/bundle-analyzer": "^16.2.11",
|
||||
"@next/bundle-analyzer": "^16.2.12",
|
||||
"@types/dagre": "^0.7.54",
|
||||
"@types/node": "^22.20.1",
|
||||
"@types/react": "^18.2.48",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"autoprefixer": "^10.5.4",
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-config-next": "^15.5.21",
|
||||
"eslint-config-next": "^15.5.22",
|
||||
"postcss": "^8.5.12",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.3",
|
||||
@@ -1074,9 +1074,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/bundle-analyzer": {
|
||||
"version": "16.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@next/bundle-analyzer/-/bundle-analyzer-16.2.11.tgz",
|
||||
"integrity": "sha512-C5228wy1RC0g7uOSvmQhrZXCuEeLIPureKkqramAkySmqY2Y0yw6K+TyAxXXvtrYe4uehnZs3YMFfJcorBRwpg==",
|
||||
"version": "16.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/bundle-analyzer/-/bundle-analyzer-16.2.12.tgz",
|
||||
"integrity": "sha512-0dYhmCYsTMFYUFaoEUx+VKw/oYP6b3XiGgq47EujXTE2UGgwVWAaur2tbko2pxfUka3WRZ9YWxa3PlSv3luWNQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1084,15 +1084,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "15.5.21",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.21.tgz",
|
||||
"integrity": "sha512-hjJI/GfrjWHgNguRIBzItjRRu0m3Nrz17GhxsjuHfjIvg9hyg3239REd2dpI+bpMTFuVrVprHzEQ19m++cDtbw==",
|
||||
"version": "15.5.22",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.22.tgz",
|
||||
"integrity": "sha512-O5BlKb3KtsHkvO0gjjV66PuJnAgCtIEIzwkt50HRAHsQkU1t77eksIXSZV84/WMtZJjWrnDUPKHVRi0D62nSAA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/eslint-plugin-next": {
|
||||
"version": "15.5.21",
|
||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.5.21.tgz",
|
||||
"integrity": "sha512-5MoR9vO3dE1lU3LixogB54xrn7OhMGempTqk1q3WlvbKrT6cNt5mV2mMkX5sghAk0vZF6Nz1HxoTb9YmZv8wRg==",
|
||||
"version": "15.5.22",
|
||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.5.22.tgz",
|
||||
"integrity": "sha512-SB8PGBmpAiVmOtMOIfObnI2VA3MN3jUGg0iYTh4MO2efxQIV/IL0U8iYOok/8DldCEKARyCOJJix+Vvgzaic/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1100,9 +1100,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "15.5.21",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.21.tgz",
|
||||
"integrity": "sha512-ZfjqPEdi6TRC/fWx7UDbwb1fbVgyh2uD5tVTRKIDZDlYM+UNuE/LafDG2fwuAoZilADpABh46OY/F5qf9JjqLQ==",
|
||||
"version": "15.5.22",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.22.tgz",
|
||||
"integrity": "sha512-/VISwtffSg8+fVvBbXdglsvruCsdbBC4dG25iU6xascKVqfQKsj/OtjGnOEkIS7pX5GB9e9/r5QprpicsGL3gw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1116,9 +1116,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "15.5.21",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.21.tgz",
|
||||
"integrity": "sha512-TlCf1NpxgQLzTrexuev75xwmNCJMd1/qkJpTVP1GRRcih93hlIBn1P72hkh8T0gnRFr6BmWksQtbyG3jT6jnww==",
|
||||
"version": "15.5.22",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.22.tgz",
|
||||
"integrity": "sha512-NiA9ve8hbiuhG/Q17a2mZDRVxMTtg3rTOgjLnDaLlE+AEPAQlkkuKrfePEbeOrgYmX0U2KGX4EVEn09hXU5GlQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1132,12 +1132,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "15.5.21",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.21.tgz",
|
||||
"integrity": "sha512-LXRsq1p+HvHSi7ygwNcSEEcK0zuo5jS75ZlqFHtOH+LF7qntXAJVJxah+1Pi/GyBm7EpkwU7m4EgbvIKrMqm9A==",
|
||||
"version": "15.5.22",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.22.tgz",
|
||||
"integrity": "sha512-vAPa9vltW+UW/KWtjXeSUFgV3wb1x9d/BeyC6WFI6eBpL0D2f70oGwtOp6193mNW3qusrpgBzMQferPf+Zh8Dw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1148,12 +1151,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "15.5.21",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.21.tgz",
|
||||
"integrity": "sha512-hyGixhFxpDKjqoev6l4KlcRBlt9AXWrGhDZwmwg49sMJM5tnKQPSi+SEj9+e5n+l/bthRGZUdh59GKIs6lQPRw==",
|
||||
"version": "15.5.22",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.22.tgz",
|
||||
"integrity": "sha512-iknK80pWlNDnkdSr13bd8mMuG3Z2oTxODwsZHvuMY7caMk77+rBLdHVWsy8v2EVa3ZojJ/+wJX5fnq8va6Gv8A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1164,12 +1170,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "15.5.21",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.21.tgz",
|
||||
"integrity": "sha512-qfE+YfOba6S2+13e8qn1/UozDVNZ2clBlrs8UtDoax4s8ediu6sq93z66OEHUYlb69Tffh5JTNkgtsAKiSuugg==",
|
||||
"version": "15.5.22",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.22.tgz",
|
||||
"integrity": "sha512-penuEdkwU2OOAiS+n4LE8T/VIoCfAI01QcLZTJ2xc3+l4Q22L/DzURocmI2LU1b+8BMQoLAP1Sze3uYAZT05Bg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1180,12 +1189,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "15.5.21",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.21.tgz",
|
||||
"integrity": "sha512-BXLGG+EvIwp/Rrgl6HY8sqvD6BOUOIRz8/naDbeLNX7mlA5H2XRcL6MW/0IGnJISfj5BA9gNhFyJj5yOoiIDJQ==",
|
||||
"version": "15.5.22",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.22.tgz",
|
||||
"integrity": "sha512-ZM0BKJm3FZ+guG6WT6PcyOLtp6paZ5tngcJC/uUKvLW4Y0TQnnVi1+UGdo8Q6Yxp5gaS82pmC1rD/oFlhkWB3g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1196,9 +1208,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "15.5.21",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.21.tgz",
|
||||
"integrity": "sha512-tNGNOlT0Wn7E4IMsSnufjXN/l2L2/AGdLLpa2vzS89SYCBuihgLn3ngLsIrvndAnWo9nAkus+4gZHTI/Ijx9HA==",
|
||||
"version": "15.5.22",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.22.tgz",
|
||||
"integrity": "sha512-rY/YaumrZaS0//94BnHLF5VSRp0GFUO4GvXNuoCBb0cGSci96yO+p1JaNL2aq9YZAYv9cuZRziV02x5IQH/wjg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3909,13 +3921,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-config-next": {
|
||||
"version": "15.5.21",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.5.21.tgz",
|
||||
"integrity": "sha512-W8m8iFZis5SLqrHVXFv3IrRUpUu95VCHcXRMrMWtxpjV7JHfRB847qL2HJS5wIdLER+k17LWGnihBDRkFBRK8A==",
|
||||
"version": "15.5.22",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.5.22.tgz",
|
||||
"integrity": "sha512-HhZsB3dvBsYwQpnymZm/Ps9NVaSYiSzDSXkR02CjyOcQutCuea3cF7NxE8i8Drs2+Dgw95WUzK2CMBIHd5YbMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/eslint-plugin-next": "15.5.21",
|
||||
"@next/eslint-plugin-next": "15.5.22",
|
||||
"@rushstack/eslint-patch": "^1.10.3",
|
||||
"@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||
"@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||
@@ -5932,12 +5944,12 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "15.5.21",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-15.5.21.tgz",
|
||||
"integrity": "sha512-/TsdBtkWLhkl+NVL3Uqws2UphNd6IPzOtzSk1fHaf+0P7GQKLZDUytyhns/Ykbzdy9+YRjwG7ONvrHaaTDdFqQ==",
|
||||
"version": "15.5.22",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-15.5.22.tgz",
|
||||
"integrity": "sha512-mrtal1sRxO4YrlDS98sDuIvGZivKbFix8w7oAL9ZynfOgc3cADQOQgvwtMooc18Qr8bKzvQAcHwHZ0mbJ7zcfQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/env": "15.5.21",
|
||||
"@next/env": "15.5.22",
|
||||
"@swc/helpers": "0.5.15",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
"postcss": "8.4.31",
|
||||
@@ -5950,14 +5962,14 @@
|
||||
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-darwin-arm64": "15.5.21",
|
||||
"@next/swc-darwin-x64": "15.5.21",
|
||||
"@next/swc-linux-arm64-gnu": "15.5.21",
|
||||
"@next/swc-linux-arm64-musl": "15.5.21",
|
||||
"@next/swc-linux-x64-gnu": "15.5.21",
|
||||
"@next/swc-linux-x64-musl": "15.5.21",
|
||||
"@next/swc-win32-arm64-msvc": "15.5.21",
|
||||
"@next/swc-win32-x64-msvc": "15.5.21",
|
||||
"@next/swc-darwin-arm64": "15.5.22",
|
||||
"@next/swc-darwin-x64": "15.5.22",
|
||||
"@next/swc-linux-arm64-gnu": "15.5.22",
|
||||
"@next/swc-linux-arm64-musl": "15.5.22",
|
||||
"@next/swc-linux-x64-gnu": "15.5.22",
|
||||
"@next/swc-linux-x64-musl": "15.5.22",
|
||||
"@next/swc-win32-arm64-msvc": "15.5.22",
|
||||
"@next/swc-win32-x64-msvc": "15.5.22",
|
||||
"sharp": "^0.34.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -5984,9 +5996,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/next/node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "15.5.21",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.21.tgz",
|
||||
"integrity": "sha512-DmIdWmC9p4rdNIiQqo8ap0+Cnj6kKtTZnuSCxoYydSc8sgpDgAg9wFhxplunak9imLV0pTvc5WVCOHwm5eHLtQ==",
|
||||
"version": "15.5.22",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.22.tgz",
|
||||
"integrity": "sha512-s5IA4cyrbR2XK/5NWcu5dp8CfPBiKME+UhvNperia7uQybEgg5+LIhGMiY37WQE4rcI4owsDcU4IVUjLoTuDkA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
||||
@@ -24,21 +24,21 @@
|
||||
"dagre": "^0.8.5",
|
||||
"fflate": "^0.8.3",
|
||||
"leaflet": "^1.9.4",
|
||||
"next": "^15.5.21",
|
||||
"next": "^15.5.22",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"recharts": "^2.15.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@next/bundle-analyzer": "^16.2.11",
|
||||
"@next/bundle-analyzer": "^16.2.12",
|
||||
"@types/dagre": "^0.7.54",
|
||||
"@types/node": "^22.20.1",
|
||||
"@types/react": "^18.2.48",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"autoprefixer": "^10.5.4",
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-config-next": "^15.5.21",
|
||||
"eslint-config-next": "^15.5.22",
|
||||
"postcss": "^8.5.12",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.3",
|
||||
|
||||
@@ -43,6 +43,10 @@ export interface Session {
|
||||
geoCountry?: string;
|
||||
geoRegion?: string;
|
||||
geoCity?: string;
|
||||
/** Session-wide average agent→backend HTTP round-trip (ms), measured on the device. Absent for sessions from agents that predate the field. */
|
||||
avgApiLatencyMs?: number;
|
||||
/** Number of HTTP requests behind avgApiLatencyMs — the weight when averaging across sessions. */
|
||||
apiRequestCount?: number;
|
||||
/**
|
||||
* Compact JSON snapshot of "last known session state" written by the maintenance
|
||||
* 5h-timeout sweep when a session graduates to terminal Failed (Hybrid User-Driven
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
isChunkLoadError,
|
||||
shouldAutoReload,
|
||||
RELOAD_WINDOW_MS,
|
||||
} from "../chunkReloadRecovery";
|
||||
|
||||
function namedError(name: string, message: string): Error {
|
||||
const err = new Error(message);
|
||||
err.name = name;
|
||||
return err;
|
||||
}
|
||||
|
||||
describe("isChunkLoadError", () => {
|
||||
it("matches webpack ChunkLoadError by name", () => {
|
||||
expect(isChunkLoadError(namedError("ChunkLoadError", "Loading chunk 4121 failed."))).toBe(true);
|
||||
});
|
||||
|
||||
it("matches the message even when the name is generic", () => {
|
||||
expect(isChunkLoadError(new Error("Loading chunk app-line-chart failed. (error: https://x/_next/static/chunks/4121.js)"))).toBe(true);
|
||||
expect(isChunkLoadError(new Error("Loading CSS chunk 22 failed."))).toBe(true);
|
||||
});
|
||||
|
||||
it("matches native dynamic-import failures across browsers", () => {
|
||||
// Chromium
|
||||
expect(isChunkLoadError(new TypeError("Failed to fetch dynamically imported module: https://x/_next/y.js"))).toBe(true);
|
||||
// Firefox
|
||||
expect(isChunkLoadError(new TypeError("error loading dynamically imported module"))).toBe(true);
|
||||
// Safari
|
||||
expect(isChunkLoadError(new TypeError("Importing a module script failed."))).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts plain-string reasons (ErrorEvent.message fallback)", () => {
|
||||
expect(isChunkLoadError("Uncaught ChunkLoadError: Loading chunk 4121 failed.")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unrelated errors and empty values", () => {
|
||||
expect(isChunkLoadError(new Error("Failed: Internal Server Error"))).toBe(false);
|
||||
expect(isChunkLoadError(new TypeError("Failed to fetch"))).toBe(false); // plain network error
|
||||
expect(isChunkLoadError(null)).toBe(false);
|
||||
expect(isChunkLoadError(undefined)).toBe(false);
|
||||
expect(isChunkLoadError({ message: "Loading chunk 1 failed" })).toBe(false); // not an Error/string
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldAutoReload", () => {
|
||||
const now = 1_700_000_000_000;
|
||||
|
||||
it("allows the first reload (no prior timestamp)", () => {
|
||||
expect(shouldAutoReload(null, now)).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks a second reload inside the guard window", () => {
|
||||
expect(shouldAutoReload(now - 1, now)).toBe(false);
|
||||
expect(shouldAutoReload(now - RELOAD_WINDOW_MS + 1, now)).toBe(false);
|
||||
});
|
||||
|
||||
it("allows again once the window has passed", () => {
|
||||
expect(shouldAutoReload(now - RELOAD_WINDOW_MS, now)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { trackEvent } from "@/lib/appInsights";
|
||||
|
||||
/**
|
||||
* Recovery for the post-deploy stale-bundle failure mode: a tab still running the
|
||||
* previous deployment's client bundle asks for a lazily-loaded chunk (next/dynamic
|
||||
* charts, route chunks) whose content-hashed filename no longer exists after the
|
||||
* SWA swap. The import rejects (or the script tag 404s) and the user is stuck on
|
||||
* a "Loading chart…" placeholder until they reload by hand.
|
||||
*
|
||||
* The standard remedy is a single automatic reload — the fresh document references
|
||||
* the new bundle, so the retry succeeds. The reload MUST be bounded: if chunks are
|
||||
* genuinely unreachable (CDN outage), reloading again would loop. Hence the
|
||||
* sessionStorage timestamp guard: at most one auto-reload per tab per window; a
|
||||
* chunk error inside the guard window falls through to the normal error surface.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = "am-chunk-reload-at";
|
||||
/** Minimum gap between automatic reloads for one tab. */
|
||||
export const RELOAD_WINDOW_MS = 5 * 60 * 1000;
|
||||
|
||||
/** True when the value looks like a failed chunk / dynamic-import load (webpack + browser variants). */
|
||||
export function isChunkLoadError(reason: unknown): boolean {
|
||||
if (!reason) return false;
|
||||
const message =
|
||||
typeof reason === "string"
|
||||
? reason
|
||||
: reason instanceof Error
|
||||
? `${reason.name}: ${reason.message}`
|
||||
: "";
|
||||
return (
|
||||
/ChunkLoadError/.test(message) ||
|
||||
/Loading chunk [\w-]+ failed/i.test(message) ||
|
||||
/Loading CSS chunk/i.test(message) ||
|
||||
/Failed to fetch dynamically imported module/i.test(message) ||
|
||||
/error loading dynamically imported module/i.test(message) ||
|
||||
/Importing a module script failed/i.test(message)
|
||||
);
|
||||
}
|
||||
|
||||
/** Pure guard decision — exported for tests. Allows a reload only outside the window. */
|
||||
export function shouldAutoReload(lastReloadAt: number | null, now: number): boolean {
|
||||
return lastReloadAt === null || now - lastReloadAt >= RELOAD_WINDOW_MS;
|
||||
}
|
||||
|
||||
function readLastReloadAt(): number | null {
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(STORAGE_KEY);
|
||||
if (raw === null) return null;
|
||||
const parsed = Number(raw);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload once if allowed. Returns true when a reload was initiated (callers should
|
||||
* treat the error as handled). Without working sessionStorage there is no loop
|
||||
* guard, so no auto-reload happens at all — the normal error surface takes over.
|
||||
*/
|
||||
export function tryRecoverFromChunkError(source: string): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
|
||||
const now = Date.now();
|
||||
if (!shouldAutoReload(readLastReloadAt(), now)) return false;
|
||||
try {
|
||||
window.sessionStorage.setItem(STORAGE_KEY, String(now));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
trackEvent("ChunkReloadRecovery", { source, path: window.location.pathname });
|
||||
window.location.reload();
|
||||
return true;
|
||||
}
|
||||
@@ -3,12 +3,12 @@
|
||||
* Run: node scripts/generate-lastmod.js
|
||||
*/
|
||||
export const PAGE_LASTMOD: Record<string, string> = {
|
||||
"/": "2026-07-21T13:42:03+02:00",
|
||||
"/about": "2026-07-22T10:31:49+02:00",
|
||||
"/": "2026-07-23T08:37:42+02:00",
|
||||
"/about": "2026-07-25T12:43:42+02:00",
|
||||
"/roadmap": "2026-07-06T18:31:00+02:00",
|
||||
"/changelog": "2026-07-06T18:12:31+02:00",
|
||||
"/privacy": "2026-07-21T18:00:18+02:00",
|
||||
"/terms": "2026-07-21T18:00:18+02:00",
|
||||
"/privacy": "2026-07-25T12:43:42+02:00",
|
||||
"/terms": "2026-07-23T08:37:42+02:00",
|
||||
"/docs": "2026-07-06T18:12:31+02:00",
|
||||
"/docs/private-preview": "2026-07-06T18:12:31+02:00",
|
||||
"/docs/overview": "2026-07-06T18:12:31+02:00",
|
||||
|
||||
Reference in New Issue
Block a user