From 51737174b28e8b870d4d18d1b4fbf932d3e4886d Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Mon, 31 Aug 2026 04:32:51 +0200 Subject: [PATCH] =?UTF-8?q?feat(backend,mcp):=20typed=20API=20contract=20C?= =?UTF-8?q?OMPLETED=20=E2=80=94=20every=20remaining=20top-level=20success?= =?UTF-8?q?=20body=20is=20a=20manifest-exported=20wire=20DTO,=20and=20the?= =?UTF-8?q?=20guard=20now=20catches=20the=20bypass=20shapes=20that=20let?= =?UTF-8?q?=20them=20escape.=20The=2008-31=20migration's=20ratchet=20only?= =?UTF-8?q?=20saw=20inline=20WriteAsJsonAsync(new=20{...});=20four=20bypas?= =?UTF-8?q?s=20classes=20had=20kept=20~20=20endpoint=20families=20untyped:?= =?UTF-8?q?=20(A)=20Shared=20classes=20without=20IApiResponse=20=E2=80=94?= =?UTF-8?q?=20GeographicMetricsResponse,=20FleetHealthMetrics,=20ImePatter?= =?UTF-8?q?nHealthResponse,=20SlaMetricsResponse,=20PlatformUsageMetrics?= =?UTF-8?q?=20now=20implement=20it=20(wire=20byte-identical,=20interface?= =?UTF-8?q?=20is=20empty);=20(B)=20Functions-local=20named=20response=20fa?= =?UTF-8?q?milies=20moved=20verbatim=20to=20Shared=20=E2=80=94=20PlatformA?= =?UTF-8?q?gentMetricsResponse/SessionAgentMetric/DeliveryLatencyMetrics/C?= =?UTF-8?q?rashRateMetrics/CrashExceptionSummary,=20AgentEfficiencyMetrics?= =?UTF-8?q?Response=20family=20(CloneAsCacheHit=20relocated=20into=20the?= =?UTF-8?q?=20service;=20init=E2=86=92set=20for=20netstandard2.0),=20Offbo?= =?UTF-8?q?ardResponse;=20(C)=20anonymous/object=20builders=20converted=20?= =?UTF-8?q?to=20DTOs=20with=20old-vs-new=20wire-parity=20facts=20(TypedCon?= =?UTF-8?q?tractCompletionWireParityTests,=2014=20facts=20incl.=20every=20?= =?UTF-8?q?vanishing-key=20case):=20rule-stats=20x2=20(RuleStatsResponse;?= =?UTF-8?q?=20uniqueRules=20is=20global-only=20via=20an=20optional=20slot?= =?UTF-8?q?=20at=20its=20exact=20wire=20position),=20verdict-calibration?= =?UTF-8?q?=20(builder=20renamed=20VerdictCalibrationResponseBuilder,=20Pa?= =?UTF-8?q?thRow/TrendWindow=20to=20Shared),=20metrics/app=20(MetricsMath)?= =?UTF-8?q?,=20apps=20list/analytics/sessions=20x6=20routes=20(AppsAnalyti?= =?UTF-8?q?csHelper;=20legacy=20+=20paged=20list=20share=20ONE=20DTO=20via?= =?UTF-8?q?=20optional=20paging=20keys),=20ime-versions=20lean=20projectio?= =?UTF-8?q?n=20(ImeVersionHistoryLeanEntry=20[WireContract];=20bare-array?= =?UTF-8?q?=20envelope=20stays,=20typed=20as=20IReadOnlyList),=20d?= =?UTF-8?q?evice-journeys=20+=20time-attribution=20fleet=20envelopes,=20fe?= =?UTF-8?q?ature-flags,=20diagnostics/paths,=20session=20restore,=20cascad?= =?UTF-8?q?e-delete=20Enqueued=20arm=20(error=20arms=20stay=20anonymous=20?= =?UTF-8?q?by=20design),=20feedback=20eligibility/list;=20(D)=20Serialize+?= =?UTF-8?q?WriteString=20path=20=E2=80=94=20ListBackupsResponse=20under=20?= =?UTF-8?q?the=20backup=20surface's=20own=20serializer=20options;=20AppIns?= =?UTF-8?q?ightsQuery's=20Serialize=20is=20an=20outgoing=20REQUEST=20body?= =?UTF-8?q?=20(not=20response=20debt,=20baselined=20as=20such).=20TypedRes?= =?UTF-8?q?ponseGuardTests=20gains=20three=20ratchets=20with=20mutation-pr?= =?UTF-8?q?oven=20red/green:=20WriteAsJsonAsync()=20(empty=20baseline),=20JsonSerializer.?= =?UTF-8?q?Serialize(new{=20(error-tolerant;=201=20documented=20request-bo?= =?UTF-8?q?dy=20entry),=20and=20object/Task-returning=20Build*/Com?= =?UTF-8?q?pute*/*Payload/*Response=20methods=20(1=20documented=20union=20?= =?UTF-8?q?entry);=20the=20local-WriteJson-wrapper=20class=20is=20closed?= =?UTF-8?q?=20structurally=20(generic=20T=20:=20IApiResponse=20=E2=80=94?= =?UTF-8?q?=20an=20interface-typed=20parameter=20would=20serialize=20zero?= =?UTF-8?q?=20properties,=20the=20generic=20keeps=20the=20concrete=20type)?= =?UTF-8?q?.=20Manifest=20regenerated=20208=E2=86=92298=20exported=20types?= =?UTF-8?q?;=20MCP=20adopts=20the=20new=20types=20(get=5Frule=5Fstats=20ty?= =?UTF-8?q?ped=20with=20the=20trend-drop=20as=20explicit=20output=20shapin?= =?UTF-8?q?g,=20get=5Fplatform=5Fmetrics=20drops=20its=20hand-declared=20m?= =?UTF-8?q?irror=20for=20PlatformAgentMetricsResponse,=20verdict-calibrati?= =?UTF-8?q?on-shape=20derives=20its=20rows=20via=20extends=20Omit=20so=20backend=20renames=20go=20tsc-red).=20Six?= =?UTF-8?q?=20backend=20shape-test=20files=20switched=20from=20default-opt?= =?UTF-8?q?ions=20serialization=20to=20the=20production=20wire=20options?= =?UTF-8?q?=20via=20TestWire=20(ApiJsonOptions;=20the=20old=20default-opti?= =?UTF-8?q?ons=20runs=20only=20matched=20while=20payloads=20were=20anonymo?= =?UTF-8?q?us=20with=20lowercase=20members),=20and=20three=20assertions=20?= =?UTF-8?q?that=20pinned=20null-VALUED=20keys=20now=20pin=20key=20ABSENCE?= =?UTF-8?q?=20=E2=80=94=20production=20always=20omitted=20them=20(WhenWrit?= =?UTF-8?q?ingNull);=20the=20parity=20facts=20prove=20the=20wire=20itself?= =?UTF-8?q?=20is=20unchanged.=20Deliberately=20still=20untyped:=20auth/me,?= =?UTF-8?q?=20CheckMcpAccess=20dictionary,=20TenantAdmins/GlobalAdmins=20e?= =?UTF-8?q?ntity-row=20arrays=20and=20the=20object-slot=20payloads=20(Rule?= =?UTF-8?q?DryRun,=20HealthCheck,=20TenantConfigFieldSchema,=20...)=20?= =?UTF-8?q?=E2=80=94=20the=20documented=20read-model=20follow-up.=20Suites?= =?UTF-8?q?:=20Backend=205323,=20DecisionCore=20577,=20Agent=202805,=20Sum?= =?UTF-8?q?maryDialog=2044,=20MCP=20617,=20Web=20tsc=200=20+=20vitest=2095?= =?UTF-8?q?7,=20generator=20idempotent.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../AppInstallIdentityAndEspBlockingTests.cs | 8 +- .../AppVersionRegressionRadarTests.cs | 13 +- .../AppsAnalyticsFailureRateTests.cs | 15 +- .../AppsAnalyticsHelperPagingTests.cs | 7 +- .../DeleteSessionFunctionTests.cs | 6 +- .../DeviceJourneyAndFtrTests.cs | 14 +- .../GetTenantFeatureFlagsPayloadTests.cs | 10 +- .../MetricsMathAppPayloadTests.cs | 2 +- .../TestWire.cs | 21 + ...tributionPersistenceAndAggregationTests.cs | 2 +- .../TypedContractCompletionWireParityTests.cs | 879 +++ .../TypedResponseGuardTests.cs | 80 + .../VerdictCalibrationTests.cs | 4 +- .../Functions/Admin/TenantOffboardFunction.cs | 20 +- .../Functions/Apps/AppsAnalyticsHelper.cs | 294 +- .../Functions/Backup/ListBackupsFunction.cs | 2 +- .../Config/GetTenantFeatureFlagsFunction.cs | 40 +- .../GetDiagnosticsPathsFunction.cs | 20 +- .../Functions/Feedback/FeedbackFunction.cs | 50 +- .../Metrics/GetDeviceJourneyFunctions.cs | 68 +- .../Metrics/GetImeVersionHistoryFunction.cs | 17 +- .../Metrics/GetTimeAttributionFunctions.cs | 24 +- .../Metrics/GetVerdictCalibrationFunction.cs | 87 +- .../Functions/Metrics/RuleStatsFunction.cs | 156 +- .../Sessions/DeleteSessionFunction.cs | 11 +- .../Sessions/RestoreSessionFunction.cs | 25 +- .../Helpers/MetricsMath.cs | 79 +- .../Services/AgentEfficiencyMetricsService.cs | 92 +- .../Services/AgentMetricsAggregation.cs | 1 + .../Services/PlatformMetricsService.cs | 80 +- .../src/generated/wire-types.generated.ts | 1188 ++++ .../autopilot-monitor-mcp/src/tools/admin.ts | 37 +- .../src/verdict-calibration-shape.ts | 24 +- .../Models/Backup/ListBackupsResponse.cs | 14 + .../Config/TenantFeatureFlagsApiModels.cs | 48 + .../Diagnostics/DiagnosticsPathsApiModels.cs | 34 + .../Models/FeedbackApiModels.cs | 37 + .../Models/FleetHealthModels.cs | 2 +- .../Models/GeographicApiModels.cs | 2 +- .../Models/ImePatternHealthModels.cs | 2 +- .../Models/Metrics/AgentPerformanceMetrics.cs | 144 + .../Models/Metrics/AppMetricsApiModels.cs | 68 + .../Models/Metrics/AppsDashboardApiModels.cs | 178 + .../Metrics/InsightsMetricsApiModels.cs | 67 + .../Models/Metrics/RuleStatsApiModels.cs | 67 + .../Models/Metrics/SlaMetrics.cs | 2 +- .../Models/Metrics/UsageMetrics.cs | 2 +- .../Metrics/VerdictCalibrationApiModels.cs | 85 + .../Models/OffboardApiModels.cs | 25 + .../Models/SessionApiModels.cs | 14 + .../Models/SessionRestoreApiModels.cs | 43 + .../utils/shared-manifests.json | 5565 +++++++++++++++++ .../utils/wire-types.generated.ts | 1188 ++++ 53 files changed, 10263 insertions(+), 700 deletions(-) create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/TestWire.cs create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/TypedContractCompletionWireParityTests.cs create mode 100644 src/Shared/AutopilotMonitor.Shared/Models/Backup/ListBackupsResponse.cs create mode 100644 src/Shared/AutopilotMonitor.Shared/Models/Config/TenantFeatureFlagsApiModels.cs create mode 100644 src/Shared/AutopilotMonitor.Shared/Models/Diagnostics/DiagnosticsPathsApiModels.cs create mode 100644 src/Shared/AutopilotMonitor.Shared/Models/FeedbackApiModels.cs create mode 100644 src/Shared/AutopilotMonitor.Shared/Models/Metrics/AgentPerformanceMetrics.cs create mode 100644 src/Shared/AutopilotMonitor.Shared/Models/Metrics/AppMetricsApiModels.cs create mode 100644 src/Shared/AutopilotMonitor.Shared/Models/Metrics/AppsDashboardApiModels.cs create mode 100644 src/Shared/AutopilotMonitor.Shared/Models/Metrics/InsightsMetricsApiModels.cs create mode 100644 src/Shared/AutopilotMonitor.Shared/Models/Metrics/RuleStatsApiModels.cs create mode 100644 src/Shared/AutopilotMonitor.Shared/Models/Metrics/VerdictCalibrationApiModels.cs create mode 100644 src/Shared/AutopilotMonitor.Shared/Models/OffboardApiModels.cs create mode 100644 src/Shared/AutopilotMonitor.Shared/Models/SessionRestoreApiModels.cs diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AppInstallIdentityAndEspBlockingTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AppInstallIdentityAndEspBlockingTests.cs index 4bfee637..8e77ba88 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/AppInstallIdentityAndEspBlockingTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/AppInstallIdentityAndEspBlockingTests.cs @@ -265,7 +265,7 @@ public class AppInstallIdentityAndEspBlockingTests Summary("Company Portal", status: "Failed", collision: true), }; - var root = JsonSerializer.SerializeToElement(MetricsMath.BuildAppMetricsPayload(summaries)); + var root = TestWire.SerializeToElement(MetricsMath.BuildAppMetricsPayload(summaries)); Assert.Equal(1, root.GetProperty("totalApps").GetInt32()); // collided name forms no group Assert.Equal(4, root.GetProperty("totalInstalls").GetInt32()); // raw row count unchanged @@ -282,7 +282,7 @@ public class AppInstallIdentityAndEspBlockingTests Summary("Company Portal", collision: true), }; - var root = JsonSerializer.SerializeToElement( + var root = TestWire.SerializeToElement( AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30)); var apps = root.GetProperty("apps").EnumerateArray().ToList(); @@ -305,7 +305,7 @@ public class AppInstallIdentityAndEspBlockingTests Summary("Company Portal", status: "Failed", collision: true), // the second identity's failure }; - var root = JsonSerializer.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync( + var root = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync( summaries, repo.Object, "Company Portal", days: 30)); Assert.Equal(1, root.GetProperty("collisionExcluded").GetInt32()); @@ -324,7 +324,7 @@ public class AppInstallIdentityAndEspBlockingTests var summaries = new List { Summary("Company Portal", collision: true) }; - var root = JsonSerializer.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync( + var root = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync( summaries, repo.Object, "Company Portal", days: 30)); Assert.Equal(1, root.GetProperty("collisionExcluded").GetInt32()); diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AppVersionRegressionRadarTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AppVersionRegressionRadarTests.cs index cf42e323..99118f15 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/AppVersionRegressionRadarTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/AppVersionRegressionRadarTests.cs @@ -363,7 +363,7 @@ public class AppVersionRegressionRadarTests Install("1.0", 50, status: "Failed"), // failed — excluded from durations }; - var root = JsonSerializer.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync( + var root = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync( rows, sessionRepo.Object, App, days: 30)); var version = root.GetProperty("versionBreakdown")[0]; @@ -383,19 +383,18 @@ public class AppVersionRegressionRadarTests .ReturnsAsync((SessionSummary?)null); var alert = Alert(); - // The Functions host serializes camelCase on the wire; mirror that here so the + // TestWire serializes with the REAL wire options (ApiJsonOptions — camelCase), so the // asserted property names match what the web client actually parses. - var wireOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web); // Empty-data early return still surfaces the block (episodes can outlive window data). - var emptyRoot = JsonSerializer.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync( + var emptyRoot = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync( new List(), sessionRepo.Object, App, days: 30, - new List { alert }), wireOptions); + new List { alert })); Assert.Equal(1, emptyRoot.GetProperty("versionRegressions").GetArrayLength()); - var root = JsonSerializer.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync( + var root = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync( new List { Install("1.0", 100) }, sessionRepo.Object, App, days: 30, - new List { alert }), wireOptions); + new List { alert })); var regression = root.GetProperty("versionRegressions")[0]; Assert.Equal("2.0", regression.GetProperty("currentVersion").GetString()); diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AppsAnalyticsFailureRateTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AppsAnalyticsFailureRateTests.cs index 06c3e0a0..cf930d37 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/AppsAnalyticsFailureRateTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/AppsAnalyticsFailureRateTests.cs @@ -53,7 +53,7 @@ public class AppsAnalyticsFailureRateTests A("InProgress"), }; - var root = JsonSerializer.SerializeToElement( + var root = TestWire.SerializeToElement( AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30)); var app = root.GetProperty("apps")[0]; @@ -67,7 +67,7 @@ public class AppsAnalyticsFailureRateTests { var summaries = new List { A("InProgress"), A("InProgress") }; - var root = JsonSerializer.SerializeToElement( + var root = TestWire.SerializeToElement( AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30)); Assert.Equal(0d, root.GetProperty("apps")[0].GetProperty("failureRate").GetDouble()); @@ -90,7 +90,7 @@ public class AppsAnalyticsFailureRateTests for (var i = 0; i < 2; i++) summaries.Add(A("Succeeded", startedAt: secondHalf)); for (var i = 0; i < 10; i++) summaries.Add(A("InProgress", startedAt: secondHalf)); - var root = JsonSerializer.SerializeToElement( + var root = TestWire.SerializeToElement( AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30)); var app = root.GetProperty("apps")[0]; @@ -109,12 +109,13 @@ public class AppsAnalyticsFailureRateTests for (var i = 0; i < 4; i++) summaries.Add(A("Failed", startedAt: now.AddDays(-5))); for (var i = 0; i < 10; i++) summaries.Add(A("InProgress", startedAt: now.AddDays(-5))); - var root = JsonSerializer.SerializeToElement( + var root = TestWire.SerializeToElement( AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30)); var app = root.GetProperty("apps")[0]; Assert.Equal("stable", app.GetProperty("trend").GetString()); - Assert.Equal(JsonValueKind.Null, app.GetProperty("trendDelta").ValueKind); + // Wire options omit null keys (WhenWritingNull): a withheld delta has no key at all. + Assert.False(app.TryGetProperty("trendDelta", out _)); } // ── /apps/{appName}/analytics ─────────────────────────────────────────── @@ -131,7 +132,7 @@ public class AppsAnalyticsFailureRateTests } var result = await AppsAnalyticsHelper.BuildAnalyticsResponseAsync( summaries, sessionRepo, "Contoso App", days: 30); - return JsonSerializer.SerializeToElement(result); + return TestWire.SerializeToElement(result); } [Fact] @@ -176,7 +177,7 @@ public class AppsAnalyticsFailureRateTests var summaries = new List { A("Failed"), A("Succeeded"), skip, unmeasured }; - var root = JsonSerializer.SerializeToElement( + var root = TestWire.SerializeToElement( AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30)); var app = root.GetProperty("apps")[0]; diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AppsAnalyticsHelperPagingTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AppsAnalyticsHelperPagingTests.cs index d6930bd5..cd49af1c 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/AppsAnalyticsHelperPagingTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/AppsAnalyticsHelperPagingTests.cs @@ -24,7 +24,7 @@ public class AppsAnalyticsHelperPagingTests } private static JsonElement Build(int? pageSize, int skip = 0) => - JsonSerializer.SerializeToElement(AppsAnalyticsHelper.BuildAppsListResponse( + TestWire.SerializeToElement(AppsAnalyticsHelper.BuildAppsListResponse( FiveApps(), days: 30, pageSize: pageSize, skip: skip, nextLinkForOffset: o => $"/api/apps/list?pageSize={pageSize}&skip={o}")); @@ -55,12 +55,13 @@ public class AppsAnalyticsHelperPagingTests } [Fact] - public void PageSize_LastPage_HasNullNextLink() + public void PageSize_LastPage_OmitsNextLink() { var root = Build(pageSize: 2, skip: 4); Assert.Equal(1, root.GetProperty("count").GetInt32()); Assert.Equal("Echo", root.GetProperty("apps")[0].GetProperty("appName").GetString()); - Assert.Equal(JsonValueKind.Null, root.GetProperty("nextLink").ValueKind); + // Wire options omit null keys (WhenWritingNull): no further page → no nextLink key. + Assert.False(root.TryGetProperty("nextLink", out _)); } } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/DeleteSessionFunctionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/DeleteSessionFunctionTests.cs index 3014927a..1d39f9c0 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/DeleteSessionFunctionTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/DeleteSessionFunctionTests.cs @@ -159,16 +159,18 @@ public class DeleteSessionFunctionTests // ── Anonymous-object reflection helpers ─────────────────────────────────────────────── + // IgnoreCase: the Enqueued arm is a typed DTO (PascalCase properties, camelCased on the + // wire by ApiJsonOptions); the error arms stay anonymous (camelCase). Both resolve here. private static void AssertProperty(object body, string propertyName, object? expected) { - var prop = body.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance); + var prop = body.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase); Assert.NotNull(prop); Assert.Equal(expected, prop!.GetValue(body)); } private static T GetProperty(object body, string propertyName) { - var prop = body.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance); + var prop = body.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase); Assert.NotNull(prop); var value = prop!.GetValue(body); return (T)value!; diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/DeviceJourneyAndFtrTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/DeviceJourneyAndFtrTests.cs index ff9d4c40..f4fa401b 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/DeviceJourneyAndFtrTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/DeviceJourneyAndFtrTests.cs @@ -702,7 +702,7 @@ public class DeviceJourneyAndFtrTests }, }; - var totals = DeviceJourneyMetricsResponse.SumAggregates(daily); + var totals = DeviceJourneyMetricsResponseBuilder.SumAggregates(daily); Assert.Equal(3, totals.CompletedJourneys); Assert.Equal(2, totals.FirstTimeRight); @@ -716,7 +716,7 @@ public class DeviceJourneyAndFtrTests [Fact] public void SumAggregates_NoCompletedJourneys_RateIsNull_NeverZero() { - var totals = DeviceJourneyMetricsResponse.SumAggregates(new List + var totals = DeviceJourneyMetricsResponseBuilder.SumAggregates(new List { new() { CompletedJourneyCount = 0, FirstTimeRightCount = 0, ExcludedSessionCount = 3 }, }); @@ -734,7 +734,7 @@ public class DeviceJourneyAndFtrTests [InlineData("999", 180)] // clamped to aggregate retention public void ClampDays_DefaultsAndClamps(string? raw, int expected) { - Assert.Equal(expected, DeviceJourneyMetricsResponse.ClampDays(raw)); + Assert.Equal(expected, DeviceJourneyMetricsResponseBuilder.ClampDays(raw)); } [Fact] @@ -743,8 +743,8 @@ public class DeviceJourneyAndFtrTests // Codex review: both range ends are inclusive, so "today - days" returned N+1 keys — // days=1 summed yesterday AND today. var today = new DateTime(2026, 7, 27); - Assert.Equal(today, DeviceJourneyMetricsResponse.InclusiveWindowStart(today, 1)); - Assert.Equal(today.AddDays(-29), DeviceJourneyMetricsResponse.InclusiveWindowStart(today, 30)); + Assert.Equal(today, DeviceJourneyMetricsResponseBuilder.InclusiveWindowStart(today, 1)); + Assert.Equal(today.AddDays(-29), DeviceJourneyMetricsResponseBuilder.InclusiveWindowStart(today, 30)); } private static DeviceHistory RepeaterHistory( @@ -780,7 +780,7 @@ public class DeviceJourneyAndFtrTests Ref("w2", SessionStatus.Succeeded, T0.AddDays(-2))), }; - var selected = DeviceJourneyMetricsResponse.SelectRepeatDevices(histories, T0.AddDays(-30)); + var selected = DeviceJourneyMetricsResponseBuilder.SelectRepeatDevices(histories, T0.AddDays(-30)); Assert.Equal(2, selected.Count); Assert.Equal("hot-1", selected[0].History.SerialKey); // attempts desc @@ -799,7 +799,7 @@ public class DeviceJourneyAndFtrTests Ref($"r-{i}", SessionStatus.Failed, T0.AddDays(-i)))); } - var selected = DeviceJourneyMetricsResponse.SelectRepeatDevices(histories, T0.AddDays(-30)); + var selected = DeviceJourneyMetricsResponseBuilder.SelectRepeatDevices(histories, T0.AddDays(-30)); Assert.Equal(10, selected.Count); Assert.Equal("dev-11", selected[0].History.SerialKey); // highest attempts first diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/GetTenantFeatureFlagsPayloadTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/GetTenantFeatureFlagsPayloadTests.cs index 30791f25..559148bf 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/GetTenantFeatureFlagsPayloadTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/GetTenantFeatureFlagsPayloadTests.cs @@ -19,7 +19,7 @@ public class GetTenantFeatureFlagsPayloadTests private static JsonElement Serialize(TenantConfiguration config) { var payload = GetTenantFeatureFlagsFunction.BuildPayload(config, Now); - var json = JsonSerializer.Serialize(payload); + var json = TestWire.Serialize(payload); return JsonDocument.Parse(json).RootElement; } @@ -49,7 +49,9 @@ public class GetTenantFeatureFlagsPayloadTests "isTrial", "showScriptOutput", "trialAvailable", - "trialExpiresUtc", + // trialExpiresUtc is absent here: the default tenant is not on a trial and the + // wire options omit null keys (WhenWritingNull) — exactly what production always + // served. Payload_ActiveTrial_IsTrialWithExpiry pins the key on an active trial. "unrestrictedMode", "validateAutopilotDevice", }, fieldNames); @@ -162,7 +164,7 @@ public class GetTenantFeatureFlagsPayloadTests WebhookUrl = "https://hooks.example.com/services/secret-generic-hook", }; - var json = JsonSerializer.Serialize(GetTenantFeatureFlagsFunction.BuildPayload(config, Now)); + var json = TestWire.Serialize(GetTenantFeatureFlagsFunction.BuildPayload(config, Now)); Assert.DoesNotContain("secret-sas-token", json); Assert.DoesNotContain("secret-team-hook", json); @@ -270,7 +272,7 @@ public class GetTenantFeatureFlagsPayloadTests [Fact] public void Payload_ContactEmailSet_NeverLeaksTheAddress() { - var json = JsonSerializer.Serialize(GetTenantFeatureFlagsFunction.BuildPayload( + var json = TestWire.Serialize(GetTenantFeatureFlagsFunction.BuildPayload( new TenantConfiguration { ContactEmail = "secret-contact@contoso.com" }, Now)); Assert.DoesNotContain("secret-contact", json); diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/MetricsMathAppPayloadTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/MetricsMathAppPayloadTests.cs index b6c5c83f..9ec2b09f 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/MetricsMathAppPayloadTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/MetricsMathAppPayloadTests.cs @@ -18,7 +18,7 @@ public class MetricsMathAppPayloadTests private static JsonElement Build(IEnumerable summaries) { var payload = MetricsMath.BuildAppMetricsPayload(summaries); - return JsonSerializer.SerializeToElement(payload); + return TestWire.SerializeToElement(payload); } [Fact] diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TestWire.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TestWire.cs new file mode 100644 index 00000000..5bc940ed --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TestWire.cs @@ -0,0 +1,21 @@ +using System.Text.Json; +using AutopilotMonitor.Functions.Helpers; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Serializes test payloads with the PRODUCTION wire options ( — +/// camelCase, WhenWritingNull), the policy every test harness with its own serializer must +/// follow. Shape tests serialized DTOs with default options before the 2026-08-31 completion +/// pass; that only worked while the payloads were anonymous objects with lowercase members. +/// Generic so the CONCRETE type reaches System.Text.Json (a declared object parameter would +/// too, but the generic also keeps compile-time typing at the call site). +/// +internal static class TestWire +{ + private static readonly JsonSerializerOptions Options = ApiJsonOptions.Create(); + + public static string Serialize(T value) => JsonSerializer.Serialize(value, Options); + + public static JsonElement SerializeToElement(T value) => JsonSerializer.SerializeToElement(value, Options); +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TimeAttributionPersistenceAndAggregationTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TimeAttributionPersistenceAndAggregationTests.cs index 20fcb876..1fad4a63 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/TimeAttributionPersistenceAndAggregationTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TimeAttributionPersistenceAndAggregationTests.cs @@ -465,6 +465,6 @@ public class TimeAttributionPersistenceAndAggregationTests // returned 31 day keys under a "windowDays: 30" label. var today = new System.DateTime(2026, 7, 27); Assert.Equal(today.AddDays(-29), - Functions.Metrics.TimeAttributionResponse.InclusiveWindowStart(today, Functions.Metrics.TimeAttributionResponse.WindowDays)); + Functions.Metrics.TimeAttributionResponseBuilder.InclusiveWindowStart(today, Functions.Metrics.TimeAttributionResponseBuilder.WindowDays)); } } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TypedContractCompletionWireParityTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TypedContractCompletionWireParityTests.cs new file mode 100644 index 00000000..5fe37149 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TypedContractCompletionWireParityTests.cs @@ -0,0 +1,879 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Shared.Models; +using AutopilotMonitor.Shared.Models.Backup; +using AutopilotMonitor.Shared.Models.Metrics; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Wire-identity proofs for the 2026-08-31 typed-contract COMPLETION pass: the endpoints the +/// original migration missed because their anonymous literal was smuggled past the ratchet +/// (variable indirection, object-returning builders, Serialize+WriteString, local WriteJson +/// wrappers). Same contract as : OLD anonymous +/// literal (copied from the pre-conversion diff) vs NEW DTO, ordinal JSON equality, plus a +/// null-slot case wherever a key must vanish. Sites converted by MOVING an already-named +/// class (PlatformAgentMetricsResponse family, AgentEfficiency family, VerdictCalibration +/// path rows, DeviceJourneyWindowTotals, OffboardResponse) are wire-identical by +/// construction (System.Text.Json ignores namespaces) and carry no fact. +/// +public class TypedContractCompletionWireParityTests +{ + private static void AssertWireIdentical(object anonymousLiteral, IApiResponse typed) + => ApiResponseWireParityTests.AssertWireIdentical(anonymousLiteral, typed); + + // ── rule-stats (tenant + global) ───────────────────────────────────────────────────── + + [Fact] + public void RuleStatsResponse_matches_the_tenant_shape_without_uniqueRules() + { + var regressions = new List(); + AssertWireIdentical( + new + { + rules = new[] + { + new + { + ruleId = "ANALYZE-NET-001", + ruleType = "analyze", + ruleTitle = "Network issue", + category = "network", + severity = "warning", + fireCount = 3, + evaluationCount = 10, + sessionsEvaluated = 9, + hitRate = 30.0, + avgConfidenceScore = 71.5, + trend = new[] { new { date = "2026-08-30", fireCount = 3, evaluationCount = 10 } }, + }, + }, + regressions, + summary = new + { + totalEvaluations = 10, + totalFires = 3, + overallHitRate = 30.0, + topRuleByFireCount = (string?)"ANALYZE-NET-001", + period = new { start = "2026-08-01", end = "2026-08-31" }, + }, + }, + new RuleStatsResponse + { + Rules = new[] + { + new RuleStatsRuleAggregate + { + RuleId = "ANALYZE-NET-001", + RuleType = "analyze", + RuleTitle = "Network issue", + Category = "network", + Severity = "warning", + FireCount = 3, + EvaluationCount = 10, + SessionsEvaluated = 9, + HitRate = 30.0, + AvgConfidenceScore = 71.5, + Trend = new[] { new RuleTrendPoint { Date = "2026-08-30", FireCount = 3, EvaluationCount = 10 } }, + }, + }, + Regressions = regressions, + // Tenant route: UniqueRules stays null → the key is absent, matching the + // historical tenant shape which never carried it. + Summary = new RuleStatsSummary + { + TotalEvaluations = 10, + TotalFires = 3, + OverallHitRate = 30.0, + TopRuleByFireCount = "ANALYZE-NET-001", + UniqueRules = null, + Period = new RuleStatsPeriod { Start = "2026-08-01", End = "2026-08-31" }, + }, + }); + } + + [Fact] + public void RuleStatsResponse_matches_the_global_shape_with_uniqueRules_and_null_topRule() + { + var regressions = new List(); + AssertWireIdentical( + new + { + rules = Array.Empty(), + regressions, + summary = new + { + totalEvaluations = 0, + totalFires = 0, + overallHitRate = 0.0, + topRuleByFireCount = (string?)null, // empty window → key vanishes + uniqueRules = 0, + period = new { start = "2026-08-01", end = "2026-08-31" }, + }, + }, + new RuleStatsResponse + { + Rules = Array.Empty(), + Regressions = regressions, + Summary = new RuleStatsSummary + { + TotalEvaluations = 0, + TotalFires = 0, + OverallHitRate = 0.0, + TopRuleByFireCount = null, + UniqueRules = 0, + Period = new RuleStatsPeriod { Start = "2026-08-01", End = "2026-08-31" }, + }, + }); + } + + // ── verdict-calibration ────────────────────────────────────────────────────────────── + + [Fact] + public void VerdictCalibrationResponse_matches_the_envelope_incl_computedAt_omission() + { + // Path rows and alerts were ALREADY named classes on the old wire (moved verbatim) — + // the envelope keys are what the conversion changed, so the fact pins those with the + // row lists empty; the no-data window additionally proves computedAt vanishes. + var paths = new List(); + var alerts = new List(); + AssertWireIdentical( + new + { + success = true, + tenantId = "global", + windowDays = 30, + windowStart = "2026-08-02", + windowEnd = "2026-08-31", + computedAt = (DateTime?)null, + versions = new[] { 1, 2 }, + totals = new { sessions = 0, terminal = 0, derived = 0, days = 0 }, + trend = new { windowDays = 7, baselineDays = 28, windowSessions = 0, baselineSessions = 0 }, + paths, + alerts, + }, + new VerdictCalibrationResponse + { + Success = true, + TenantId = "global", + WindowDays = 30, + WindowStart = "2026-08-02", + WindowEnd = "2026-08-31", + ComputedAt = null, + Versions = new[] { 1, 2 }, + Totals = new VerdictCalibrationTotals { Sessions = 0, Terminal = 0, Derived = 0, Days = 0 }, + Trend = new VerdictCalibrationTrendMeta { WindowDays = 7, BaselineDays = 28, WindowSessions = 0, BaselineSessions = 0 }, + Paths = paths, + Alerts = alerts, + }); + } + + // ── metrics/app ────────────────────────────────────────────────────────────────────── + + [Fact] + public void AppMetricsResponse_matches_the_anonymous_shape() + { + AssertWireIdentical( + new + { + success = true, + totalApps = 1, + totalInstalls = 4, + totalSkipped = 1, + totalUnmeasured = 1, + totalCollisionExcluded = 0, + slowestApps = new[] + { + new + { + appName = "Contoso App", + totalInstalls = 4, + succeeded = 2, + skipped = 1, + unmeasured = 1, + failed = 1, + failureRate = 33.3, + avgDurationSeconds = 120.0, + maxDurationSeconds = 240, + measuredInstalls = 1, + avgDownloadBytes = 1048576L, + doTotalBytesDownloaded = 2097152L, + doBytesFromPeers = 1048576L, + doBytesFromCacheServer = 0L, + doBytesFromHttp = 1048576L, + peerOffloadPercent = 50.0, + topFailureCodes = new[] { new { code = "0x80070005", count = 1 } }, + }, + }, + topFailingApps = Array.Empty(), + deliveryOptimization = new + { + totalBytesDownloaded = 2097152L, + fromPeers = 1048576L, + fromCacheServer = 0L, + fromHttp = 1048576L, + peerOffloadPercent = 50.0, + }, + }, + new AppMetricsResponse + { + Success = true, + TotalApps = 1, + TotalInstalls = 4, + TotalSkipped = 1, + TotalUnmeasured = 1, + TotalCollisionExcluded = 0, + SlowestApps = new[] + { + new AppMetricsAppGroup + { + AppName = "Contoso App", + TotalInstalls = 4, + Succeeded = 2, + Skipped = 1, + Unmeasured = 1, + Failed = 1, + FailureRate = 33.3, + AvgDurationSeconds = 120.0, + MaxDurationSeconds = 240, + MeasuredInstalls = 1, + AvgDownloadBytes = 1048576L, + DoTotalBytesDownloaded = 2097152L, + DoBytesFromPeers = 1048576L, + DoBytesFromCacheServer = 0L, + DoBytesFromHttp = 1048576L, + PeerOffloadPercent = 50.0, + TopFailureCodes = new[] { new AppFailureCodeCount { Code = "0x80070005", Count = 1 } }, + }, + }, + TopFailingApps = Array.Empty(), + DeliveryOptimization = new AppMetricsDeliveryOptimization + { + TotalBytesDownloaded = 2097152L, + FromPeers = 1048576L, + FromCacheServer = 0L, + FromHttp = 1048576L, + PeerOffloadPercent = 50.0, + }, + }); + } + + // ── metrics/ime-versions (non-global projection) ───────────────────────────────────── + + [Fact] + public void ImeVersionHistoryLeanEntry_matches_the_anonymous_projection() + { + var seen = new DateTime(2026, 8, 1, 12, 0, 0, DateTimeKind.Utc); + var expected = JsonSerializer.Serialize( + new { Version = "1.104.102.0", FirstSeenAt = seen, LastSeenAt = seen.AddDays(3), SessionCount = 12 }, + ApiJsonOptions.Create()); + var actual = JsonSerializer.Serialize( + new ImeVersionHistoryLeanEntry + { + Version = "1.104.102.0", + FirstSeenAt = seen, + LastSeenAt = seen.AddDays(3), + SessionCount = 12, + }, + ApiJsonOptions.Create()); + Assert.Equal(expected, actual); + } + + // ── metrics/time-attribution (fleet) ───────────────────────────────────────────────── + + [Fact] + public void TimeAttributionMetricsResponse_matches_the_envelope() + { + // Row lists are TimeAttributionDailyAggregate on BOTH sides (identical class) — + // the envelope keys are the conversion. + var rows = new List(); + AssertWireIdentical( + new { success = true, windowDays = 30, classes = rows, daily = rows }, + new TimeAttributionMetricsResponse { Success = true, WindowDays = 30, Classes = rows, Daily = rows }); + } + + // ── metrics/device-journeys ────────────────────────────────────────────────────────── + + [Fact] + public void DeviceJourneyMetricsResponse_matches_the_envelope_incl_repeatDevices_omission() + { + var totals = new DeviceJourneyWindowTotals { CompletedJourneys = 0, FirstTimeRight = 0, FtrRatePct = null, ExcludedSessions = 0 }; + var daily = new List(); + // Global aggregate: repeatDevices=null → the key vanishes (the disclosed gap). + AssertWireIdentical( + new { success = true, windowDays = 30, totals, daily, repeatDevices = (List?)null }, + new DeviceJourneyMetricsResponse { Success = true, WindowDays = 30, Totals = totals, Daily = daily, RepeatDevices = null }); + } + + [Fact] + public void DeviceJourneyRepeatDevice_matches_the_anonymous_row() + { + var started = new DateTime(2026, 8, 29, 8, 0, 0, DateTimeKind.Utc); + AssertWireIdentical( + new + { + success = true, + windowDays = 30, + totals = new DeviceJourneyWindowTotals(), + daily = new List(), + repeatDevices = new[] + { + new + { + serialNumber = "SER123", + manufacturer = "Contoso", + model = "Book 3", + attempts = 3, + journeyCount = 2, + lastStatus = "Failed", + lastSessionId = "22222222-2222-2222-2222-222222222222", + lastStartedAt = started, + lastFailureReason = "ESP timeout", + }, + }, + }, + new DeviceJourneyMetricsResponse + { + Success = true, + WindowDays = 30, + Totals = new DeviceJourneyWindowTotals(), + Daily = new List(), + RepeatDevices = new[] + { + new DeviceJourneyRepeatDevice + { + SerialNumber = "SER123", + Manufacturer = "Contoso", + Model = "Book 3", + Attempts = 3, + JourneyCount = 2, + LastStatus = "Failed", + LastSessionId = "22222222-2222-2222-2222-222222222222", + LastStartedAt = started, + LastFailureReason = "ESP timeout", + }, + }, + }); + } + + // ── config/{tenantId}/feature-flags ────────────────────────────────────────────────── + + [Fact] + public void TenantFeatureFlagsResponse_matches_the_anonymous_shape_incl_null_omissions() + { + AssertWireIdentical( + new + { + bootstrapTokenEnabled = true, + diagnosticsUploadConfigured = false, + validateAutopilotDevice = true, + appHomingFunnelActive = false, + showScriptOutput = true, + enableSoftwareInventoryAnalyzer = false, + enableIntegrityBypassAnalyzer = true, + unrestrictedMode = false, + edition = "community", + isTrial = false, + trialExpiresUtc = (DateTime?)null, // not on trial → key vanishes + trialAvailable = true, + contactEmailSet = false, + entitlements = new + { + retentionCapDays = 90, + userRateLimitPerMinute = (int?)null, // platform default → key vanishes + delegatedAdminAllowed = false, + mcpUsagePlan = "community", + }, + }, + new TenantFeatureFlagsResponse + { + BootstrapTokenEnabled = true, + DiagnosticsUploadConfigured = false, + ValidateAutopilotDevice = true, + AppHomingFunnelActive = false, + ShowScriptOutput = true, + EnableSoftwareInventoryAnalyzer = false, + EnableIntegrityBypassAnalyzer = true, + UnrestrictedMode = false, + Edition = "community", + IsTrial = false, + TrialExpiresUtc = null, + TrialAvailable = true, + ContactEmailSet = false, + Entitlements = new TenantFeatureEntitlements + { + RetentionCapDays = 90, + UserRateLimitPerMinute = null, + DelegatedAdminAllowed = false, + McpUsagePlan = "community", + }, + }); + } + + // ── diagnostics/paths ──────────────────────────────────────────────────────────────── + + [Fact] + public void DiagnosticsPathsResponse_matches_the_anonymous_shape() + { + var globalPaths = new List(); + AssertWireIdentical( + new + { + builtIn = new[] + { + new + { + id = "AgentLogs", + zipFolder = "AgentLogs", + sourceFolder = "%ProgramData%/AutopilotMonitor", + patterns = new[] { "*.log" }, + includeSubfolders = false, + description = "Agent log files", + condition = "Always", + }, + }, + globalPaths, + }, + new DiagnosticsPathsResponse + { + BuiltIn = new[] + { + new DiagnosticsBuiltInSectionWire + { + Id = "AgentLogs", + ZipFolder = "AgentLogs", + SourceFolder = "%ProgramData%/AutopilotMonitor", + Patterns = new[] { "*.log" }, + IncludeSubfolders = false, + Description = "Agent log files", + Condition = "Always", + }, + }, + GlobalPaths = globalPaths, + }); + } + + // ── sessions restore / delete-queued ───────────────────────────────────────────────── + + [Fact] + public void SessionRestoreResponse_matches_the_anonymous_shape_incl_null_omissions() + { + var rows = new Dictionary(StringComparer.Ordinal) { ["Sessions"] = 1 }; + var empty = new Dictionary(StringComparer.Ordinal); + AssertWireIdentical( + new + { + success = true, + outcome = "Restored", + mode = (string?)"full", + message = (string?)null, // clean success → key vanishes + currentState = (string?)null, + pendingManifestId = (string?)null, + rowsRestoredByTable = rows, + rowsSkippedByTable = empty, + wouldRestoreByTable = empty, + inventoryReIncrements = 2, + durationMs = 1234L, + }, + new SessionRestoreResponse + { + Success = true, + Outcome = "Restored", + Mode = "full", + Message = null, + CurrentState = null, + PendingManifestId = null, + RowsRestoredByTable = rows, + RowsSkippedByTable = empty, + WouldRestoreByTable = empty, + InventoryReIncrements = 2, + DurationMs = 1234L, + }); + } + + [Fact] + public void SessionDeletionQueuedResponse_matches_the_anonymous_Enqueued_arm() + { + AssertWireIdentical( + new + { + success = true, + status = "queued", + manifestId = (string?)"01J0123456789ABCDEFGHIJKLM", + message = "Cascade deletion queued; worker will drain asynchronously.", + }, + new SessionDeletionQueuedResponse + { + Success = true, + Status = "queued", + ManifestId = "01J0123456789ABCDEFGHIJKLM", + Message = "Cascade deletion queued; worker will drain asynchronously.", + }); + } + + // ── feedback ───────────────────────────────────────────────────────────────────────── + + [Fact] + public void Feedback_responses_match_the_anonymous_shapes() + { + AssertWireIdentical(new { eligible = true }, new FeedbackEligibilityResponse { Eligible = true }); + + AssertWireIdentical( + new + { + feedback = new[] + { + new + { + type = "InApp", + upn = "alice@contoso.com", + tenantId = "11111111-1111-1111-1111-111111111111", + displayName = "Alice", + rating = (int?)5, + comment = (string?)"Great", + dismissed = false, + submitted = true, + interactedAt = (string?)"2026-08-30T10:00:00.0000000Z", + historyRowKey = (string?)null, // in-app entry → key vanishes + domainName = (string?)null, + }, + }, + }, + new FeedbackListResponse + { + Feedback = new[] + { + new FeedbackEntryWire + { + Type = "InApp", + Upn = "alice@contoso.com", + TenantId = "11111111-1111-1111-1111-111111111111", + DisplayName = "Alice", + Rating = 5, + Comment = "Great", + Dismissed = false, + Submitted = true, + InteractedAt = "2026-08-30T10:00:00.0000000Z", + HistoryRowKey = null, + DomainName = null, + }, + }, + }); + } + + // ── global/backups (custom serializer options) ─────────────────────────────────────── + + [Fact] + public void ListBackupsResponse_matches_the_anonymous_shape_under_backup_options() + { + var ids = new List { "20260831T010203Z_abcd1234" }; + var expected = JsonSerializer.Serialize(new { backupIds = ids }, BackupManifestJson.SerializerOptions); + var actual = JsonSerializer.Serialize(new ListBackupsResponse { BackupIds = ids }, BackupManifestJson.SerializerOptions); + Assert.Equal(expected, actual); + } + + // ── apps list / analytics / sessions ───────────────────────────────────────────────── + + [Fact] + public void AppsListResponse_matches_the_legacy_and_paged_shapes() + { + var item = new AppsListItem + { + AppName = "Contoso App", + AppType = "Win32", + TotalInstalls = 5, + Succeeded = 3, + Skipped = 1, + Unmeasured = 0, + Failed = 1, + FailureRate = 25.0, + AvgDurationSeconds = 60.0, + MaxDurationSeconds = 120, + AvgDownloadBytes = 1024L, + Trend = "stable", + TrendDelta = null, // under 5 finished installs in a half → key vanishes + LastSeenAt = new DateTime(2026, 8, 30, 9, 0, 0, DateTimeKind.Utc), + }; + var anonymousItem = new + { + appName = "Contoso App", + appType = "Win32", + totalInstalls = 5, + succeeded = 3, + skipped = 1, + unmeasured = 0, + failed = 1, + failureRate = 25.0, + avgDurationSeconds = 60.0, + maxDurationSeconds = 120, + avgDownloadBytes = 1024L, + trend = "stable", + trendDelta = (double?)null, + lastSeenAt = new DateTime(2026, 8, 30, 9, 0, 0, DateTimeKind.Utc), + }; + + // Legacy mode: no paging keys at all. + AssertWireIdentical( + new { success = true, totalApps = 1, totalInstalls = 5, collisionExcluded = 0, windowDays = 30, apps = new[] { anonymousItem } }, + new AppsListResponse + { + Success = true, + TotalApps = 1, + TotalInstalls = 5, + CollisionExcluded = 0, + WindowDays = 30, + Apps = new[] { item }, + }); + + // Paged mode incl. last-page nextLink omission. + AssertWireIdentical( + new + { + success = true, + totalApps = 1, + totalInstalls = 5, + collisionExcluded = 0, + windowDays = 30, + count = 1, + offset = 0, + pageSize = 100, + apps = new[] { anonymousItem }, + nextLink = (string?)null, + }, + new AppsListResponse + { + Success = true, + TotalApps = 1, + TotalInstalls = 5, + CollisionExcluded = 0, + WindowDays = 30, + Count = 1, + Offset = 0, + PageSize = 100, + Apps = new[] { item }, + NextLink = null, + }); + } + + [Fact] + public void AppAnalyticsResponse_matches_the_empty_window_shape() + { + var regressions = new List(); + AssertWireIdentical( + new + { + success = true, + appName = "Contoso App", + appType = string.Empty, + windowDays = 30, + collisionExcluded = 0, + bucket = "day", + summary = new + { + totalInstalls = 0, + succeeded = 0, + skipped = 0, + unmeasured = 0, + failed = 0, + failureRate = 0.0, + avgDurationSeconds = 0.0, + p95DurationSeconds = 0, + avgDownloadBytes = 0L, + trend = "stable", + trendDelta = (double?)null, + flakinessScore = 0.0, + }, + timeSeries = Array.Empty(), + versionBreakdown = Array.Empty(), + installerPhaseBreakdown = Array.Empty(), + topFailureCodes = Array.Empty(), + detectionLiesCount = 0, + deviceModelBreakdown = Array.Empty(), + versionRegressions = regressions, + }, + new AppAnalyticsResponse + { + Success = true, + AppName = "Contoso App", + AppType = string.Empty, + WindowDays = 30, + CollisionExcluded = 0, + Bucket = "day", + Summary = new AppAnalyticsSummary + { + TotalInstalls = 0, + Succeeded = 0, + Skipped = 0, + Unmeasured = 0, + Failed = 0, + FailureRate = 0.0, + AvgDurationSeconds = 0.0, + P95DurationSeconds = 0, + AvgDownloadBytes = 0L, + Trend = "stable", + TrendDelta = null, + FlakinessScore = 0.0, + }, + TimeSeries = Array.Empty(), + VersionBreakdown = Array.Empty(), + InstallerPhaseBreakdown = Array.Empty(), + TopFailureCodes = Array.Empty(), + DetectionLiesCount = 0, + DeviceModelBreakdown = Array.Empty(), + VersionRegressions = regressions, + }); + } + + [Fact] + public void AppAnalytics_inner_rows_match_their_anonymous_shapes() + { + var bucketStart = new DateTime(2026, 8, 29, 0, 0, 0, DateTimeKind.Utc); + var wire = ApiJsonOptions.Create(); + + Assert.Equal( + JsonSerializer.Serialize(new + { + bucketStart, + installs = 4, + succeeded = 3, + failed = 1, + failureRate = 25.0, + avgDurationSeconds = 90.0, + }, wire), + JsonSerializer.Serialize(new AppAnalyticsTimeBucket + { + BucketStart = bucketStart, + Installs = 4, + Succeeded = 3, + Failed = 1, + FailureRate = 25.0, + AvgDurationSeconds = 90.0, + }, wire)); + + Assert.Equal( + JsonSerializer.Serialize(new + { + appVersion = "1.2.3", + installs = 4, + failed = 1, + failureRate = 25.0, + measuredInstalls = 3, + medianDurationSeconds = 60, + p95DurationSeconds = 110, + }, wire), + JsonSerializer.Serialize(new AppVersionBreakdownItem + { + AppVersion = "1.2.3", + Installs = 4, + Failed = 1, + FailureRate = 25.0, + MeasuredInstalls = 3, + MedianDurationSeconds = 60, + P95DurationSeconds = 110, + }, wire)); + + Assert.Equal( + JsonSerializer.Serialize(new { phase = "DownloadStart", failed = 2 }, wire), + JsonSerializer.Serialize(new AppInstallerPhaseCount { Phase = "DownloadStart", Failed = 2 }, wire)); + + Assert.Equal( + JsonSerializer.Serialize(new + { + code = "0x80070005", + exitCode = (int?)5, + count = 2, + sampleMessage = "Access denied", + }, wire), + JsonSerializer.Serialize(new AppAnalyticsFailureCode + { + Code = "0x80070005", + ExitCode = 5, + Count = 2, + SampleMessage = "Access denied", + }, wire)); + + Assert.Equal( + JsonSerializer.Serialize(new + { + manufacturer = "Contoso", + model = "Book 3", + installs = 8, + failed = 2, + failureRate = 25.0, + liftVsBaseline = 1.5, + }, wire), + JsonSerializer.Serialize(new AppDeviceModelBreakdownItem + { + Manufacturer = "Contoso", + Model = "Book 3", + Installs = 8, + Failed = 2, + FailureRate = 25.0, + LiftVsBaseline = 1.5, + }, wire)); + } + + [Fact] + public void AppSessionsResponse_matches_the_anonymous_shape() + { + var started = new DateTime(2026, 8, 30, 7, 30, 0, DateTimeKind.Utc); + AssertWireIdentical( + new + { + success = true, + total = 1, + offset = 0, + limit = 50, + items = new[] + { + new + { + sessionId = "22222222-2222-2222-2222-222222222222", + tenantId = "11111111-1111-1111-1111-111111111111", + deviceName = "DESKTOP-1", + manufacturer = "Contoso", + model = "Book 3", + appVersion = "1.2.3", + status = "Failed", + installerPhase = "Install", + failureCode = "0x80070005", + exitCode = (int?)5, + attemptNumber = 2, + startedAt = started, + durationSeconds = 90, + installPassCount = 2, + }, + }, + }, + new AppSessionsResponse + { + Success = true, + Total = 1, + Offset = 0, + Limit = 50, + Items = new[] + { + new AppSessionItem + { + SessionId = "22222222-2222-2222-2222-222222222222", + TenantId = "11111111-1111-1111-1111-111111111111", + DeviceName = "DESKTOP-1", + Manufacturer = "Contoso", + Model = "Book 3", + AppVersion = "1.2.3", + Status = "Failed", + InstallerPhase = "Install", + FailureCode = "0x80070005", + ExitCode = 5, + AttemptNumber = 2, + StartedAt = started, + DurationSeconds = 90, + InstallPassCount = 2, + }, + }, + }); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TypedResponseGuardTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TypedResponseGuardTests.cs index 976f1055..6107c3a8 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/TypedResponseGuardTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TypedResponseGuardTests.cs @@ -47,6 +47,50 @@ public class TypedResponseGuardTests /// private static readonly Dictionary WriteBaseline = new(StringComparer.OrdinalIgnoreCase); + // ── Bypass-shape guards (closed 2026-08-31) ───────────────────────────────────────── + // Regexes A/B only see the INLINE literal `...(new { ... })`. Three bypass shapes let a + // response ship untyped anyway and are ratcheted here with EMPTY baselines: + // C: the literal parked in a variable first (`var result = new { ... };` → + // `WriteAsJsonAsync(result)`), which is how rule-stats escaped the 08-31 migration, + // D: hand-serialized bodies (`WriteStringAsync(JsonSerializer.Serialize(new { ... }))`), + // E: builder methods declared `object` / `Task` returning `new { ... }` + // (verdict-calibration's Build). A fourth shape — local `WriteJson(req, object)` + // wrappers — is closed structurally: wrappers take a `T : IApiResponse` generic. + + /// Identifier passed to WriteAsJsonAsync — flagged when the SAME file assigns that identifier an anonymous object. + private static readonly Regex WriteAsJsonIdentifier = + new(@"WriteAsJsonAsync\(\s*([A-Za-z_]\w*)\s*[,)]", RegexOptions.Compiled); + + private static readonly Regex SerializeAnonymous = + new(@"JsonSerializer\.Serialize(?:<[^>]+>)?\(\s*new\s*\{", RegexOptions.Compiled); + + private static readonly Regex ObjectReturningBuilder = + new(@"\b(?:object|Task)\s+\w*(?:Build|Compute|Payload|Response)\w*\s*\(", RegexOptions.Compiled); + + /// EMPTY — an anonymous object smuggled through a local variable is the same regression as an inline one. + private static readonly Dictionary IdentifierBaseline = new(StringComparer.OrdinalIgnoreCase); + + /// + /// JsonSerializer.Serialize(new { ... }) sites that are NOT responses. The single entry is + /// an outgoing HTTP CLIENT request body (App Insights query POST) — not response debt. + /// Everything else must be a typed DTO (error shapes are tolerated by the shape check). + /// + private static readonly Dictionary SerializeBaseline = new(StringComparer.OrdinalIgnoreCase) + { + ["Functions/Raw/AppInsightsQueryFunction.cs"] = 1, + }; + + /// + /// A Build*/Compute*/*Payload/*Response method declared object hides its wire shape from + /// every type check. The single tolerated site is BuildV2ResponseBody: its SUCCESS arm is + /// the typed SessionDeletionQueuedResponse, but the method returns a union with the + /// anonymous ERROR arms (tolerated by design), so its declared type must stay object. + /// + private static readonly Dictionary ObjectBuilderBaseline = new(StringComparer.OrdinalIgnoreCase) + { + ["Functions/Sessions/DeleteSessionFunction.cs"] = 1, + }; + [Fact] public void Anonymous_helper_success_bodies_never_exceed_the_frozen_baseline() { @@ -66,6 +110,42 @@ public class TypedResponseGuardTests "anonymous success WriteAsJsonAsync(new { ... })"); } + [Fact] + public void Anonymous_objects_smuggled_through_a_variable_into_WriteAsJsonAsync_are_flagged() + { + AssertRatchet( + text => WriteAsJsonIdentifier.Matches(text).Count(m => + { + var ident = m.Groups[1].Value; + if (ident == "new") return false; + var assign = new Regex(@"(?:var\s+)?" + Regex.Escape(ident) + @"\s*=\s*new\s*\{"); + var a = assign.Match(text); + // Tolerate error-shaped variables the same way inline literals are tolerated. + return a.Success && !IsErrorShape(text, a.Index + a.Length); + }), + IdentifierBaseline, + "WriteAsJsonAsync()"); + } + + [Fact] + public void Anonymous_JsonSerializer_Serialize_success_bodies_never_exceed_the_frozen_baseline() + { + AssertRatchet( + text => SerializeAnonymous.Matches(text) + .Count(m => !IsErrorShape(text, m.Index + m.Length)), + SerializeBaseline, + "anonymous success JsonSerializer.Serialize(new { ... })"); + } + + [Fact] + public void Object_returning_response_builders_are_flagged() + { + AssertRatchet( + text => ObjectReturningBuilder.Matches(text).Count, + ObjectBuilderBaseline, + "object/Task-returning Build*/Compute*/*Payload/*Response method"); + } + /// /// Wire DTOs must be flat: System.Text.Json serializes derived properties BEFORE base /// properties, so any base class would silently reorder JSON keys — and key order is part diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/VerdictCalibrationTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/VerdictCalibrationTests.cs index 6e52af24..0c2651bb 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/VerdictCalibrationTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/VerdictCalibrationTests.cs @@ -217,7 +217,7 @@ public class VerdictCalibrationTests private static readonly JsonSerializerOptions Wire = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; private static JsonElement BuildJson(IReadOnlyList daily, DateTime today, int days) - => JsonSerializer.SerializeToElement(VerdictCalibrationResponse.Build(daily, "global", today, days), Wire); + => JsonSerializer.SerializeToElement(VerdictCalibrationResponseBuilder.Build(daily, "global", today, days), Wire); [Fact] public void Response_sums_the_window_and_computes_share_rate_and_trend() @@ -297,5 +297,5 @@ public class VerdictCalibrationTests [InlineData("500", 180)] [InlineData("14", 14)] public void ClampDays_bounds_the_window(string? raw, int expected) - => Assert.Equal(expected, VerdictCalibrationResponse.ClampDays(raw)); + => Assert.Equal(expected, VerdictCalibrationResponseBuilder.ClampDays(raw)); } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantOffboardFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantOffboardFunction.cs index b3b0fbc8..89e0e821 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantOffboardFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantOffboardFunction.cs @@ -750,24 +750,8 @@ public class TenantOffboardFunction } } -/// -/// 202/200 response body for the offboarding endpoint. Fields point the caller at the -/// History row so subsequent reporting / status polling can resolve back to the audit -/// trail. drives the "data deletion starts in mm ss" -/// countdown in the Web UI's drain-barrier state. -/// -public class OffboardResponse -{ - public string TenantId { get; set; } = string.Empty; - public string Status { get; set; } = string.Empty; - public string HistoryPartitionKey { get; set; } = string.Empty; - public string HistoryRowKey { get; set; } = string.Empty; - public string Message { get; set; } = string.Empty; - - /// UTC timestamp before which the worker MUST NOT start Phase 2. Drives the - /// cache-drain-barrier countdown UI. Null on the idempotent-Completed/Failed branches. - public DateTime? EarliestProcessingAt { get; set; } -} +// OffboardResponse (the 202/200 offboarding body) moved to AutopilotMonitor.Shared.Models +// (Models/OffboardApiModels.cs) so the shared manifest exports it as a wire type. /// /// Decision the resume-path produced; consumed by diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/AppsAnalyticsHelper.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/AppsAnalyticsHelper.cs index 06df148d..b38c3b82 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/AppsAnalyticsHelper.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/AppsAnalyticsHelper.cs @@ -128,7 +128,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps /// (so an MCP caller can page a large fleet's app list). builds the /// route-specific nextLink for the next offset; it is only invoked when more pages remain. /// - public static object BuildAppsListResponse( + public static AppsListResponse BuildAppsListResponse( List allSummaries, int days, int? pageSize = null, @@ -161,40 +161,40 @@ namespace AutopilotMonitor.Functions.Functions.Apps var secondHalf = g.Where(s => s.StartedAt >= midpoint).ToList(); var (trend, trendDelta) = ComputeFailureTrend(firstHalf, secondHalf); - return new + return new AppsListItem { - appName = g.Key, - appType = g.Select(s => s.AppType).FirstOrDefault(t => !string.IsNullOrEmpty(t)) ?? string.Empty, - totalInstalls = total, - succeeded = installed.Count, - skipped, - unmeasured = installed.Count - measured.Count, - failed, - failureRate, - avgDurationSeconds = measured.Count > 0 ? Math.Round(measured.Average(s => s.DurationSeconds), 0) : 0, - maxDurationSeconds = measured.Count > 0 ? measured.Max(s => s.DurationSeconds) : 0, - avgDownloadBytes = measured.Count > 0 ? (long)measured.Average(s => s.DownloadBytes) : 0, - trend, - trendDelta, - lastSeenAt = g.Max(s => s.CompletedAt ?? s.StartedAt) + AppName = g.Key, + AppType = g.Select(s => s.AppType).FirstOrDefault(t => !string.IsNullOrEmpty(t)) ?? string.Empty, + TotalInstalls = total, + Succeeded = installed.Count, + Skipped = skipped, + Unmeasured = installed.Count - measured.Count, + Failed = failed, + FailureRate = failureRate, + AvgDurationSeconds = measured.Count > 0 ? Math.Round(measured.Average(s => s.DurationSeconds), 0) : 0, + MaxDurationSeconds = measured.Count > 0 ? measured.Max(s => s.DurationSeconds) : 0, + AvgDownloadBytes = measured.Count > 0 ? (long)measured.Average(s => s.DownloadBytes) : 0, + Trend = trend, + TrendDelta = trendDelta, + LastSeenAt = g.Max(s => s.CompletedAt ?? s.StartedAt) }; }) - .OrderByDescending(a => a.failed) - .ThenByDescending(a => a.failureRate) - .ThenBy(a => a.appName, StringComparer.OrdinalIgnoreCase) // deterministic tiebreaker for stable paging cursors + .OrderByDescending(a => a.Failed) + .ThenByDescending(a => a.FailureRate) + .ThenBy(a => a.AppName, StringComparer.OrdinalIgnoreCase) // deterministic tiebreaker for stable paging cursors .ToList(); // Legacy mode: caller did not opt into pagination → full array (web UI pages client-side). if (pageSize == null) { - return new + return new AppsListResponse { - success = true, - totalApps = apps.Count, - totalInstalls = summaries.Count, - collisionExcluded, - windowDays = days, - apps + Success = true, + TotalApps = apps.Count, + TotalInstalls = summaries.Count, + CollisionExcluded = collisionExcluded, + WindowDays = days, + Apps = apps }; } @@ -204,18 +204,18 @@ namespace AutopilotMonitor.Functions.Functions.Apps var nextOffset = offset + page.Count; var hasMore = nextOffset < apps.Count; - return new + return new AppsListResponse { - success = true, - totalApps = apps.Count, - totalInstalls = summaries.Count, - collisionExcluded, - windowDays = days, - count = page.Count, - offset, - pageSize = pageSize.Value, - apps = page, - nextLink = hasMore ? nextLinkForOffset?.Invoke(nextOffset) : null + Success = true, + TotalApps = apps.Count, + TotalInstalls = summaries.Count, + CollisionExcluded = collisionExcluded, + WindowDays = days, + Count = page.Count, + Offset = offset, + PageSize = pageSize.Value, + Apps = page, + NextLink = hasMore ? nextLinkForOffset?.Invoke(nextOffset) : null }; } @@ -225,7 +225,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps /// Builds the analytics response body for a single app. /// Loads sessions individually via the session repository for the device-model join. /// - public static async Task BuildAnalyticsResponseAsync( + public static async Task BuildAnalyticsResponseAsync( List allSummaries, ISessionRepository sessionRepo, string appName, @@ -252,38 +252,38 @@ namespace AutopilotMonitor.Functions.Functions.Apps if (summaries.Count == 0) { - return new + return new AppAnalyticsResponse { - success = true, - appName, - appType = string.Empty, - windowDays = days, - collisionExcluded, - bucket = "day", - summary = new + Success = true, + AppName = appName, + AppType = string.Empty, + WindowDays = days, + CollisionExcluded = collisionExcluded, + Bucket = "day", + Summary = new AppAnalyticsSummary { - totalInstalls = 0, - succeeded = 0, - skipped = 0, - unmeasured = 0, - failed = 0, - failureRate = 0, - avgDurationSeconds = 0, - p95DurationSeconds = 0, - avgDownloadBytes = 0, - trend = "stable", - trendDelta = (double?)null, - flakinessScore = 0.0 + TotalInstalls = 0, + Succeeded = 0, + Skipped = 0, + Unmeasured = 0, + Failed = 0, + FailureRate = 0, + AvgDurationSeconds = 0, + P95DurationSeconds = 0, + AvgDownloadBytes = 0, + Trend = "stable", + TrendDelta = null, + FlakinessScore = 0.0 }, - timeSeries = Array.Empty(), - versionBreakdown = Array.Empty(), - installerPhaseBreakdown = Array.Empty(), - topFailureCodes = Array.Empty(), - detectionLiesCount = 0, - deviceModelBreakdown = Array.Empty(), + TimeSeries = Array.Empty(), + VersionBreakdown = Array.Empty(), + InstallerPhaseBreakdown = Array.Empty(), + TopFailureCodes = Array.Empty(), + DetectionLiesCount = 0, + DeviceModelBreakdown = Array.Empty(), // Lockstep with the full response: episodes can outlive the window's data // (e.g. a shrunk days= selection), so the block is still surfaced here. - versionRegressions = appVersionRegressions + VersionRegressions = appVersionRegressions }; } @@ -329,38 +329,38 @@ namespace AutopilotMonitor.Functions.Functions.Apps .Where(MetricsMath.HasMeasuredDuration) .Select(s => s.DurationSeconds) .ToList(); - return new + return new AppVersionBreakdownItem { - appVersion = g.Key, - installs = vTotal, - failed = vFailed, - failureRate = MetricsMath.TerminalFailureRatePct(vFailed, vSucceeded), - measuredInstalls = vDurations.Count, - medianDurationSeconds = Percentile(vDurations, 0.50), - p95DurationSeconds = Percentile(vDurations, 0.95) + AppVersion = g.Key, + Installs = vTotal, + Failed = vFailed, + FailureRate = MetricsMath.TerminalFailureRatePct(vFailed, vSucceeded), + MeasuredInstalls = vDurations.Count, + MedianDurationSeconds = Percentile(vDurations, 0.50), + P95DurationSeconds = Percentile(vDurations, 0.95) }; }) - .OrderByDescending(v => v.installs) + .OrderByDescending(v => v.Installs) .ToList(); var installerPhaseBreakdown = summaries .Where(s => s.Status == "Failed" && !string.IsNullOrEmpty(s.InstallerPhase)) .GroupBy(s => s.InstallerPhase) - .Select(g => new { phase = g.Key, failed = g.Count() }) - .OrderByDescending(p => p.failed) + .Select(g => new AppInstallerPhaseCount { Phase = g.Key, Failed = g.Count() }) + .OrderByDescending(p => p.Failed) .ToList(); var topFailureCodes = summaries .Where(s => s.Status == "Failed" && !string.IsNullOrEmpty(s.FailureCode)) .GroupBy(s => s.FailureCode) - .Select(g => new + .Select(g => new AppAnalyticsFailureCode { - code = g.Key, - exitCode = g.Select(s => s.ExitCode).FirstOrDefault(e => e.HasValue), - count = g.Count(), - sampleMessage = g.Select(s => s.FailureMessage).FirstOrDefault(m => !string.IsNullOrEmpty(m)) ?? string.Empty + Code = g.Key, + ExitCode = g.Select(s => s.ExitCode).FirstOrDefault(e => e.HasValue), + Count = g.Count(), + SampleMessage = g.Select(s => s.FailureMessage).FirstOrDefault(m => !string.IsNullOrEmpty(m)) ?? string.Empty }) - .OrderByDescending(f => f.count) + .OrderByDescending(f => f.Count) .Take(5) .ToList(); @@ -397,57 +397,57 @@ namespace AutopilotMonitor.Functions.Functions.Apps var lift = failureRate > 0 ? Math.Round(modelFailureRate / failureRate, 2) : 0; - return new + return new AppDeviceModelBreakdownItem { - manufacturer = g.Key.Manufacturer, - model = g.Key.Model, - installs = modelTotal, - failed = modelFailed, - failureRate = modelFailureRate, - liftVsBaseline = lift + Manufacturer = g.Key.Manufacturer, + Model = g.Key.Model, + Installs = modelTotal, + Failed = modelFailed, + FailureRate = modelFailureRate, + LiftVsBaseline = lift }; }) - .OrderByDescending(m => m.liftVsBaseline) + .OrderByDescending(m => m.LiftVsBaseline) .ToList(); var appType = summaries.Select(s => s.AppType).FirstOrDefault(t => !string.IsNullOrEmpty(t)) ?? string.Empty; - return new + return new AppAnalyticsResponse { - success = true, - appName, - appType, - windowDays = days, - collisionExcluded, - bucket, - summary = new + Success = true, + AppName = appName, + AppType = appType, + WindowDays = days, + CollisionExcluded = collisionExcluded, + Bucket = bucket, + Summary = new AppAnalyticsSummary { - totalInstalls = total, - succeeded, - skipped, - unmeasured = installed.Count - measured.Count, - failed, - failureRate, - avgDurationSeconds, - p95DurationSeconds, - avgDownloadBytes, - trend, - trendDelta, - flakinessScore + TotalInstalls = total, + Succeeded = succeeded, + Skipped = skipped, + Unmeasured = installed.Count - measured.Count, + Failed = failed, + FailureRate = failureRate, + AvgDurationSeconds = avgDurationSeconds, + P95DurationSeconds = p95DurationSeconds, + AvgDownloadBytes = avgDownloadBytes, + Trend = trend, + TrendDelta = trendDelta, + FlakinessScore = flakinessScore }, - timeSeries, - versionBreakdown, - installerPhaseBreakdown, - topFailureCodes, - detectionLiesCount, - deviceModelBreakdown, - versionRegressions = appVersionRegressions + TimeSeries = timeSeries, + VersionBreakdown = versionBreakdown, + InstallerPhaseBreakdown = installerPhaseBreakdown, + TopFailureCodes = topFailureCodes, + DetectionLiesCount = detectionLiesCount, + DeviceModelBreakdown = deviceModelBreakdown, + VersionRegressions = appVersionRegressions }; } // ── /apps/{appName}/sessions ──────────────────────────────────────── - public static async Task BuildSessionsResponseAsync( + public static async Task BuildSessionsResponseAsync( List allSummaries, ISessionRepository sessionRepo, string appName, @@ -499,34 +499,34 @@ namespace AutopilotMonitor.Functions.Functions.Apps var items = page.Select(s => { sessionLookup.TryGetValue($"{s.TenantId}|{s.SessionId}", out var sess); - return new + return new AppSessionItem { - sessionId = s.SessionId, - tenantId = s.TenantId, - deviceName = sess?.DeviceName ?? string.Empty, - manufacturer = sess?.Manufacturer ?? string.Empty, - model = sess?.Model ?? string.Empty, - appVersion = s.AppVersion, - status = s.Status, - installerPhase = s.InstallerPhase, - failureCode = s.FailureCode, - exitCode = s.ExitCode, - attemptNumber = s.AttemptNumber, - startedAt = s.StartedAt, - durationSeconds = s.DurationSeconds, + SessionId = s.SessionId, + TenantId = s.TenantId, + DeviceName = sess?.DeviceName ?? string.Empty, + Manufacturer = sess?.Manufacturer ?? string.Empty, + Model = sess?.Model ?? string.Empty, + AppVersion = s.AppVersion, + Status = s.Status, + InstallerPhase = s.InstallerPhase, + FailureCode = s.FailureCode, + ExitCode = s.ExitCode, + AttemptNumber = s.AttemptNumber, + StartedAt = s.StartedAt, + DurationSeconds = s.DurationSeconds, // 2+ = the IME processed this app in multiple passes (device-ESP // evaluation + real install) — explains a completion far after startedAt. - installPassCount = s.InstallPassCount + InstallPassCount = s.InstallPassCount }; }).ToList(); - return new + return new AppSessionsResponse { - success = true, - total, - offset, - limit, - items + Success = true, + Total = total, + Offset = offset, + Limit = limit, + Items = items }; } @@ -559,7 +559,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps return (trend, delta); } - private static List BuildTimeSeries(List summaries, DateTime cutoff, DateTime now, string bucket) + private static List BuildTimeSeries(List summaries, DateTime cutoff, DateTime now, string bucket) { var start = bucket == "week" ? StartOfWeek(cutoff) : cutoff.Date; var end = now.Date; @@ -589,14 +589,14 @@ namespace AutopilotMonitor.Functions.Functions.Apps // PR0 convention: skips are not attempts; durations read measured rows only. var bInstalled = items.Where(s => s.Status == "Succeeded" && !MetricsMath.IsSkipTerminalState(s)).ToList(); var bMeasured = bInstalled.Where(MetricsMath.HasMeasuredDuration).ToList(); - return (object)new + return new AppAnalyticsTimeBucket { - bucketStart = DateTime.SpecifyKind(kv.Key, DateTimeKind.Utc), - installs = bTotal, - succeeded = bInstalled.Count, - failed = bFailed, - failureRate = MetricsMath.TerminalFailureRatePct(bFailed, bInstalled.Count), - avgDurationSeconds = bMeasured.Count > 0 ? Math.Round(bMeasured.Average(s => s.DurationSeconds), 0) : 0 + BucketStart = DateTime.SpecifyKind(kv.Key, DateTimeKind.Utc), + Installs = bTotal, + Succeeded = bInstalled.Count, + Failed = bFailed, + FailureRate = MetricsMath.TerminalFailureRatePct(bFailed, bInstalled.Count), + AvgDurationSeconds = bMeasured.Count > 0 ? Math.Round(bMeasured.Average(s => s.DurationSeconds), 0) : 0 }; }) .ToList(); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Backup/ListBackupsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Backup/ListBackupsFunction.cs index 8333237c..679fa4f0 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Backup/ListBackupsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Backup/ListBackupsFunction.cs @@ -43,7 +43,7 @@ namespace AutopilotMonitor.Functions.Functions.Backup var response = req.CreateResponse(HttpStatusCode.OK); response.Headers.Add("Content-Type", "application/json; charset=utf-8"); - var json = JsonSerializer.Serialize(new { backupIds = ids }, BackupManifestJson.SerializerOptions); + var json = JsonSerializer.Serialize(new ListBackupsResponse { BackupIds = ids }, BackupManifestJson.SerializerOptions); await response.WriteStringAsync(json); return response; } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetTenantFeatureFlagsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetTenantFeatureFlagsFunction.cs index 4b9d673b..89849261 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetTenantFeatureFlagsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetTenantFeatureFlagsFunction.cs @@ -70,56 +70,56 @@ namespace AutopilotMonitor.Functions.Functions.Config /// without standing up an HttpRequestData mock. feeds the /// read-time edition resolution (trial expiry degrades automatically). /// - internal static object BuildPayload(TenantConfiguration config, DateTime nowUtc, bool appHomingFunnelActive = false) + internal static TenantFeatureFlagsResponse BuildPayload(TenantConfiguration config, DateTime nowUtc, bool appHomingFunnelActive = false) { var edition = FeatureEntitlementCatalog.ResolveEdition(config.PlanTier, config.TrialExpiresUtc, nowUtc); var entitlements = FeatureEntitlementCatalog.Get(edition); var isTrial = edition == TenantEdition.Pro && !FeatureEntitlementCatalog.IsPermanentProTier(config.PlanTier); - return new + return new TenantFeatureFlagsResponse { // EFFECTIVE bootstrap availability (Pro includes it; the GA flag is the additive // Community enable) — field name kept for web compatibility. - bootstrapTokenEnabled = TenantEntitlementService.IsBootstrapEnabled(config, nowUtc), + BootstrapTokenEnabled = TenantEntitlementService.IsBootstrapEnabled(config, nowUtc), // Session-detail "Collect Logs" button: whether an on-demand diagnostics upload can // succeed right now (mode not Off + a usable destination). Members below Admin cannot // read the full config, so this boolean is their only signal. Deliberately exposes no // destination detail — just "would an upload work". - diagnosticsUploadConfigured = DiagnosticsUploadConfigChange.IsConfigured(config), + DiagnosticsUploadConfigured = DiagnosticsUploadConfigChange.IsConfigured(config), // Drives the "Autopilot Device Validation disabled" dashboard banner // (useTenantSecurityConfig). - validateAutopilotDevice = config.ValidateAutopilotDevice, + ValidateAutopilotDevice = config.ValidateAutopilotDevice, // Dual app-reg self-service migration: when true, running the consent flow (or // "Detect existing access") targets the NEW app registration and auto-flips this // tenant's homing after verification — drives the explanatory banner in the // Autopilot Validation settings section. Non-sensitive: exposes no client ids. - appHomingFunnelActive, + AppHomingFunnelActive = appHomingFunnelActive, // Session-detail UI flags (useSessionTenantConfig). Nullable in the model; // surface the agent-side defaults so the UI does not need a second nullable layer. - showScriptOutput = config.ShowScriptOutput ?? true, - enableSoftwareInventoryAnalyzer = config.EnableSoftwareInventoryAnalyzer ?? false, - enableIntegrityBypassAnalyzer = config.EnableIntegrityBypassAnalyzer ?? true, + ShowScriptOutput = config.ShowScriptOutput ?? true, + EnableSoftwareInventoryAnalyzer = config.EnableSoftwareInventoryAnalyzer ?? false, + EnableIntegrityBypassAnalyzer = config.EnableIntegrityBypassAnalyzer ?? true, // Gather-rules page validation indicator. EFFECTIVE value (requires Pro edition + // GA gate + tenant toggle) — the privileged toggle is UnrestrictedModeEnabled // (admin-only, stays in the full config response). - unrestrictedMode = TenantEntitlementService.IsUnrestrictedModeActive(config, nowUtc), + UnrestrictedMode = TenantEntitlementService.IsUnrestrictedModeActive(config, nowUtc), // Edition/entitlement surface (read-time resolution — non-sensitive by design): // drives the EditionBadge, trial CTA and retention hint in the web UI. - edition = edition.ToString().ToLowerInvariant(), - isTrial, - trialExpiresUtc = isTrial ? config.TrialExpiresUtc : null, - trialAvailable = !config.TrialConsumed && edition == TenantEdition.Community, + Edition = edition.ToString().ToLowerInvariant(), + IsTrial = isTrial, + TrialExpiresUtc = isTrial ? config.TrialExpiresUtc : null, + TrialAvailable = !config.TrialConsumed && edition == TenantEdition.Community, // Pro-requires-contact surface: drives the trial CTA gate and the dashboard // "set a contact address" banner for Pro tenants. Boolean only — the address // itself stays in the admin-gated full config response. - contactEmailSet = !string.IsNullOrWhiteSpace(config.ContactEmail), - entitlements = new + ContactEmailSet = !string.IsNullOrWhiteSpace(config.ContactEmail), + Entitlements = new TenantFeatureEntitlements { - retentionCapDays = entitlements.RetentionCapDays, - userRateLimitPerMinute = entitlements.UserRateLimitPerMinute, - delegatedAdminAllowed = entitlements.DelegatedAdminAllowed, - mcpUsagePlan = entitlements.McpUsagePlanName + RetentionCapDays = entitlements.RetentionCapDays, + UserRateLimitPerMinute = entitlements.UserRateLimitPerMinute, + DelegatedAdminAllowed = entitlements.DelegatedAdminAllowed, + McpUsagePlan = entitlements.McpUsagePlanName } }; } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Diagnostics/GetDiagnosticsPathsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Diagnostics/GetDiagnosticsPathsFunction.cs index c6546601..a6904a14 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Diagnostics/GetDiagnosticsPathsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Diagnostics/GetDiagnosticsPathsFunction.cs @@ -59,19 +59,19 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics /// condition travels as the enum NAME ("Always" | "RealmJoinWatcher" | /// "DevicePreparation"), never the integer — the web switches on the string. /// - internal static object BuildPayload(AdminConfiguration adminConfig) => new + internal static DiagnosticsPathsResponse BuildPayload(AdminConfiguration adminConfig) => new() { - builtIn = DiagnosticsBuiltInSections.All.Select(s => new + BuiltIn = DiagnosticsBuiltInSections.All.Select(s => new DiagnosticsBuiltInSectionWire { - id = s.Id, - zipFolder = s.ZipFolder, - sourceFolder = s.SourceFolder, - patterns = s.Patterns, - includeSubfolders = s.IncludeSubfolders, - description = s.Description, - condition = s.Condition.ToString(), + Id = s.Id, + ZipFolder = s.ZipFolder, + SourceFolder = s.SourceFolder, + Patterns = s.Patterns, + IncludeSubfolders = s.IncludeSubfolders, + Description = s.Description, + Condition = s.Condition.ToString(), }).ToArray(), - globalPaths = adminConfig.GetDiagnosticsGlobalLogPaths(), + GlobalPaths = adminConfig.GetDiagnosticsGlobalLogPaths(), }; } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Feedback/FeedbackFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Feedback/FeedbackFunction.cs index 994a3b45..396fff3a 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Feedback/FeedbackFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Feedback/FeedbackFunction.cs @@ -58,7 +58,7 @@ namespace AutopilotMonitor.Functions.Functions.Feedback // four extra Table reads on every poll from every active user. var adminConfig = await _adminConfigService.GetConfigurationAsync(); if (!adminConfig.FeedbackEnabled) - return await WriteJson(req, new { eligible = false }); + return await WriteJson(req, new FeedbackEligibilityResponse { Eligible = false }); // 2. User identity string tenantId = TenantHelper.GetTenantId(req); @@ -86,17 +86,17 @@ namespace AutopilotMonitor.Functions.Functions.Feedback var roleInfo = EntraAppRoleResolver.Resolve( tableState, tableRole, context.GetUser()?.GetAppRoles(), tenantConfig.EntraAppRolesEnabled); if (roleInfo == null || roleInfo.Role == Constants.TenantRoles.Viewer) - return await WriteJson(req, new { eligible = false }); + return await WriteJson(req, new FeedbackEligibilityResponse { Eligible = false }); // 4. Tenant age check if (tenantConfig.OnboardedAt == null || (DateTime.UtcNow - tenantConfig.OnboardedAt.Value).TotalDays < adminConfig.FeedbackMinTenantAgeDays) - return await WriteJson(req, new { eligible = false }); + return await WriteJson(req, new FeedbackEligibilityResponse { Eligible = false }); // 5. Sessions check — at least 1 session exists. var sessionPage = sessionsTask.Result; if (sessionPage.Items.Count == 0) - return await WriteJson(req, new { eligible = false }); + return await WriteJson(req, new FeedbackEligibilityResponse { Eligible = false }); // 6. Cooldown check var feedbackEntry = feedbackTask.Result; @@ -104,14 +104,14 @@ namespace AutopilotMonitor.Functions.Functions.Feedback { // Cooldown = 0 means single wave only if (adminConfig.FeedbackCooldownDays == 0) - return await WriteJson(req, new { eligible = false }); + return await WriteJson(req, new FeedbackEligibilityResponse { Eligible = false }); var daysSinceInteraction = (DateTime.UtcNow - feedbackEntry.InteractedAt.Value).TotalDays; if (daysSinceInteraction < adminConfig.FeedbackCooldownDays) - return await WriteJson(req, new { eligible = false }); + return await WriteJson(req, new FeedbackEligibilityResponse { Eligible = false }); } - return await WriteJson(req, new { eligible = true }); + return await WriteJson(req, new FeedbackEligibilityResponse { Eligible = true }); } catch (UnauthorizedAccessException) { @@ -122,7 +122,7 @@ namespace AutopilotMonitor.Functions.Functions.Feedback { _logger.LogError(ex, "Error checking feedback status"); // Fail closed — don't show bubble on errors - return await WriteJson(req, new { eligible = false }); + return await WriteJson(req, new FeedbackEligibilityResponse { Eligible = false }); } } @@ -197,7 +197,7 @@ namespace AutopilotMonitor.Functions.Functions.Feedback _logger.LogInformation("Feedback {Action} by {Upn} (tenant {TenantId})", body.Dismissed ? "dismissed" : "submitted", upn, tenantId); - return await WriteJson(req, new { success = true }); + return await WriteJson(req, new SuccessOnlyResponse { Success = true }); } catch (UnauthorizedAccessException) { @@ -222,22 +222,22 @@ namespace AutopilotMonitor.Functions.Functions.Feedback try { var allEntries = await _feedbackRepo.GetAllAsync(); - var entries = allEntries.Select(e => new + var entries = allEntries.Select(e => new FeedbackEntryWire { - type = e.Type, - upn = e.Upn, - tenantId = e.TenantId, - displayName = e.DisplayName, - rating = e.Rating, - comment = e.Comment, - dismissed = e.Dismissed, - submitted = e.Submitted, - interactedAt = e.InteractedAt?.ToString("o"), - historyRowKey = e.HistoryRowKey, - domainName = e.DomainName, + Type = e.Type, + Upn = e.Upn, + TenantId = e.TenantId, + DisplayName = e.DisplayName, + Rating = e.Rating, + Comment = e.Comment, + Dismissed = e.Dismissed, + Submitted = e.Submitted, + InteractedAt = e.InteractedAt?.ToString("o"), + HistoryRowKey = e.HistoryRowKey, + DomainName = e.DomainName, }).ToList(); - return await WriteJson(req, new { feedback = entries }); + return await WriteJson(req, new FeedbackListResponse { Feedback = entries }); } catch (Exception ex) { @@ -248,7 +248,11 @@ namespace AutopilotMonitor.Functions.Functions.Feedback } } - private static async Task WriteJson(HttpRequestData req, object data) + // Generic with an IApiResponse constraint (NOT an IApiResponse parameter): success + // bodies through this wrapper are typed by construction — an anonymous body no longer + // compiles — while the CONCRETE type still reaches the serializer (a declared interface + // type would serialize zero properties under System.Text.Json). + private static async Task WriteJson(HttpRequestData req, T data) where T : IApiResponse { var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(data); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetDeviceJourneyFunctions.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetDeviceJourneyFunctions.cs index 6f41a805..41887030 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetDeviceJourneyFunctions.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetDeviceJourneyFunctions.cs @@ -129,10 +129,10 @@ namespace AutopilotMonitor.Functions.Functions.Metrics // Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware var tenantId = TenantHelper.GetTenantId(req); var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query); - var days = DeviceJourneyMetricsResponse.ClampDays(query["days"]); + var days = DeviceJourneyMetricsResponseBuilder.ClampDays(query["days"]); var response = req.CreateResponse(HttpStatusCode.OK); - await response.WriteAsJsonAsync(await DeviceJourneyMetricsResponse.BuildAsync( + await response.WriteAsJsonAsync(await DeviceJourneyMetricsResponseBuilder.BuildAsync( _metricsRepo, _sessionRepo, tenantId, days, includeRepeatDevices: true)); return response; } @@ -177,11 +177,11 @@ namespace AutopilotMonitor.Functions.Functions.Metrics // Authentication + GlobalReadOrAdmin authorization enforced by PolicyEnforcementMiddleware var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query); var tenantIdFilter = query["tenantId"]; - var days = DeviceJourneyMetricsResponse.ClampDays(query["days"]); + var days = DeviceJourneyMetricsResponseBuilder.ClampDays(query["days"]); var partition = string.IsNullOrWhiteSpace(tenantIdFilter) ? "global" : tenantIdFilter!; var response = req.CreateResponse(HttpStatusCode.OK); - await response.WriteAsJsonAsync(await DeviceJourneyMetricsResponse.BuildAsync( + await response.WriteAsJsonAsync(await DeviceJourneyMetricsResponseBuilder.BuildAsync( _metricsRepo, _sessionRepo, partition, days, includeRepeatDevices: partition != "global")); return response; } @@ -195,8 +195,13 @@ namespace AutopilotMonitor.Functions.Functions.Metrics } } - /// Shared response shape for the tenant and global variants (mirrors TimeAttributionResponse). - internal static class DeviceJourneyMetricsResponse + /// + /// Shared response builder for the tenant and global variants (mirrors + /// TimeAttributionResponseBuilder). The envelope/row DTOs + /// (, , + /// ) live in Shared so the manifest exports them. + /// + internal static class DeviceJourneyMetricsResponseBuilder { internal const int DefaultWindowDays = 30; internal const int MaxWindowDays = 180; // aggregate retention — older rows no longer exist @@ -215,7 +220,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics /// internal static DateTime InclusiveWindowStart(DateTime today, int days) => today.AddDays(-(days - 1)); - internal static async Task BuildAsync( + internal static async Task BuildAsync( IMetricsRepository metricsRepo, ISessionRepository sessionRepo, string partition, int days, bool includeRepeatDevices) { @@ -223,12 +228,12 @@ namespace AutopilotMonitor.Functions.Functions.Metrics var daily = await metricsRepo.GetDeviceJourneyAggregatesAsync(partition, InclusiveWindowStart(today, days), today); var totals = SumAggregates(daily); - List? repeatDevices = null; + List? repeatDevices = null; if (includeRepeatDevices) { var histories = await metricsRepo.GetDeviceHistoriesByTenantAsync(partition); var candidates = SelectRepeatDevices(histories, today.AddDays(-days)); - repeatDevices = new List(candidates.Count); + repeatDevices = new List(candidates.Count); foreach (var candidate in candidates) { // Failure reason of the newest failed attempt — a bounded point-read per top @@ -246,44 +251,31 @@ namespace AutopilotMonitor.Functions.Functions.Metrics // fail-soft — the row renders without a reason } } - repeatDevices.Add(new + repeatDevices.Add(new DeviceJourneyRepeatDevice { - serialNumber = candidate.History.SerialNumber, - manufacturer = candidate.History.Manufacturer, - model = candidate.History.Model, - attempts = candidate.History.CurrentJourneyAttempts, - journeyCount = candidate.History.JourneyCount, - lastStatus = candidate.Newest.Status, - lastSessionId = candidate.Newest.SessionId, - lastStartedAt = candidate.Newest.StartedAt, - lastFailureReason, + SerialNumber = candidate.History.SerialNumber, + Manufacturer = candidate.History.Manufacturer, + Model = candidate.History.Model, + Attempts = candidate.History.CurrentJourneyAttempts, + JourneyCount = candidate.History.JourneyCount, + LastStatus = candidate.Newest.Status, + LastSessionId = candidate.Newest.SessionId, + LastStartedAt = candidate.Newest.StartedAt, + LastFailureReason = lastFailureReason, }); } } - return new + return new DeviceJourneyMetricsResponse { - success = true, - windowDays = days, - totals, - daily = daily.OrderBy(d => d.Date, StringComparer.Ordinal).ToList(), - repeatDevices, + Success = true, + WindowDays = days, + Totals = totals, + Daily = daily.OrderBy(d => d.Date, StringComparer.Ordinal).ToList(), + RepeatDevices = repeatDevices, }; } - /// Window totals (serialized camelCase by the worker's global serializer, like the model rows). - internal sealed class DeviceJourneyWindowTotals - { - public int CompletedJourneys { get; init; } - public int FirstTimeRight { get; init; } - - /// Null with zero completed journeys — no rate claim, never 0 (truthfulness rule 1). - public double? FtrRatePct { get; init; } - - public int ExcludedSessions { get; init; } - public List AttemptHistogram { get; init; } = new(); - } - /// /// Window sums over the daily rows — honest because every field is an additive count /// (rule: the FTR window rate IS the ratio of summed counts). internal static: pinned diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetImeVersionHistoryFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetImeVersionHistoryFunction.cs index 87dcc203..45ac28c4 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetImeVersionHistoryFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetImeVersionHistoryFunction.cs @@ -66,11 +66,12 @@ namespace AutopilotMonitor.Functions.Functions.Metrics /// Global scope sees every row, unconfirmed ones included — that is where a bogus claim /// is investigated. /// - /// Returned as 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. + /// Returned as IReadOnlyList<object> — System.Text.Json serializes each + /// element using its RUNTIME type, so both branches keep the exact wire format they + /// had while the projection was inline. Both element types are manifest-exported + /// ( / ). /// - public static object BuildResponsePayload( + public static IReadOnlyList BuildResponsePayload( IEnumerable versions, bool hasGlobalScope) { if (hasGlobalScope) @@ -80,7 +81,13 @@ namespace AutopilotMonitor.Functions.Functions.Metrics return versions .Where(IsFleetConfirmed) - .Select(v => new { v.Version, v.FirstSeenAt, v.LastSeenAt, v.SessionCount }) + .Select(v => new ImeVersionHistoryLeanEntry + { + Version = v.Version, + FirstSeenAt = v.FirstSeenAt, + LastSeenAt = v.LastSeenAt, + SessionCount = v.SessionCount, + }) .ToList(); } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetTimeAttributionFunctions.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetTimeAttributionFunctions.cs index 54186336..aec724f3 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetTimeAttributionFunctions.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetTimeAttributionFunctions.cs @@ -93,7 +93,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics // Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware var tenantId = TenantHelper.GetTenantId(req); var response = req.CreateResponse(HttpStatusCode.OK); - await response.WriteAsJsonAsync(await TimeAttributionResponse.BuildAsync(_metricsRepo, tenantId)); + await response.WriteAsJsonAsync(await TimeAttributionResponseBuilder.BuildAsync(_metricsRepo, tenantId)); return response; } catch (Exception ex) @@ -132,7 +132,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics var partition = string.IsNullOrWhiteSpace(tenantIdFilter) ? "global" : tenantIdFilter!; var response = req.CreateResponse(HttpStatusCode.OK); - await response.WriteAsJsonAsync(await TimeAttributionResponse.BuildAsync(_metricsRepo, partition)); + await response.WriteAsJsonAsync(await TimeAttributionResponseBuilder.BuildAsync(_metricsRepo, partition)); return response; } catch (Exception ex) @@ -145,8 +145,12 @@ namespace AutopilotMonitor.Functions.Functions.Metrics } } - /// Shared response shape for the tenant and global variants (mirrors the MetricsMath single-source pattern). - internal static class TimeAttributionResponse + /// + /// Shared response builder for the tenant and global variants (mirrors the MetricsMath + /// single-source pattern). The envelope DTO () + /// lives in Shared so the manifest exports it. + /// + internal static class TimeAttributionResponseBuilder { internal const int WindowDays = 30; @@ -164,7 +168,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics /// internal static DateTime InclusiveWindowStart(DateTime today, int days) => today.AddDays(-(days - 1)); - internal static async Task BuildAsync(IMetricsRepository metricsRepo, string partition) + internal static async Task BuildAsync(IMetricsRepository metricsRepo, string partition) { var freshCutoff = DateTime.UtcNow - RollingMaxAge; var rolling = (await metricsRepo.GetRollingTimeAttributionAggregatesAsync(partition)) @@ -173,14 +177,14 @@ namespace AutopilotMonitor.Functions.Functions.Metrics var today = DateTime.UtcNow.Date; var daily = await metricsRepo.GetTimeAttributionAggregatesAsync(partition, InclusiveWindowStart(today, WindowDays), today); - return new + return new TimeAttributionMetricsResponse { - success = true, - windowDays = WindowDays, + Success = true, + WindowDays = WindowDays, // Range statistics per enrollment class (never mixed) — the UI gates rendering // at cleanSessionCount >= 20 and shows "insufficient data (n=…)" below it. - classes = rolling.OrderBy(r => r.EnrollmentClass, StringComparer.Ordinal).ToList(), - daily = daily.OrderBy(d => d.Date, StringComparer.Ordinal).ToList(), + Classes = rolling.OrderBy(r => r.EnrollmentClass, StringComparer.Ordinal).ToList(), + Daily = daily.OrderBy(d => d.Date, StringComparer.Ordinal).ToList(), }; } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetVerdictCalibrationFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetVerdictCalibrationFunction.cs index 9116abc6..7debc494 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetVerdictCalibrationFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetVerdictCalibrationFunction.cs @@ -42,7 +42,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics // Authentication + GlobalReadOrAdmin authorization enforced by PolicyEnforcementMiddleware var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query); var tenantIdFilter = query["tenantId"]; - var days = VerdictCalibrationResponse.ClampDays(query["days"]); + var days = VerdictCalibrationResponseBuilder.ClampDays(query["days"]); var partition = "global"; if (!string.IsNullOrWhiteSpace(tenantIdFilter)) { @@ -57,14 +57,14 @@ namespace AutopilotMonitor.Functions.Functions.Metrics var today = DateTime.UtcNow.Date; // One read covers both the requested window and the trend horizon (7d window + 28d baseline). - var readDays = Math.Max(days, VerdictCalibrationResponse.TrendHorizonDays); + var readDays = Math.Max(days, VerdictCalibrationResponseBuilder.TrendHorizonDays); var daily = await _metricsRepo.GetVerdictCalibrationAggregatesAsync( - partition, VerdictCalibrationResponse.InclusiveWindowStart(today, readDays), today); + partition, VerdictCalibrationResponseBuilder.InclusiveWindowStart(today, readDays), today); // Active drift episodes of this partition (tracker keyspace; fail-soft empty). var alerts = await _tracker.GetVerdictCalibrationAlertsAsync(partition); var response = req.CreateResponse(HttpStatusCode.OK); - await response.WriteAsJsonAsync(VerdictCalibrationResponse.Build(daily, partition, today, days, alerts)); + await response.WriteAsJsonAsync(VerdictCalibrationResponseBuilder.Build(daily, partition, today, days, alerts)); return response; } catch (Exception ex) @@ -77,8 +77,12 @@ namespace AutopilotMonitor.Functions.Functions.Metrics } } - /// Pure response builder — internal static so the matrix arithmetic is pinned by tests. - internal static class VerdictCalibrationResponse + /// + /// Pure response builder — internal static so the matrix arithmetic is pinned by tests. + /// The row/envelope DTOs live in AutopilotMonitor.Shared.Models + /// (Models/Metrics/VerdictCalibrationApiModels.cs) so the manifest exports them. + /// + internal static class VerdictCalibrationResponseBuilder { internal const int DefaultWindowDays = 30; internal const int MaxWindowDays = 180; // aggregate retention @@ -95,37 +99,10 @@ namespace AutopilotMonitor.Functions.Functions.Metrics /// "Last N days" = exactly N calendar day keys including today (both range ends inclusive). internal static DateTime InclusiveWindowStart(DateTime today, int days) => today.AddDays(-(days - 1)); - internal sealed class PathRow - { - public string VerdictPath { get; set; } = string.Empty; - public string Status { get; set; } = string.Empty; - public int Count { get; set; } - public double SharePct { get; set; } - public int DerivedCount { get; set; } - public int Eligible7d { get; set; } - public int ReEnrolled7d { get; set; } - /// Null below the minimum eligible count — never a rate on a handful of sessions. - public double? ReEnrollRatePct { get; set; } - public int OverriddenByAdmin { get; set; } - public int OverriddenByLateCompletion { get; set; } - public int OverriddenOther { get; set; } - public TrendWindow Window7 { get; set; } = new(); - public TrendWindow Baseline28 { get; set; } = new(); - /// Window share ÷ baseline share; null when the baseline share is 0 (a new path has no finite lift — never invented). - public double? Lift { get; set; } - } - - internal sealed class TrendWindow - { - public int Count { get; set; } - public int Sessions { get; set; } - public double SharePct { get; set; } - } - /// Minimum eligible sessions before a re-enrollment rate is stated (truthfulness: no rate on n<20). internal const int MinEligibleForRate = 20; - internal static object Build( + internal static VerdictCalibrationResponse Build( IReadOnlyList daily, string partition, DateTime today, int days, IReadOnlyList? alerts = null) { @@ -133,18 +110,18 @@ namespace AutopilotMonitor.Functions.Functions.Metrics var trendWindowStart = Key(InclusiveWindowStart(today, TrendWindowDays)); var baselineStart = Key(InclusiveWindowStart(today, TrendHorizonDays)); - var rows = new Dictionary<(string Path, string Status), PathRow>(); + var rows = new Dictionary<(string Path, string Status), VerdictCalibrationPathRow>(); int sessions = 0, terminal = 0, derived = 0; int trendSessions = 0, baselineSessions = 0; DateTime? computedAt = null; var versions = new HashSet(); - PathRow Row(VerdictCalibrationBucket b) + VerdictCalibrationPathRow Row(VerdictCalibrationBucket b) { var key = (b.VerdictPath, b.Status); if (!rows.TryGetValue(key, out var row)) { - row = new PathRow { VerdictPath = b.VerdictPath, Status = b.Status }; + row = new VerdictCalibrationPathRow { VerdictPath = b.VerdictPath, Status = b.Status }; rows[key] = row; } return row; @@ -206,20 +183,32 @@ namespace AutopilotMonitor.Functions.Functions.Metrics : null; } - return new + return new VerdictCalibrationResponse { - success = true, - tenantId = partition, - windowDays = days, - windowStart, - windowEnd = Key(today), - computedAt, - versions = versions.OrderBy(v => v).ToArray(), - totals = new { sessions, terminal, derived, days = daily.Count(d => string.CompareOrdinal(d.Date, windowStart) >= 0) }, - trend = new { windowDays = TrendWindowDays, baselineDays = TrendBaselineDays, windowSessions = trendSessions, baselineSessions }, - paths = list, + Success = true, + TenantId = partition, + WindowDays = days, + WindowStart = windowStart, + WindowEnd = Key(today), + ComputedAt = computedAt, + Versions = versions.OrderBy(v => v).ToArray(), + Totals = new VerdictCalibrationTotals + { + Sessions = sessions, + Terminal = terminal, + Derived = derived, + Days = daily.Count(d => string.CompareOrdinal(d.Date, windowStart) >= 0), + }, + Trend = new VerdictCalibrationTrendMeta + { + WindowDays = TrendWindowDays, + BaselineDays = TrendBaselineDays, + WindowSessions = trendSessions, + BaselineSessions = baselineSessions, + }, + Paths = list, // Wording contract shared with the rule radar: a dimension is correlated, not causal. - alerts = (alerts ?? Array.Empty()) + Alerts = (alerts ?? Array.Empty()) .OrderByDescending(a => a.FirstNotifiedAt) .ToList(), }; diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/RuleStatsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/RuleStatsFunction.cs index 6b8644a0..a87f6f7e 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/RuleStatsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/RuleStatsFunction.cs @@ -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; @@ -29,7 +30,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics } /// - /// GET /api/metrics/rule-stats?startDate=...&endDate=...&ruleType=analyze + /// GET /api/metrics/rule-stats?startDate=...&endDate=...&ruleType=analyze /// Returns rule stats for the caller's tenant (MemberRead). /// [Function("GetRuleStats")] @@ -48,55 +49,16 @@ namespace AutopilotMonitor.Functions.Functions.Metrics tenantId, startDate, endDate); var entries = await _metricsRepo.GetRuleStatsAsync(tenantId, startDate, endDate, ruleType); + var aggregated = BuildRuleAggregates(entries); - // Aggregate across dates per ruleId - var aggregated = entries - .GroupBy(e => e.RuleId) - .Select(g => new - { - ruleId = g.Key, - ruleType = g.First().RuleType, - ruleTitle = g.First().RuleTitle, - category = g.First().Category, - severity = g.First().Severity, - fireCount = g.Sum(e => e.FireCount), - evaluationCount = g.Sum(e => e.EvaluationCount), - sessionsEvaluated = g.Sum(e => e.SessionsEvaluated), - hitRate = g.Sum(e => e.EvaluationCount) > 0 - ? Math.Round(100.0 * g.Sum(e => e.FireCount) / g.Sum(e => e.EvaluationCount), 1) - : 0.0, - avgConfidenceScore = g.Sum(e => e.FireCount) > 0 - ? Math.Round((double)g.Sum(e => e.ConfidenceScoreSum) / g.Sum(e => e.FireCount), 1) - : 0.0, - trend = g.OrderBy(e => e.Date).Select(e => new - { - date = e.Date, - fireCount = e.FireCount, - evaluationCount = e.EvaluationCount - }) - }) - .OrderByDescending(r => r.fireCount) - .ToList(); - - var totalEvaluations = aggregated.Sum(r => r.evaluationCount); - var totalFires = aggregated.Sum(r => r.fireCount); - - var result = new + var result = new RuleStatsResponse { - rules = aggregated, + Rules = aggregated, // F3 PR6: active regression episodes (tracker rows) — the rules-page badge // and MCP get_rule_stats read them from here. Empty list = nothing regressed. - regressions = await _notificationTracker.GetRuleRegressionsAsync(tenantId), - summary = new - { - totalEvaluations, - totalFires, - overallHitRate = totalEvaluations > 0 - ? Math.Round(100.0 * totalFires / totalEvaluations, 1) - : 0.0, - topRuleByFireCount = aggregated.FirstOrDefault()?.ruleId, - period = new { start = startDate, end = endDate } - } + Regressions = await _notificationTracker.GetRuleRegressionsAsync(tenantId), + // The tenant route historically carries no uniqueRules key — keep it absent. + Summary = BuildSummary(aggregated, startDate, endDate, uniqueRules: null), }; var response = req.CreateResponse(HttpStatusCode.OK); @@ -113,7 +75,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics } /// - /// GET /api/global/metrics/rule-stats?startDate=...&endDate=...&ruleType=analyze&tenantId=... + /// GET /api/global/metrics/rule-stats?startDate=...&endDate=...&ruleType=analyze&tenantId=... /// Returns cross-tenant global rule stats (Global Admin only). /// When tenantId is provided, returns tenant-specific stats instead of global aggregates. /// @@ -136,57 +98,17 @@ namespace AutopilotMonitor.Functions.Functions.Metrics queryTenantId, startDate, endDate); var entries = await _metricsRepo.GetRuleStatsAsync(queryTenantId, startDate, endDate, ruleType); + var aggregated = BuildRuleAggregates(entries); - var aggregated = entries - .GroupBy(e => e.RuleId) - .Select(g => new - { - ruleId = g.Key, - ruleType = g.First().RuleType, - ruleTitle = g.First().RuleTitle, - category = g.First().Category, - severity = g.First().Severity, - fireCount = g.Sum(e => e.FireCount), - evaluationCount = g.Sum(e => e.EvaluationCount), - sessionsEvaluated = g.Sum(e => e.SessionsEvaluated), - hitRate = g.Sum(e => e.EvaluationCount) > 0 - ? Math.Round(100.0 * g.Sum(e => e.FireCount) / g.Sum(e => e.EvaluationCount), 1) - : 0.0, - avgConfidenceScore = g.Sum(e => e.FireCount) > 0 - ? Math.Round((double)g.Sum(e => e.ConfidenceScoreSum) / g.Sum(e => e.FireCount), 1) - : 0.0, - trend = g.OrderBy(e => e.Date).Select(e => new - { - date = e.Date, - fireCount = e.FireCount, - evaluationCount = e.EvaluationCount - }) - }) - .OrderByDescending(r => r.fireCount) - .ToList(); - - var totalEvaluations = aggregated.Sum(r => r.evaluationCount); - var totalFires = aggregated.Sum(r => r.fireCount); - - var result = new + var result = new RuleStatsResponse { - rules = aggregated, + Rules = aggregated, // Regression episodes are tenant-scoped: present when drilling into one // tenant, empty for the cross-tenant "global" aggregate (no episode there). - regressions = queryTenantId != "global" + Regressions = queryTenantId != "global" ? await _notificationTracker.GetRuleRegressionsAsync(queryTenantId) - : new List(), - summary = new - { - totalEvaluations, - totalFires, - overallHitRate = totalEvaluations > 0 - ? Math.Round(100.0 * totalFires / totalEvaluations, 1) - : 0.0, - topRuleByFireCount = aggregated.FirstOrDefault()?.ruleId, - uniqueRules = aggregated.Count, - period = new { start = startDate, end = endDate } - } + : new List(), + Summary = BuildSummary(aggregated, startDate, endDate, uniqueRules: aggregated.Count), }; var response = req.CreateResponse(HttpStatusCode.OK); @@ -201,5 +123,53 @@ namespace AutopilotMonitor.Functions.Functions.Metrics return errorResponse; } } + + /// Aggregate the per-date entries across dates per ruleId, fires descending. + internal static List BuildRuleAggregates(IReadOnlyList entries) + => entries + .GroupBy(e => e.RuleId) + .Select(g => new RuleStatsRuleAggregate + { + RuleId = g.Key, + RuleType = g.First().RuleType, + RuleTitle = g.First().RuleTitle, + Category = g.First().Category, + Severity = g.First().Severity, + FireCount = g.Sum(e => e.FireCount), + EvaluationCount = g.Sum(e => e.EvaluationCount), + SessionsEvaluated = g.Sum(e => e.SessionsEvaluated), + HitRate = g.Sum(e => e.EvaluationCount) > 0 + ? Math.Round(100.0 * g.Sum(e => e.FireCount) / g.Sum(e => e.EvaluationCount), 1) + : 0.0, + AvgConfidenceScore = g.Sum(e => e.FireCount) > 0 + ? Math.Round((double)g.Sum(e => e.ConfidenceScoreSum) / g.Sum(e => e.FireCount), 1) + : 0.0, + Trend = g.OrderBy(e => e.Date).Select(e => new RuleTrendPoint + { + Date = e.Date, + FireCount = e.FireCount, + EvaluationCount = e.EvaluationCount + }).ToList(), + }) + .OrderByDescending(r => r.FireCount) + .ToList(); + + internal static RuleStatsSummary BuildSummary( + IReadOnlyList aggregated, string startDate, string endDate, int? uniqueRules) + { + var totalEvaluations = aggregated.Sum(r => r.EvaluationCount); + var totalFires = aggregated.Sum(r => r.FireCount); + return new RuleStatsSummary + { + TotalEvaluations = totalEvaluations, + TotalFires = totalFires, + OverallHitRate = totalEvaluations > 0 + ? Math.Round(100.0 * totalFires / totalEvaluations, 1) + : 0.0, + TopRuleByFireCount = aggregated.Count > 0 ? aggregated[0].RuleId : null, + UniqueRules = uniqueRules, + Period = new RuleStatsPeriod { Start = startDate, End = endDate }, + }; + } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/DeleteSessionFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/DeleteSessionFunction.cs index 97866513..b4ba6b8e 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/DeleteSessionFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/DeleteSessionFunction.cs @@ -2,6 +2,7 @@ using System.Net; using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Functions.Services.Deletion; +using AutopilotMonitor.Shared.Models; using AutopilotMonitor.Shared.Models.Deletion; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; @@ -172,12 +173,12 @@ namespace AutopilotMonitor.Functions.Functions.Sessions /// internal static object BuildV2ResponseBody(SessionDeletionEnqueueResult result, string sessionId) => result.Outcome switch { - SessionDeletionEnqueueOutcome.Enqueued => new + SessionDeletionEnqueueOutcome.Enqueued => new SessionDeletionQueuedResponse { - success = true, - status = "queued", - manifestId = result.ManifestId, - message = "Cascade deletion queued; worker will drain asynchronously.", + Success = true, + Status = "queued", + ManifestId = result.ManifestId, + Message = "Cascade deletion queued; worker will drain asynchronously.", }, SessionDeletionEnqueueOutcome.AlreadyInFlight => new { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RestoreSessionFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RestoreSessionFunction.cs index 36c79eb2..78170ea3 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RestoreSessionFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RestoreSessionFunction.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Services.Deletion; using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; @@ -170,19 +171,19 @@ namespace AutopilotMonitor.Functions.Functions.Sessions { var status = MapOutcomeToStatus(result.Outcome); - var body = new + var body = new SessionRestoreResponse { - success = status == HttpStatusCode.OK, - outcome = result.Outcome.ToString(), - mode = result.Mode, - message = result.Message, - currentState = result.CurrentState, - pendingManifestId = result.PendingManifestId, - rowsRestoredByTable = result.RowsRestoredByTable, - rowsSkippedByTable = result.RowsSkippedByTable, - wouldRestoreByTable = result.WouldRestoreByTable, - inventoryReIncrements = result.InventoryReIncrements, - durationMs = result.DurationMs, + Success = status == HttpStatusCode.OK, + Outcome = result.Outcome.ToString(), + Mode = result.Mode, + Message = result.Message, + CurrentState = result.CurrentState, + PendingManifestId = result.PendingManifestId, + RowsRestoredByTable = result.RowsRestoredByTable, + RowsSkippedByTable = result.RowsSkippedByTable, + WouldRestoreByTable = result.WouldRestoreByTable, + InventoryReIncrements = result.InventoryReIncrements, + DurationMs = result.DurationMs, }; return await WriteJsonAsync(req, status, body); diff --git a/src/Backend/AutopilotMonitor.Functions/Helpers/MetricsMath.cs b/src/Backend/AutopilotMonitor.Functions/Helpers/MetricsMath.cs index 1a04ed03..a1901391 100644 --- a/src/Backend/AutopilotMonitor.Functions/Helpers/MetricsMath.cs +++ b/src/Backend/AutopilotMonitor.Functions/Helpers/MetricsMath.cs @@ -71,7 +71,7 @@ public static class MetricsMath /// final status. Peer bytes and Microsoft Connected Cache (MCC) bytes are reported separately — /// MCC is counted apart from peers by DO — and offload% credits both as "not pulled from the CDN". /// - public static object BuildAppMetricsPayload(IEnumerable summaries) + public static AppMetricsResponse BuildAppMetricsPayload(IEnumerable summaries) { var summaryList = summaries as IList ?? summaries.ToList(); @@ -101,65 +101,66 @@ public static class MetricsMath // row reports source bytes but no DoTotalBytesDownloaded — so that telemetry is not lost. var doG = DoAggregator.Compute(g); - return new + return new AppMetricsAppGroup { - appName = g.Key, - totalInstalls = total, - succeeded = installed.Count, - skipped = skipped.Count, - unmeasured = installed.Count - measured.Count, - failed = failed.Count, + AppName = g.Key, + TotalInstalls = total, + Succeeded = installed.Count, + Skipped = skipped.Count, + Unmeasured = installed.Count - measured.Count, + Failed = failed.Count, // Skips leave the rate (they are not attempts); legacy rows stay in it. - failureRate = TerminalFailureRatePct(failed.Count, installed.Count), - avgDurationSeconds = measured.Count > 0 ? Math.Round(measured.Average(s => s.DurationSeconds), 0) : 0, - maxDurationSeconds = measured.Count > 0 ? measured.Max(s => s.DurationSeconds) : 0, - measuredInstalls = measured.Count, - avgDownloadBytes = measured.Count > 0 ? (long)measured.Average(s => s.DownloadBytes) : 0, - doTotalBytesDownloaded = doG.TotalBytesDownloaded, - doBytesFromPeers = doG.BytesFromPeers, - doBytesFromCacheServer = doG.BytesFromCacheServer, - doBytesFromHttp = doG.BytesFromHttp, - peerOffloadPercent = OffloadPercent(doG.BytesFromPeers + doG.BytesFromCacheServer, doG.TotalBytesDownloaded), - topFailureCodes = failed + FailureRate = TerminalFailureRatePct(failed.Count, installed.Count), + AvgDurationSeconds = measured.Count > 0 ? Math.Round(measured.Average(s => s.DurationSeconds), 0) : 0, + MaxDurationSeconds = measured.Count > 0 ? measured.Max(s => s.DurationSeconds) : 0, + MeasuredInstalls = measured.Count, + AvgDownloadBytes = measured.Count > 0 ? (long)measured.Average(s => s.DownloadBytes) : 0, + DoTotalBytesDownloaded = doG.TotalBytesDownloaded, + DoBytesFromPeers = doG.BytesFromPeers, + DoBytesFromCacheServer = doG.BytesFromCacheServer, + DoBytesFromHttp = doG.BytesFromHttp, + PeerOffloadPercent = OffloadPercent(doG.BytesFromPeers + doG.BytesFromCacheServer, doG.TotalBytesDownloaded), + TopFailureCodes = failed .Where(f => !string.IsNullOrEmpty(f.FailureCode)) .GroupBy(f => f.FailureCode) .OrderByDescending(fc => fc.Count()) .Take(3) - .Select(fc => new { code = fc.Key, count = fc.Count() }) + .Select(fc => new AppFailureCodeCount { Code = fc.Key, Count = fc.Count() }) + .ToList() }; }).ToList(); // Slowest ranking gates on MEASURED installs — an app whose durations were mostly // unobserved must not rank as "fast" on a handful of zeros (audit §0.5). var slowestApps = SelectSlowestApps( - appGroups, a => a.measuredInstalls, a => (double)a.avgDurationSeconds, minSamples: 3, take: 10); + appGroups, a => a.MeasuredInstalls, a => a.AvgDurationSeconds, minSamples: 3, take: 10); var topFailingApps = appGroups - .Where(a => a.failed > 0) - .OrderByDescending(a => a.failed) - .ThenByDescending(a => a.failureRate) + .Where(a => a.Failed > 0) + .OrderByDescending(a => a.Failed) + .ThenByDescending(a => a.FailureRate) .Take(10) .ToList(); var doAll = DoAggregator.Compute(summaryList); - return new + return new AppMetricsResponse { - success = true, - totalApps = appGroups.Count, - totalInstalls = summaryList.Count, - totalSkipped = appGroups.Sum(a => a.skipped), - totalUnmeasured = appGroups.Sum(a => a.unmeasured), - totalCollisionExcluded, - slowestApps, - topFailingApps, - deliveryOptimization = new + Success = true, + TotalApps = appGroups.Count, + TotalInstalls = summaryList.Count, + TotalSkipped = appGroups.Sum(a => a.Skipped), + TotalUnmeasured = appGroups.Sum(a => a.Unmeasured), + TotalCollisionExcluded = totalCollisionExcluded, + SlowestApps = slowestApps, + TopFailingApps = topFailingApps, + DeliveryOptimization = new AppMetricsDeliveryOptimization { - totalBytesDownloaded = doAll.TotalBytesDownloaded, - fromPeers = doAll.BytesFromPeers, - fromCacheServer = doAll.BytesFromCacheServer, - fromHttp = doAll.BytesFromHttp, - peerOffloadPercent = OffloadPercent(doAll.BytesFromPeers + doAll.BytesFromCacheServer, doAll.TotalBytesDownloaded), + TotalBytesDownloaded = doAll.TotalBytesDownloaded, + FromPeers = doAll.BytesFromPeers, + FromCacheServer = doAll.BytesFromCacheServer, + FromHttp = doAll.BytesFromHttp, + PeerOffloadPercent = OffloadPercent(doAll.BytesFromPeers + doAll.BytesFromCacheServer, doAll.TotalBytesDownloaded), } }; } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/AgentEfficiencyMetricsService.cs b/src/Backend/AutopilotMonitor.Functions/Services/AgentEfficiencyMetricsService.cs index 91034baa..57c86e31 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/AgentEfficiencyMetricsService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/AgentEfficiencyMetricsService.cs @@ -72,7 +72,7 @@ namespace AutopilotMonitor.Functions.Services // Shallow copy so flipping FromCache never mutates the instance an earlier // (fresh) caller may still be holding/serializing. The buckets are shared // read-only — nothing downstream writes into a response. - return entry.metrics.CloneAsCacheHit(); + return CloneAsCacheHit(entry.metrics); } } @@ -287,6 +287,21 @@ namespace AutopilotMonitor.Functions.Services })); } + /// Shallow copy flagged as a cache hit — see the cache-read path. + private static AgentEfficiencyMetricsResponse CloneAsCacheHit(AgentEfficiencyMetricsResponse source) => new() + { + WindowDays = source.WindowDays, + SessionLimit = source.SessionLimit, + TenantId = source.TenantId, + SessionsScanned = source.SessionsScanned, + SessionsWithSnapshots = source.SessionsWithSnapshots, + ByVersion = source.ByVersion, + Overall = source.Overall, + ComputedAt = source.ComputedAt, + ComputeDurationMs = source.ComputeDurationMs, + FromCache = true + }; + private static PercentileStats? BuildStats(IEnumerable values) { var sorted = values.OrderBy(v => v).ToList(); @@ -303,76 +318,7 @@ namespace AutopilotMonitor.Functions.Services } } - // ── Response DTOs ──────────────────────────────────────────────────────────── - - public class AgentEfficiencyMetricsResponse - { - public int WindowDays { get; set; } - public int SessionLimit { get; set; } - /// Echo of the requested tenant filter; null = cross-tenant aggregate. - public string? TenantId { get; set; } - /// Sessions the scan covered — compare against for truncation. - public int SessionsScanned { get; set; } - public int SessionsWithSnapshots { get; set; } - public List ByVersion { get; set; } = new(); - public AgentVersionEfficiency? Overall { get; set; } - public DateTime ComputedAt { get; set; } - public int ComputeDurationMs { get; set; } - public bool FromCache { get; set; } - - /// Shallow copy flagged as a cache hit — see the cache-read path. - internal AgentEfficiencyMetricsResponse CloneAsCacheHit() => new() - { - WindowDays = WindowDays, - SessionLimit = SessionLimit, - TenantId = TenantId, - SessionsScanned = SessionsScanned, - SessionsWithSnapshots = SessionsWithSnapshots, - ByVersion = ByVersion, - Overall = Overall, - ComputedAt = ComputedAt, - ComputeDurationMs = ComputeDurationMs, - FromCache = true - }; - } - - public class AgentVersionEfficiency - { - /// Null on the cross-version "overall" bucket (omitted on the wire). - public string? AgentVersion { get; set; } - public int SessionsScanned { get; set; } - public int SessionsWithSnapshots { get; set; } - public int SpoolPressureSessions { get; set; } - public PercentileStats? AvgCpuPercent { get; set; } - public PercentileStats? MaxCpuPercent { get; set; } - public PercentileStats? MaxWorkingSetMb { get; set; } - public PercentileStats? MaxPrivateBytesMb { get; set; } - public PercentileStats? MaxThreadCount { get; set; } - public PercentileStats? MaxHandleCount { get; set; } - public PercentileStats? MaxSpoolDepth { get; set; } - public PercentileStats? MaxSpoolFileBytes { get; set; } - public PercentileStats? ApiLatencyMs { get; set; } - public PercentileStats? ApiRequestCount { get; set; } - public CrashRateMetrics? CrashRate { get; set; } - public List? TopOffenders { get; set; } - } - - /// Distribution of a per-session statistic across a version bucket. - public class PercentileStats - { - public double P50 { get; set; } - public double P95 { get; set; } - public double Max { get; set; } - public double Avg { get; set; } - public int SampleCount { get; set; } - } - - public class EfficiencyOffender - { - public string SessionId { get; set; } = string.Empty; - public string TenantId { get; set; } = string.Empty; - public string? DeviceName { get; set; } - public string Dimension { get; set; } = string.Empty; - public double Value { get; set; } - } + // Response DTOs (AgentEfficiencyMetricsResponse family) moved to + // AutopilotMonitor.Shared.Models (Models/Metrics/AgentPerformanceMetrics.cs) so the + // shared manifest exports them as wire types. } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/AgentMetricsAggregation.cs b/src/Backend/AutopilotMonitor.Functions/Services/AgentMetricsAggregation.cs index d2ffb090..a5bcc7e9 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/AgentMetricsAggregation.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/AgentMetricsAggregation.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; +using AutopilotMonitor.Shared.Models; namespace AutopilotMonitor.Functions.Services { diff --git a/src/Backend/AutopilotMonitor.Functions/Services/PlatformMetricsService.cs b/src/Backend/AutopilotMonitor.Functions/Services/PlatformMetricsService.cs index 705e271b..f054ea39 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/PlatformMetricsService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/PlatformMetricsService.cs @@ -328,81 +328,7 @@ namespace AutopilotMonitor.Functions.Services => AgentMetricsAggregation.GetString(data, key); } - // ── Response DTOs ──────────────────────────────────────────────────────────── - - public class PlatformAgentMetricsResponse - { - public List Sessions { get; set; } = new(); - public DeliveryLatencyMetrics? DeliveryLatency { get; set; } - public CrashRateMetrics? CrashRate { get; set; } - public DateTime ComputedAt { get; set; } - public int ComputeDurationMs { get; set; } - public bool FromCache { get; set; } - public int WindowDays { get; set; } - public int SessionLimit { get; set; } - /// - /// Sessions the scan actually covered in the window (before the has-snapshots filter - /// that shapes ). Callers must compare THIS against - /// to decide whether the window was truncated — - /// Sessions.Count understates truncation on fleets where many sessions - /// emit no agent_metrics_snapshot. - /// - public int SessionsScanned { get; set; } - } - - public class SessionAgentMetric - { - public string SessionId { get; set; } = string.Empty; - public string TenantId { get; set; } = string.Empty; - public string? DeviceName { get; set; } - public string? Manufacturer { get; set; } - public string? Model { get; set; } - public string? StartedAt { get; set; } - public string? Status { get; set; } - public string? AgentVersion { get; set; } - public int SnapshotCount { get; set; } - public double TotalBytesUp { get; set; } - public double TotalBytesDown { get; set; } - public double TotalRequests { get; set; } - public double AvgCpu { get; set; } - public double MaxCpu { get; set; } - public double AvgWorkingSet { get; set; } - public double MaxWorkingSet { get; set; } - public double AvgPrivateBytes { get; set; } - public double AvgLatency { get; set; } - public double AvgSpoolDepth { get; set; } - public double MaxSpoolDepth { get; set; } - public double PeakSpoolDepth { get; set; } - public double MaxSpoolFileBytes { get; set; } - public double TotalEventsEmitted { get; set; } - public bool SpoolPressureDetected { get; set; } - } - - public class DeliveryLatencyMetrics - { - public double P50Ms { get; set; } - public double P95Ms { get; set; } - public double P99Ms { get; set; } - public double AvgMs { get; set; } - public int SampleCount { get; set; } - public double ClockSkewPercent { get; set; } - } - - public class CrashRateMetrics - { - public int TotalStarts { get; set; } - public int CleanExits { get; set; } - public int ExceptionCrashes { get; set; } - public int HardKills { get; set; } - public int RebootKills { get; set; } - public int FirstRuns { get; set; } - public double CrashRatePercent { get; set; } - public List TopExceptions { get; set; } = new(); - } - - public class CrashExceptionSummary - { - public string ExceptionType { get; set; } = string.Empty; - public int Count { get; set; } - } + // Response DTOs (PlatformAgentMetricsResponse family) moved to + // AutopilotMonitor.Shared.Models (Models/Metrics/AgentPerformanceMetrics.cs) so the + // shared manifest exports them as wire types. } diff --git a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts index 895d84d8..fe48faa5 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts @@ -155,6 +155,42 @@ export interface AdminIdentityBinding { readonly isObjectIdPinned: boolean; } +/** Response of GET global/metrics/agent-efficiency (GetGlobalAgentEfficiency). */ +export interface AgentEfficiencyMetricsResponse { + windowDays: number; + sessionLimit: number; + /** Echo of the requested tenant filter; null = cross-tenant aggregate. */ + tenantId?: string; + /** Sessions the scan covered — compare against SessionLimit for truncation. */ + sessionsScanned: number; + sessionsWithSnapshots: number; + byVersion: AgentVersionEfficiency[]; + overall?: AgentVersionEfficiency; + computedAt: string; + computeDurationMs: number; + fromCache: boolean; +} + +export interface AgentVersionEfficiency { + /** Null on the cross-version "overall" bucket (omitted on the wire). */ + agentVersion?: string; + sessionsScanned: number; + sessionsWithSnapshots: number; + spoolPressureSessions: number; + avgCpuPercent?: PercentileStats; + maxCpuPercent?: PercentileStats; + maxWorkingSetMb?: PercentileStats; + maxPrivateBytesMb?: PercentileStats; + maxThreadCount?: PercentileStats; + maxHandleCount?: PercentileStats; + maxSpoolDepth?: PercentileStats; + maxSpoolFileBytes?: PercentileStats; + apiLatencyMs?: PercentileStats; + apiRequestCount?: PercentileStats; + crashRate?: CrashRateMetrics; + topOffenders?: EfficiencyOffender[]; +} + /** Defines how to analyze collected events to detect issues Analyze rules run server-side during event ingestion */ export interface AnalyzeRule { /** Unique rule identifier (e.g., "ANALYZE-NET-001") */ @@ -227,6 +263,81 @@ export interface AnalyzeRuleListResponse { rules: AnalyzeRule[]; } +/** One of the top 5 failure codes of the analytics window. */ +export interface AppAnalyticsFailureCode { + code: string; + /** First observed exit code; absent when none of the rows carried one. */ + exitCode?: number; + count: number; + sampleMessage: string; +} + +/** Response of GET apps/{appName}/analytics and its global variant. */ +export interface AppAnalyticsResponse { + success: boolean; + appName: string; + appType: string; + windowDays: number; + collisionExcluded: number; + /** "day" (windows up to 30 days) or "week". */ + bucket: string; + summary: AppAnalyticsSummary; + timeSeries: AppAnalyticsTimeBucket[]; + versionBreakdown: AppVersionBreakdownItem[]; + installerPhaseBreakdown: AppInstallerPhaseCount[]; + topFailureCodes: AppAnalyticsFailureCode[]; + /** Succeeded installs whose detection re-check reported NotDetected. */ + detectionLiesCount: number; + deviceModelBreakdown: AppDeviceModelBreakdownItem[]; + /** Active duration-regression episodes for this app (tracker rows). */ + versionRegressions: AppVersionRegressionAlert[]; +} + +/** Headline aggregate of one app's analytics window. */ +export interface AppAnalyticsSummary { + totalInstalls: number; + succeeded: number; + skipped: number; + unmeasured: number; + failed: number; + failureRate: number; + avgDurationSeconds: number; + p95DurationSeconds: number; + avgDownloadBytes: number; + /** "improving" | "worsening" | "stable". */ + trend: string; + /** Absent when either window half has under 5 finished installs. */ + trendDelta?: number; + /** Share of installs with AttemptNumber > 1 (0-1, three decimals). */ + flakinessScore: number; +} + +/** One day/week bucket of the analytics time series. */ +export interface AppAnalyticsTimeBucket { + bucketStart: string; + installs: number; + succeeded: number; + failed: number; + failureRate: number; + avgDurationSeconds: number; +} + +/** Per device-model failure aggregate (lift vs the app's baseline rate, descending). */ +export interface AppDeviceModelBreakdownItem { + manufacturer: string; + model: string; + installs: number; + failed: number; + failureRate: number; + liftVsBaseline: number; +} + +/** One failure code with its occurrence count. */ +export interface AppFailureCodeCount { + code: string; + count: number; +} + /** Consent-probe verdict embedded in app-homing responses (success and deny alike). Built by AppHomingFunction.ProbePayload. */ export interface AppHomingProbeWire { /** False when the decision needed no probe (e.g. GA force flip). */ @@ -235,6 +346,180 @@ export interface AppHomingProbeWire { isTransient: boolean; } +/** App install SLA snapshot. */ +export interface AppInstallSlaSnapshot { + totalInstalls: number; + succeeded: number; + failed: number; + successRate: number; + targetMet: boolean; + /** Top failing apps by failure count. */ + topFailingApps: TopFailingApp[]; +} + +/** Failed installs per installer phase (descending). */ +export interface AppInstallerPhaseCount { + phase: string; + failed: number; +} + +/** One app's aggregate across its install rows in the window. */ +export interface AppMetricsAppGroup { + appName: string; + totalInstalls: number; + succeeded: number; + skipped: number; + unmeasured: number; + failed: number; + /** Failed / (failed + succeeded) as a percentage; skips never count as attempts. */ + failureRate: number; + /** Average measured FINAL-attempt duration (whole seconds); 0 with no measured installs. */ + avgDurationSeconds: number; + maxDurationSeconds: number; + measuredInstalls: number; + avgDownloadBytes: number; + doTotalBytesDownloaded: number; + doBytesFromPeers: number; + doBytesFromCacheServer: number; + doBytesFromHttp: number; + peerOffloadPercent: number; + /** Top 3 failure codes by count, descending. */ + topFailureCodes: AppFailureCodeCount[]; +} + +/** Fleet-wide Delivery Optimization rollup across every install row in the window. */ +export interface AppMetricsDeliveryOptimization { + totalBytesDownloaded: number; + fromPeers: number; + fromCacheServer: number; + fromHttp: number; + /** Share of bytes not pulled from the CDN (peers + Microsoft Connected Cache), 0-100 one decimal. */ + peerOffloadPercent: number; +} + +/** Response of GET metrics/app and GET global/metrics/app: per-app install health over the requested window (slowest apps by average FINAL-attempt duration, top failing apps) plus the fleet Delivery Optimization rollup. */ +export interface AppMetricsResponse { + success: boolean; + totalApps: number; + totalInstalls: number; + totalSkipped: number; + totalUnmeasured: number; + /** Rows excluded from per-app groups because their name-keyed row merged distinct appIds. */ + totalCollisionExcluded: number; + slowestApps: AppMetricsAppGroup[]; + topFailingApps: AppMetricsAppGroup[]; + deliveryOptimization: AppMetricsDeliveryOptimization; +} + +export interface AppScriptMetrics { + avgAppsPerSession: number; + totalUniqueApps: number; + avgPlatformScriptsPerSession: number; + avgRemediationScriptsPerSession: number; + totalPlatformScripts: number; + totalRemediationScripts: number; +} + +/** One install row of the app-sessions drilldown (failed first, then in-progress, newest first). */ +export interface AppSessionItem { + sessionId: string; + tenantId: string; + deviceName: string; + manufacturer: string; + model: string; + appVersion: string; + status: string; + installerPhase: string; + failureCode: string; + /** Absent when the install carried no exit code. */ + exitCode?: number; + attemptNumber: number; + startedAt: string; + durationSeconds: number; + /** 2+ = the IME processed this app in multiple passes (device-ESP evaluation + real install). */ + installPassCount: number; +} + +/** Response of GET apps/{appName}/sessions and its global variant (offset-paged). */ +export interface AppSessionsResponse { + success: boolean; + total: number; + offset: number; + limit: number; + items: AppSessionItem[]; +} + +/** Per-app-version aggregate (installs descending). */ +export interface AppVersionBreakdownItem { + appVersion: string; + installs: number; + failed: number; + failureRate: number; + measuredInstalls: number; + medianDurationSeconds: number; + p95DurationSeconds: number; +} + +/** One ACTIVE app-version duration regression: an app whose newest version's median install duration rose ≥2× (and ≥5 minutes absolute) over the previous version's median, both sides with enough measured installs. Persisted as the appversionregression|{app}|{version} keyspace of the notification tracker table — the row IS the dedup (one bell per episode), and the versionRegressions[] payload on the app-analytics response. Deleted when the episode re-arms (median falls back under 1.5× or the version drains out of the horizon) or by the tracker's 30-day retention sweep. Numbers refresh on every radar pass while the episode stays active; FirstNotifiedAt never moves. */ +export interface AppVersionRegressionAlert { + tenantId: string; + appName: string; + /** The regressed (newer) version — episode key together with the app. */ + currentVersion: string; + /** The comparison version: latest first-seen strictly before the current version's first-seen. */ + previousVersion: string; + currentMedianSeconds: number; + previousMedianSeconds: number; + /** Measured installs backing each median (gate: both ≥10). */ + currentMeasuredCount: number; + previousMeasuredCount: number; + /** Current median ÷ previous median (gate: ≥2.0; previous median is never 0 — measured durations are ≥1s). */ + lift: number; + /** When the episode first fired (bell + ops event moment). Never moves on refresh; drives the 30d retention re-arm. */ + firstNotifiedAt: string; + /** Last radar pass that re-confirmed/refreshed this episode. */ + lastEvaluatedAt: string; +} + +/** One app row of the apps list (failed desc, then failure rate, then name). */ +export interface AppsListItem { + appName: string; + appType: string; + totalInstalls: number; + succeeded: number; + skipped: number; + unmeasured: number; + failed: number; + failureRate: number; + avgDurationSeconds: number; + maxDurationSeconds: number; + avgDownloadBytes: number; + /** "improving" | "worsening" | "stable". */ + trend: string; + /** Failure-rate delta between window halves; absent when either half has under 5 finished installs. */ + trendDelta?: number; + lastSeenAt: string; +} + +/** Response of GET apps/list and GET global/apps/list. Legacy mode (no pageSize) returns the full array with the paging keys absent; opt-in pagination adds count/offset/pageSize and a nextLink while more pages remain. */ +export interface AppsListResponse { + success: boolean; + totalApps: number; + totalInstalls: number; + /** Rows excluded because their name-keyed row merged distinct appIds. */ + collisionExcluded: number; + windowDays: number; + /** Rows on this page; absent in legacy full-array mode. */ + count?: number; + /** Absent in legacy full-array mode. */ + offset?: number; + /** Absent in legacy full-array mode. */ + pageSize?: number; + apps: AppsListItem[]; + /** Next-page link; absent in legacy mode and on the last page. */ + nextLink?: string; +} + export interface AuditLogEntry { id: string; tenantId: string; @@ -390,6 +675,22 @@ export interface CpeMappingItem { createdAt: string; } +export interface CrashExceptionSummary { + exceptionType: string; + count: number; +} + +export interface CrashRateMetrics { + totalStarts: number; + cleanExits: number; + exceptionCrashes: number; + hardKills: number; + rebootKills: number; + firstRuns: number; + crashRatePercent: number; + topExceptions: CrashExceptionSummary[]; +} + /** Response of POST rules/analyze/{ruleId}/create-from-template: the newly created custom rule instantiated from the template. */ export interface CreateAnalyzeRuleFromTemplateResponse { success: boolean; @@ -534,6 +835,22 @@ export interface DeletionRowKeySample { rk: string; } +export interface DeliveryLatencyMetrics { + p50Ms: number; + p95Ms: number; + p99Ms: number; + avgMs: number; + sampleCount: number; + clockSkewPercent: number; +} + +export interface DeploymentTypeMetrics { + userDriven: number; + whiteGlove: number; + userDrivenPercentage: number; + whiteGlovePercentage: number; +} + /** Response of GET health/detailed: the full system health report (always 200; per-check status is in the body). Items are HealthCheck objects (backend-project type, serialized by runtime type); non-GA callers get a filtered list. */ export interface DetailedHealthCheckResponse { service: string; @@ -565,6 +882,66 @@ export interface DeviceHistory { lastUpdated: string; } +/** One attempt-histogram bucket: how many completed journeys took exactly Attempts attempts. */ +export interface DeviceJourneyAttemptBucket { + attempts: number; + journeyCount: number; +} + +/** Daily First-Time-Right rollup for one (tenant, date) — F2 PR4 (insights spec §F2). A journey counts on the StartedAt date of its COMPLETING success session (same StartedAt-date bucketing as every other daily aggregate; the rolling sweep re-buckets late-terminating sessions idempotently). Only journeys that ended with a terminal success are counted — open journeys (no success yet, incl. WhiteGlove waiting for its user session) and gap-abandoned journeys never enter numerator or denominator. Counts are additive across days, so a window rate is the sum of daily rows — no rolling-window row is needed (unlike the median-based time-attribution aggregates). Rows are written even below the ≥20-completed-journeys UI gate: the UI needs the n to say "insufficient data (n=3)" (truthfulness rule 4). Junk-serial exclusions are disclosed per day (rule 7). "global" mirror rows sum all tenants. */ +export interface DeviceJourneyDailyAggregate { + /** Calendar day (UTC, "yyyy-MM-dd") the completing success sessions STARTED. */ + date: string; + /** Tenant GUID, or "global" for the cross-tenant row. */ + tenantId: string; + /** Journey-grouping algorithm version the counts were computed with (rule 8). */ + journeyVersion: number; + /** Journeys completed (first terminal success) on this date — the FTR denominator. */ + completedJourneyCount: number; + /** Completed journeys with attempt count == 1 — the FTR numerator. */ + firstTimeRightCount: number; + /** Attempt distribution across the completed journeys, ordered by attempts ascending. */ + attemptHistogram: DeviceJourneyAttemptBucket[]; + /** Terminal sessions on this date excluded for junk/placeholder serials — disclosed, never silent (rule 7). */ + excludedSessionCount: number; + computedAt: string; +} + +/** Response of GET metrics/device-journeys and GET global/metrics/device-journeys: daily First-Time-Right rows of the window plus their sums, the merged attempt histogram, and the repeat-devices violator list (absent on the cross-tenant aggregate). */ +export interface DeviceJourneyMetricsResponse { + success: boolean; + windowDays: number; + totals: DeviceJourneyWindowTotals; + /** Daily rows of the window, date ordinal order. */ + daily: DeviceJourneyDailyAggregate[]; + /** Devices whose current journey took at least 2 attempts; absent on the cross-tenant aggregate (no per-device drill there). */ + repeatDevices?: DeviceJourneyRepeatDevice[]; +} + +/** One repeat-device violator row (current journey took at least 2 attempts, newest terminal session in the window). */ +export interface DeviceJourneyRepeatDevice { + serialNumber: string; + manufacturer: string; + model: string; + attempts: number; + journeyCount: number; + lastStatus: string; + lastSessionId: string; + lastStartedAt: string; + /** Failure reason of the newest failed attempt; empty when unavailable (fail-soft point-read). */ + lastFailureReason: string; +} + +/** Window totals of the device-journey response (additive sums over the daily rows). */ +export interface DeviceJourneyWindowTotals { + completedJourneys: number; + firstTimeRight: number; + /** Null with zero completed journeys — no rate claim, never 0 (truthfulness rule 1). */ + ftrRatePct?: number; + excludedSessions: number; + attemptHistogram: DeviceJourneyAttemptBucket[]; +} + /** One serial-number bucket in the GetDeviceNotRegistered aggregation. All values are self-reported by devices through the unauthenticated distress channel — UNVERIFIED. */ export interface DeviceNotRegisteredItem { serialNumber: string; @@ -603,6 +980,18 @@ export interface DeviceSessionRef { adminMarked: boolean; } +/** Wire projection of one built-in diagnostics section. Condition travels as the enum NAME ("Always" | "RealmJoinWatcher" | "DevicePreparation"), never the integer — the web switches on the string. */ +export interface DiagnosticsBuiltInSectionWire { + id: string; + zipFolder: string; + /** UNEXPANDED source folder (may contain %ProgramData% or the user-profile token). */ + sourceFolder: string; + patterns: string[]; + includeSubfolders: boolean; + description: string; + condition: string; +} + /** Response of POST diagnostics/download-ticket: a short-lived, self-authenticating download URL for one diagnostics blob (HMAC ticket in the query string). */ export interface DiagnosticsDownloadTicketResponse { success: boolean; @@ -616,6 +1005,24 @@ export interface DiagnosticsDownloadTicketResponse { sizeBytes?: number; } +/** Represents a log file path (or wildcard pattern) to include in the diagnostics ZIP package. Global (built-in) entries are defined by Global Admins; tenants may add their own. */ +export interface DiagnosticsLogPath { + /** File system path or wildcard pattern. Environment variables are expanded by the agent. Wildcards are only allowed in the last path segment (e.g. "C:\Windows\Panther\*.log"). */ + path: string; + /** Human-readable description shown in the portal. */ + description: string; + /** True when defined globally by a Global Admin — displayed as read-only for tenants. False when added by the tenant itself. */ + isBuiltIn: boolean; + /** When true, the agent also collects matching files from subdirectories recursively. Subdirectory structure is preserved in the ZIP (e.g. AdditionalLogs/Logs/subfolder/file.log). Default is false (top-level only). */ + includeSubfolders: boolean; +} + +/** Response of GET diagnostics/paths: what every diagnostics package collects before a tenant's own entries — the built-in section catalog (compiled into the agent) and the platform-wide global paths set by Global Admins. Member-readable by design. */ +export interface DiagnosticsPathsResponse { + builtIn: DiagnosticsBuiltInSectionWire[]; + globalPaths: DiagnosticsLogPath[]; +} + /** Shared response of POST global/notifications/dismiss-all and POST notifications/dismiss-all: how many notifications were dismissed. */ export interface DismissAllNotificationsResponse { success: boolean; @@ -659,6 +1066,14 @@ export interface DryRunAnalyzeRuleResponse { result: unknown; } +export interface EfficiencyOffender { + sessionId: string; + tenantId: string; + deviceName?: string; + dimension: string; + value: number; +} + /** Response of DELETE global/email-templates/{kind}: reset to the built-in template. */ export interface EmailTemplateResetResponse { /** "welcome" or "farewell". */ @@ -756,6 +1171,104 @@ export interface EpssRefreshRunWire { finishedUtc?: string; } +/** Response of GET feedback/eligibility: whether the caller should be shown the feedback prompt. */ +export interface FeedbackEligibilityResponse { + eligible: boolean; +} + +/** One feedback interaction as rendered on the Global Admin dashboard. */ +export interface FeedbackEntryWire { + type?: string; + upn?: string; + tenantId?: string; + displayName?: string; + /** Absent on dismissals. */ + rating?: number; + /** Absent on dismissals. */ + comment?: string; + dismissed: boolean; + submitted: boolean; + /** ISO-8601 round-trip string; absent when never stamped. */ + interactedAt?: string; + historyRowKey?: string; + domainName?: string; +} + +/** Response of GET feedback/all (Global Admin dashboard): every stored feedback entry. */ +export interface FeedbackListResponse { + feedback: FeedbackEntryWire[]; +} + +export interface FleetDailyPoint { + date: string; + success: number; + failed: number; +} + +export interface FleetFailingModel { + model: string; + failed: number; + /** All sessions on this model in the window, including in-flight ones. */ + total: number; + /** Failed / (Failed + Succeeded) * 100, rounded — finished enrollments only. */ + failureRate: number; +} + +export interface FleetFailureReason { + reason: string; + count: number; +} + +/** Server-computed Fleet Health aggregates. Replaces the client-side pass that drained up to 200k raw sessions into the browser and aggregated on the main thread. Built once per request from the windowed session list — see MetricsMath.BuildFleetHealthPayload. Presentation-only derivations (bar-chart maxima, weekday/month axis labels) stay on the client; this payload carries the data, not the formatting. */ +export interface FleetHealthMetrics { + success: boolean; + days: number; + stats: FleetHealthStats; + /** One entry per day in the window, oldest-first. Date is UTC "yyyy-MM-dd". */ + dailyData: FleetDailyPoint[]; + /** Top failure reasons by count (descending). */ + failureReasons: FleetFailureReason[]; + /** Device models by enrollment volume (descending). */ + modelHealth: FleetModelHealth[]; + /** Device models by average successful enrollment duration (descending). */ + slowestModels: FleetSlowModel[]; + /** Device models by failure count (descending). */ + topFailingModels: FleetFailingModel[]; + computedAt: string; +} + +export interface FleetHealthStats { + total: number; + succeeded: number; + failed: number; + inProgress: number; + /** Terminal, non-failure sessions (timeout reclassification). Surfaced as its own count; not a failure. */ + incomplete: number; + /** Succeeded / (Succeeded + Failed) * 100 (one decimal) — finished enrollments only, matching the SLA convention. 0 when nothing has finished yet (clients render "—"). */ + successRate: number; + /** Average duration in minutes over non-in-progress sessions that carry a duration. Kept for API compatibility; the cards lead with the median, which a handful of multi-hour outliers (overnight ESP, WhiteGlove late user phase) cannot drag around. */ + avgDurationMinutes: number; + /** Median duration in minutes over the same population as AvgDurationMinutes. */ + medianDurationMinutes: number; + /** 90th-percentile duration in minutes over the same population — the tail signal the median alone would hide. */ + p90DurationMinutes: number; +} + +export interface FleetModelHealth { + model: string; + /** All sessions on this model in the window, including in-flight ones. */ + total: number; + succeeded: number; + /** Clients derive the per-model rate as Succeeded / (Succeeded + Failed). */ + failed: number; +} + +export interface FleetSlowModel { + model: string; + avgMinutes: number; + count: number; +} + /** Defines what data the agent should collect Gather rules are delivered to the agent via the config API and can be managed (enabled/disabled, created) through the portal */ export interface GatherRule { /** Unique rule identifier (e.g., "GATHER-NET-001") */ @@ -830,6 +1343,18 @@ export interface GeographicLocationSessionsResponse { totalCount: number; } +/** Response containing geographic performance metrics aggregated by location. */ +export interface GeographicMetricsResponse { + success: boolean; + locations: LocationMetrics[]; + globalAverages: GlobalAverages; + computedAt: string; + totalSessions: number; + locationsWithData: number; + /** Whether geo-location collection is enabled for this tenant */ + geoLocationEnabled: boolean; +} + /** Response of GET global/presence: users active within the requested window. */ export interface GetActiveUsersResponse { success: boolean; @@ -1169,6 +1694,25 @@ export interface GetVulnerabilitySyncStatusResponse { epssLastRun?: EpssRefreshRunWire; } +/** Global average benchmarks for geographic comparison. */ +export interface GlobalAverages { + avgDurationMinutes: number; + medianDurationMinutes: number; + avgMinutesPerApp: number; + avgThroughputBytesPerSec: number; + stdDevDurationMinutes: number; + /** Global request-weighted average agent→backend API latency (ms); 0 = no data yet */ + avgApiLatencyMs: number; + /** Global median of per-session average API latency (ms), robust against outliers; 0 = no data yet */ + medianApiLatencyMs: number; + /** Global weighted average peer caching percentage */ + avgDoPercentPeerCaching: number; + /** Total peer bytes across all locations with DO data */ + totalDoBytesFromPeers: number; + /** Total HTTP bytes across all locations with DO data */ + totalDoBytesFromHttp: number; +} + /** One feature row of the graph-permissions status matrix: the feature identifier, the granted verdict (null while the snapshot is transient) and the Graph application permissions the feature requires. */ export interface GraphFeatureStatusItem { name: string; @@ -1177,6 +1721,17 @@ export interface GraphFeatureStatusItem { requiredPermissions: string[]; } +export interface HardwareCount { + name: string; + count: number; + percentage: number; +} + +export interface HardwareMetrics { + topManufacturers: HardwareCount[]; + topModels: HardwareCount[]; +} + /** One manufacturer+model bucket in the GetHardwareRejected aggregation. All values are self-reported by devices through the unauthenticated distress channel — UNVERIFIED. */ export interface HardwareRejectedItem { manufacturer: string; @@ -1253,6 +1808,58 @@ export interface ImeLogPatternListResponse { patterns: ImeLogPattern[]; } +export interface ImePatternDriftAlert { + version: string; + patternId: string; + baselineVersion: string; + baselineRate: number; + sessions: number; + flaggedAt?: string; +} + +export interface ImePatternHealthCell { + version: string; + patternId: string; + sessions: number; + sessionsWithHit: number; + hits: number; + rate: number; + driftFlaggedAt?: string; +} + +export interface ImePatternHealthPattern { + patternId: string; + category?: string; + enabled: boolean; + /** Hit rate on the baseline version; null without a baseline. */ + baselineRate?: number; + /** True when the baseline rate is at or above the expected threshold. */ + expected: boolean; +} + +/** Response of GET metrics/ime-pattern-health. */ +export interface ImePatternHealthResponse { + baselineVersion?: string; + minBaselineSessions: number; + expectedHitRate: number; + minCandidateSessions: number; + versions: ImePatternHealthVersion[]; + patterns: ImePatternHealthPattern[]; + cells: ImePatternHealthCell[]; + alerts: ImePatternDriftAlert[]; + generatedAt: string; +} + +export interface ImePatternHealthVersion { + version: string; + /** Sessions that delivered a histogram on this version (not the ImeVersionHistory session count). */ + sessions: number; + firstSeenAt?: string; + lastSeenAt?: string; + /** Total sessions per ImeVersionHistory (includes sessions without a terminal run). */ + fleetSessions?: number; +} + /** A tracked IME version sighting. Permanent archive that survives data retention. */ export interface ImeVersionHistoryEntry { version: string; @@ -1277,6 +1884,14 @@ export interface ImeVersionHistoryEntry { msiSourceUrl?: string; } +/** Redacted IME version row served to non-global callers of GET metrics/ime-versions: the platform-wide rollout facts without the first-seen tenant/session identifiers or the archive columns (see GetImeVersionHistoryFunction.BuildResponsePayload). */ +export interface ImeVersionHistoryLeanEntry { + version: string; + firstSeenAt: string; + lastSeenAt: string; + sessionCount: number; +} + /** Response of GET auth/is-global-admin: whether the caller holds the Global Admin platform role, echoing the caller's UPN. */ export interface IsGlobalAdminResponse { isGlobalAdmin: boolean; @@ -1284,6 +1899,11 @@ export interface IsGlobalAdminResponse { upn?: string; } +/** Response of GET global/backups: every backupId in the critical-table-backups container, newest first. Serialized with SerializerOptions (the backup surface's own options), not the ApiJsonOptions pipeline. */ +export interface ListBackupsResponse { + backupIds: string[]; +} + /** Success body of GET /api/global/raw/tables (ListRawTables): the queryable table names. */ export interface ListRawTablesResponse { count: number; @@ -1296,6 +1916,64 @@ export interface ListTenantConfigBackupsResponse { backups: TenantConfigBackupItem[]; } +/** Performance metrics for a single geographic location. */ +export interface LocationMetrics { + locationKey: string; + country: string; + region: string; + city: string; + loc: string; + sessionCount: number; + succeeded: number; + failed: number; + successRate: number; + avgDurationMinutes: number; + medianDurationMinutes: number; + p95DurationMinutes: number; + /** Average number of apps installed per session at this location */ + avgAppCount: number; + /** Average minutes per app (AvgDurationMinutes / AvgAppCount) */ + minutesPerApp: number; + /** Normalized score: 100 = global median, lower is better */ + appLoadScore: number; + /** Average download throughput in bytes/sec at this location */ + avgThroughputBytesPerSec: number; + totalDownloadBytes: number; + /** Percentage difference from global avg duration (negative = faster) */ + durationVsGlobalPct: number; + /** Percentage difference from global avg throughput (positive = faster) */ + throughputVsGlobalPct: number; + /** 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). A single corrupt session average (e.g. a request spanning a sleep/hibernate) can dominate this figure — use MedianApiLatencyMs as the display statistic. */ + avgApiLatencyMs: number; + /** Median of the per-session average agent→backend round-trips (ms) at this location — robust against outlier sessions. 0 when no session here carries latency data. */ + medianApiLatencyMs: number; + /** Sessions at this location that carry API-latency data */ + apiLatencySessionCount: number; + /** Percentage difference from global median API latency (positive = slower/farther) */ + apiLatencyVsGlobalPct: number; + isOutlier: boolean; + /** "fast", "slow", or null */ + outlierDirection?: string; + /** Sessions at this location that have DO telemetry data */ + doSessionCount: number; + /** Weighted percentage of bytes from peers (0-100), computed from total peer/total DO bytes */ + avgDoPercentPeerCaching: number; + /** Total bytes downloaded from all peer sources */ + totalDoBytesFromPeers: number; + /** Total bytes downloaded from HTTP/CDN */ + totalDoBytesFromHttp: number; + /** Bytes from LAN peers */ + totalDoBytesFromLanPeers: number; + /** Bytes from group peers */ + totalDoBytesFromGroupPeers: number; + /** Bytes from internet peers */ + totalDoBytesFromInternetPeers: number; + /** Bytes from link-local peers (same subnet) */ + totalDoBytesFromLinkLocalPeers: number; + /** Bytes served from a Microsoft Connected Cache (MCC) — separate from BytesFromPeers */ + totalDoBytesFromCacheServer: number; +} + /** Lean projection of LocationSessionRow used as the default drilldown row. Fields chosen to support the typical MCP triage flow (which session, when, where, how big a deal, was DO active). Built by GetGeographicLocationSessionsFunction.ToLeanRow. */ export interface LocationSessionLeanRow { sessionId: string; @@ -1513,6 +2191,17 @@ export interface NvdRefreshRunWire { finishedUtc?: string; } +/** 202/200 response body for the offboarding endpoint. Fields point the caller at the History row so subsequent reporting / status polling can resolve back to the audit trail. EarliestProcessingAt drives the "data deletion starts in mm ss" countdown in the Web UI's drain-barrier state. */ +export interface OffboardResponse { + tenantId: string; + status: string; + historyPartitionKey: string; + historyRowKey: string; + message: string; + /** UTC timestamp before which the worker MUST NOT start Phase 2. Drives the cache-drain-barrier countdown UI. Absent on the idempotent-Completed/Failed branches. */ + earliestProcessingAt?: string; +} + export interface OpsEventEntry { id: string; category: string; @@ -1534,6 +2223,26 @@ export interface OpsEventListResponse { nextLink?: string; } +/** Distribution of a per-session statistic across a version bucket. */ +export interface PercentileStats { + p50: number; + p95: number; + max: number; + avg: number; + sampleCount: number; +} + +export interface PerformanceMetrics { + avgDurationMinutes: number; + medianDurationMinutes: number; + p95DurationMinutes: number; + p99DurationMinutes: number; + /** Number of sessions contributing to the duration distribution (after the >0 filter). */ + sampleCount: number; + /** Number of sessions whose raw duration exceeded the clamp ceiling and were capped before aggregation. A non-zero value flags stuck/non-terminal sessions skewing the window — the percentiles above are computed on the clamped values, not the runaway wall-clock duration. */ + clampedSessionCount: number; +} + /** Defines a usage plan tier with request limits. Stored as JSON array in AdminConfiguration.PlanTierDefinitionsJson. */ export interface PlanTierDefinition { name: string; @@ -1547,6 +2256,72 @@ export interface PlanTierDefinitionsResponse { tiers: PlanTierDefinition[]; } +/** Response of GET global/metrics/platform (GetGlobalPlatformMetrics). */ +export interface PlatformAgentMetricsResponse { + sessions: SessionAgentMetric[]; + deliveryLatency?: DeliveryLatencyMetrics; + crashRate?: CrashRateMetrics; + computedAt: string; + computeDurationMs: number; + fromCache: boolean; + windowDays: number; + sessionLimit: number; + /** Sessions the scan actually covered in the window (before the has-snapshots filter that shapes Sessions). Callers must compare THIS against SessionLimit to decide whether the window was truncated — Sessions.Count understates truncation on fleets where many sessions emit no agent_metrics_snapshot. */ + sessionsScanned: number; +} + +/** Pre-computed platform-wide statistics for the public landing page. Stored as a single row (PartitionKey: "global", RowKey: "current"). Incremented during registration/ingest/login; the daily maintenance recompute treats every cumulative counter as a monotonic "since release" high-water-mark (raise-only — the scanned tables are retention-pruned, so a raw recompute would regress the figures). Only TotalSignedUpTenants is current-state (its source table is not retention-pruned). See MaintenanceService.BuildMonotonicPlatformStats. */ +export interface PlatformStats { + /** Total enrollment sessions monitored since launch */ + totalEnrollments: number; + /** Total unique users who logged in */ + totalUsers: number; + /** Total unique tenants using the platform (have at least one session) */ + totalTenants: number; + /** Total tenants signed up (have a tenant configuration entry) */ + totalSignedUpTenants: number; + /** Total unique device models seen (manufacturer + model) */ + uniqueDeviceModels: number; + /** Total events processed across all tenants */ + totalEventsProcessed: number; + /** Total successful enrollments */ + successfulEnrollments: number; + /** Total analysis issues detected */ + issuesDetected: number; + /** When these stats were last fully recomputed */ + lastFullCompute: string; + /** When these stats were last updated (including incremental) */ + lastUpdated: string; +} + +/** Platform usage metrics response */ +export interface PlatformUsageMetrics { + /** Session metrics */ + sessions: SessionMetrics; + /** Tenant metrics */ + tenants: TenantMetrics; + /** User metrics (requires Entra ID authentication) */ + users: UserMetrics; + /** Performance metrics */ + performance: PerformanceMetrics; + /** Hardware metrics */ + hardware: HardwareMetrics; + /** Deployment type metrics (User Driven vs White Glove) */ + deploymentTypes: DeploymentTypeMetrics; + /** App and script count metrics */ + appScripts: AppScriptMetrics; + /** Platform statistics (cumulative since release) */ + platformStats?: PlatformStats; + /** When these metrics were computed */ + computedAt: string; + /** How long it took to compute (milliseconds) */ + computeDurationMs: number; + /** Whether result is from cache */ + fromCache: boolean; + /** Time window (in days) the metrics were computed over. */ + windowDays: number; +} + /** Response of GET /api/progress/sessions/{sessionId}/events — the session's event stream after the serial knowledge proof passed. */ export interface ProgressGetSessionEventsResponse { success: boolean; @@ -1771,6 +2546,43 @@ export interface RulePrecondition { description?: string; } +/** One ACTIVE rule-frequency regression (F3, insights spec §F3): an analyze rule whose 7-day hit rate rose ≥2× over its 28-day baseline with disjoint Wilson intervals. Persisted as the ruleregression|{ruleId} keyspace of the notification tracker table — the row IS the dedup (one bell per episode), the badge state (rules page) and the regressions[] payload (rule-stats response). Deleted when the rate re-arms (falls under 1.5× baseline or stops firing) or by the tracker's 30-day retention sweep (spec: retention cleanup re-arms). Numbers are refreshed on every radar pass while the episode stays active; FirstNotifiedAt never moves. */ +export interface RuleRegressionAlert { + tenantId: string; + ruleId: string; + ruleTitle: string; + windowFireCount: number; + windowSessionCount: number; + baselineFireCount: number; + baselineSessionCount: number; + windowRatePct: number; + baselineRatePct: number; + /** Window rate ÷ baseline rate. Null when the baseline rate is 0 (a NEW signal has no finite lift — never invented). */ + lift?: number; + /** Trailing 7-day window ("yyyy-MM-dd", inclusive) the current numbers describe. */ + windowStartDate: string; + windowEndDate: string; + /** Dimension concentration captured when the alert FIRST fired (stable story); null = no clear concentration. */ + dimension?: RuleRegressionDimension; + /** When the episode first fired (bell + ops event moment). Never moves on refresh; drives the 30d retention re-arm. */ + firstNotifiedAt: string; + /** Last radar pass that re-confirmed/refreshed this episode. */ + lastEvaluatedAt: string; +} + +/** The dominant dimension value among a regression's hit sessions — CORRELATION only, and every consumer's wording must say so ("correlated — not necessarily causal", insights spec §F3 / truthfulness rule 6). Null on an alert means "no clear dimension concentration" — the radar never stretches for one. */ +export interface RuleRegressionDimension { + /** "osBuild", "model", "agentVersion" or "imeVersion". */ + dimension: string; + value: string; + /** Hit sessions carrying this value (gate: ≥5). */ + hitCount: number; + hitSharePct: number; + allSharePct: number; + /** HitShare ÷ AllShare (gate: ≥2.0). */ + lift: number; +} + /** Result of an analyze rule evaluation against a session's events Stored in the RuleResults table and displayed in the session detail UI */ export interface RuleResult { /** Unique identifier for this result */ @@ -1811,6 +2623,56 @@ export interface RuleResult { notifiedAt?: string; } +/** Echo of the effective date window ("yyyy-MM-dd"). */ +export interface RuleStatsPeriod { + start: string; + end: string; +} + +/** Response of GET metrics/rule-stats and GET global/metrics/rule-stats: per-rule firing aggregates over the requested window, the active rule-frequency regression episodes (tenant scope only — empty on the cross-tenant aggregate), and window totals. */ +export interface RuleStatsResponse { + rules: RuleStatsRuleAggregate[]; + regressions: RuleRegressionAlert[]; + summary: RuleStatsSummary; +} + +/** One rule's aggregate across all dates in the window (fires descending). */ +export interface RuleStatsRuleAggregate { + ruleId: string; + ruleType: string; + ruleTitle: string; + category: string; + severity: string; + fireCount: number; + evaluationCount: number; + sessionsEvaluated: number; + /** Fires per evaluation as a percentage (one decimal); 0 with no evaluations. */ + hitRate: number; + /** Average confidence score across fires (one decimal); 0 with no fires. */ + avgConfidenceScore: number; + /** Per-day trend rows, oldest first (one row per stored date). */ + trend: RuleTrendPoint[]; +} + +/** Window totals of a rule-stats response. */ +export interface RuleStatsSummary { + totalEvaluations: number; + totalFires: number; + overallHitRate: number; + /** Rule id with the most fires; absent when the window holds no rules. */ + topRuleByFireCount?: string; + /** Distinct rule count — set on the global route only (the key is absent on the tenant route, preserving its historical shape). */ + uniqueRules?: number; + period: RuleStatsPeriod; +} + +/** One day of a rule's trend ("yyyy-MM-dd"). */ +export interface RuleTrendPoint { + date: string; + fireCount: number; + evaluationCount: number; +} + /** Response of POST vulnerability/cpe-mapping on a successful save. */ export interface SaveCustomCpeMappingResponse { success: boolean; @@ -1828,6 +2690,33 @@ export interface SearchSessionsResponse { nextLink?: string; } +export interface SessionAgentMetric { + sessionId: string; + tenantId: string; + deviceName?: string; + manufacturer?: string; + model?: string; + startedAt?: string; + status?: string; + agentVersion?: string; + snapshotCount: number; + totalBytesUp: number; + totalBytesDown: number; + totalRequests: number; + avgCpu: number; + maxCpu: number; + avgWorkingSet: number; + maxWorkingSet: number; + avgPrivateBytes: number; + avgLatency: number; + avgSpoolDepth: number; + maxSpoolDepth: number; + peakSpoolDepth: number; + maxSpoolFileBytes: number; + totalEventsEmitted: number; + spoolPressureDetected: boolean; +} + /** Wire shape of one session annotation as returned by the session-scoped annotation endpoints (lane implied by context, tenant/session implied by the route). Built by AnnotationWire.ToWire. */ export interface SessionAnnotationItem { lane: string; @@ -1898,6 +2787,15 @@ export interface SessionDeletionProgressWire { lastResidualSampleJson?: string; } +/** Success body (202 Accepted) of the V2 cascade-delete enqueue — the non-success arms stay anonymous error bodies by design (one shape: success=false + message). */ +export interface SessionDeletionQueuedResponse { + success: boolean; + /** Always "queued". */ + status: string; + manifestId?: string; + message: string; +} + /** Paged session listing envelope shared by GetSessions, GetAllSessions, SearchSessionsByCve and SearchSessionsByEvent (identical wire shape). */ export interface SessionListResponse { success: boolean; @@ -1907,6 +2805,21 @@ export interface SessionListResponse { nextLink?: string; } +export interface SessionMetrics { + total: number; + /** Cumulative enrollment sessions since the tenant signed up (retention-independent per-tenant counter, see TenantStats). Null on the platform-wide (all-tenants) metrics, which carry the cumulative figure in PlatformStats instead. */ + totalAllTime?: number; + today: number; + last7Days: number; + last30Days: number; + succeeded: number; + failed: number; + inProgress: number; + /** Terminal, non-failure sessions (timeout reclassification): the sweep classified them as Incomplete instead of Failed. Surfaced as its own count and deliberately excluded from SuccessRate (denominator = Succeeded + Failed only), mirroring SessionStats and FleetHealthStats. See tasks/enrollment-status-reclassification.md §5. */ + incomplete: number; + successRate: number; +} + /** Success body of GET global/session-reports/download-url: short-lived SAS download URL for a session report blob. */ export interface SessionReportDownloadUrlResponse { success: boolean; @@ -1941,6 +2854,26 @@ export interface SessionReportMetadata { diagnosticsCopyStatus?: string; } +/** Body of POST global/sessions/{id}/restore for every outcome (success AND reject — the outcome/status mapping decides the HTTP code, the shape stays one). */ +export interface SessionRestoreResponse { + success: boolean; + /** SessionRestoreOutcome name (e.g. "Restored", "DryRunOk", "RejectManifestNotFound"). */ + outcome: string; + /** "full" | "partial" | "dryRun"; absent on early rejects. */ + mode?: string; + /** Operator-readable reason; absent on clean successes. */ + message?: string; + /** Reject diagnostics; absent otherwise. */ + currentState?: string; + /** Reject diagnostics; absent otherwise. */ + pendingManifestId?: string; + rowsRestoredByTable: Record; + rowsSkippedByTable: Record; + wouldRestoreByTable: Record; + inventoryReIncrements: number; + durationMs: number; +} + /** Aggregated session counters for the dashboard stats cards. Computed server-side over a windowed scan of the SessionsIndex so the numbers don't drift with whatever the client happens to have paginated into memory. */ export interface SessionStats { /** Window the windowed counters were computed over (matches request). */ @@ -2149,6 +3082,42 @@ export interface SignalRecord { payloadJson: string; } +/** SLA metrics response for a given tenant and time window. */ +export interface SlaMetricsResponse { + targetSuccessRate?: number; + targetMaxDurationMinutes?: number; + targetAppInstallSuccessRate?: number; + /** Current ISO week SLA snapshot. */ + currentWeek: SlaSnapshot; + /** Weekly trend (newest first). */ + weeklyTrend: SlaWeeklyTrend[]; + /** Sessions that breached SLA targets (failed or exceeded duration). */ + violators: SlaViolatorSession[]; + /** App install SLA snapshot (null if no app install target configured). */ + appInstallSla?: AppInstallSlaSnapshot; + computedAt: string; + fromCache: boolean; + computeDurationMs: number; +} + +/** SLA compliance snapshot for a single period (ISO week). */ +export interface SlaSnapshot { + /** ISO week identifier, e.g. "2026-W15". */ + week: string; + totalCompleted: number; + succeeded: number; + failed: number; + successRate: number; + avgDurationMinutes: number; + p95DurationMinutes: number; + /** Number of sessions that exceeded the duration target. */ + durationViolationCount: number; + /** Whether the success rate target is met. */ + successRateMet: boolean; + /** Whether the P95 duration target is met. */ + durationTargetMet: boolean; +} + /** A session that violated SLA targets. */ export interface SlaViolatorSession { sessionId: string; @@ -2164,6 +3133,19 @@ export interface SlaViolatorSession { violationType: string; } +/** SLA compliance trend entry for one ISO week. */ +export interface SlaWeeklyTrend { + /** ISO week identifier, e.g. "2026-W15". */ + week: string; + successRate: number; + p95DurationMinutes: number; + appInstallSuccessRate: number; + totalCompleted: number; + successRateMet: boolean; + durationTargetMet: boolean; + appInstallTargetMet: boolean; +} + /** One completed sleep episode observed during the session (system_sleep_episode payload — enteredAt/exitedAt are the authoritative instants, never the event's own timestamp, which is stamped at wake and may be clamped). Cross-cutting annotation like RebootSpan: sleep overlaps the segment it started in and is NOT part of the wall-clock partition — the session really took that long; the span explains where the time went. */ export interface SleepSpan { startUtc: string; @@ -2482,6 +3464,41 @@ export interface TenantDeletionManifestSessionNode { manifests: TenantDeletionManifestItem[]; } +/** Read-time entitlement surface of the resolved edition. */ +export interface TenantFeatureEntitlements { + retentionCapDays: number; + /** Absent when the platform default applies. */ + userRateLimitPerMinute?: number; + delegatedAdminAllowed: boolean; + mcpUsagePlan: string; +} + +/** Response of GET config/{tenantId}/feature-flags: the member-readable subset of the tenant configuration — UI display toggles, feature switches and the read-time edition/entitlement surface. Adding a field here is a deliberate decision that the field is non-sensitive (no webhook URLs, SAS tokens, allowlists, addresses). */ +export interface TenantFeatureFlagsResponse { + /** EFFECTIVE bootstrap availability (Pro includes it; the GA flag is the additive Community enable) — field name kept for web compatibility. */ + bootstrapTokenEnabled: boolean; + /** Whether an on-demand diagnostics upload can succeed right now (mode not Off + usable destination). Deliberately no destination detail. */ + diagnosticsUploadConfigured: boolean; + /** Drives the "Autopilot Device Validation disabled" dashboard banner. */ + validateAutopilotDevice: boolean; + /** Dual app-reg self-service migration: consent flow targets the NEW app registration. Non-sensitive — exposes no client ids. */ + appHomingFunnelActive: boolean; + showScriptOutput: boolean; + enableSoftwareInventoryAnalyzer: boolean; + enableIntegrityBypassAnalyzer: boolean; + /** EFFECTIVE gather-rule unrestricted mode (Pro edition + GA gate + tenant toggle). */ + unrestrictedMode: boolean; + /** Resolved edition, lowercase ("community" / "pro"). */ + edition: string; + isTrial: boolean; + /** Absent unless the tenant is on an active trial. */ + trialExpiresUtc?: string; + trialAvailable: boolean; + /** Whether a contact address is stored (boolean only — the address stays admin-gated). */ + contactEmailSet: boolean; + entitlements: TenantFeatureEntitlements; +} + /** A Tenant Group: an app-internal named bundle of tenants. A delegated admin assigned to the group (see TenantGroupAssignment) gains read scope to every tenant in TenantIds. Adding a tenant to the group grants it to all assignees at once. */ export interface TenantGroup { groupId: string; @@ -2512,6 +3529,12 @@ export interface TenantGroupListResponse { groups: TenantGroup[]; } +export interface TenantMetrics { + total: number; + active7Days: number; + active30Days: number; +} + /** Snapshot of a tenant's custom rule row written by the offboarding handler during Phase 2.D-archive, BEFORE the original row is safe-wiped. Survives forever so a Global Admin can review (and selectively delete) the tenant's custom rules from /admin/customs-archive after offboarding completes. Storage layout (see PR3.B plan §3.2): PartitionKey: "{normalizedTenantId}_{historyRowKey}". One partition per offboarding run — Re-Re-Offboarding produces a fresh, immutable partition each time so multiple runs co-exist without RowKey collisions.RowKey: "{originalTable}_{base64url(originalRowKey)}". The table-name prefix disambiguates collisions across the three rules tables; the base64url encoding of avoids the Azure-Tables RowKey-forbidden characters (#, ?, /, \, control chars). */ export interface TenantOffboardingCustomsArchiveEntry { /** "{normalizedTenantId}_{historyRowKey}". */ @@ -2549,9 +3572,62 @@ export interface TestWebhookNotificationResponse { message: string; } +/** Per-app rollup of ESP-blocking install intervals across the clean sessions of an aggregate row. The what-if numbers are the per-session critical-path savings from removing this app (recomputed union end without it) — an upper BOUND by construction: UI copy must say "up to", never "you will save" (truthfulness rule 3). */ +export interface TimeAttributionBlockingAppStat { + appId: string; + appName: string; + /** Clean sessions with a measured interval for this app (row gate: ≥5). */ + sessionCount: number; + medianSeconds: number; + p75Seconds: number; + medianSavingSeconds: number; + p75SavingSeconds: number; +} + +/** Daily fleet rollup of session time attribution for one (tenant, enrollment class, date) — F1 PR2 (insights spec §F1 "Data & compute changes"). Enrollment classes are never mixed (a WhiteGlove flow has a structurally different time profile than user-driven). Only breakdowns with None enter the statistics; flagged sessions are excluded WITH a disclosed count (truthfulness rule 7). Rows are written even below the ≥20-session UI gate — the UI needs the n to say "insufficient data (n=3)" instead of silently rendering a small-n median (rule 4). Recomputed idempotently by the rolling 30-day maintenance sweep. */ +export interface TimeAttributionDailyAggregate { + /** Calendar day (UTC) the sessions STARTED, "yyyy-MM-dd" — same bucketing as the usage snapshots. */ + date: string; + /** Tenant GUID, or "global" for the cross-tenant row. */ + tenantId: string; + /** Enrollment class: "user_driven", "whiteglove", "self_deploying" or "device_preparation" (WDP v2). */ + enrollmentClass: string; + /** Attribution algorithm version the underlying breakdowns were computed with (rule 8: never mix definitions). */ + attributionVersion: number; + /** Breakdowns with QualityFlags == None that formed the statistics. */ + cleanSessionCount: number; + /** Flagged breakdowns excluded from the statistics (disclosed, never silent). */ + flaggedExcludedCount: number; + /** Terminal sessions of this bucket without a computable breakdown (e.g. events aged out before backfill). */ + missingBreakdownCount: number; + segmentStats: TimeAttributionSegmentStat[]; + /** Top blocking apps by median interval, gated at ≥5 sessions per app, capped at 20. */ + topBlockingApps: TimeAttributionBlockingAppStat[]; + computedAt: string; +} + /** Data-quality flags for a SessionTimeBreakdown (truthfulness rule 7: problems flag the record and exclude it from fleet aggregates with a disclosed count, rather than silently skewing them). */ export type TimeAttributionFlags = "None" | "ClockSkewDropped" | "PartialObservation" | "BlockingSetUnknown" | "BlockingSetTruncated" | "WhiteGloveAnchorsIncomplete" | "PriorEnrollmentResidue"; +/** Response of GET metrics/time-attribution and GET global/metrics/time-attribution: the rolling 30-day range statistics per enrollment class plus the daily rows for the per-day trend. The range window is FIXED at the sweep's 30 days. */ +export interface TimeAttributionMetricsResponse { + success: boolean; + windowDays: number; + /** Range statistics per enrollment class (never mixed), class-name ordinal order. */ + classes: TimeAttributionDailyAggregate[]; + /** Daily rows of the window, date ordinal order. */ + daily: TimeAttributionDailyAggregate[]; +} + +/** Distribution of one attribution segment across the clean sessions of an aggregate row. Values are whole seconds; a session that has no span of this segment contributes 0 — the distribution answers "time spent in this segment per enrollment of this class", not "per enrollment that happened to enter it". */ +export interface TimeAttributionSegmentStat { + /** Segment key (TimeAttributionSegments), including "unattributed" — the stack must sum to the wall clock (truthfulness rule 2). */ + segmentKey: string; + medianSeconds: number; + p75Seconds: number; + p90Seconds: number; +} + /** One contiguous attributed span inside an observation window. */ export interface TimeAttributionSpan { /** Segment key — one of the TimeAttributionSegments constants (never "unattributed": the remainder is a scalar, not a span claim). */ @@ -2574,6 +3650,14 @@ export interface TopCve { sampleSoftware: string[]; } +/** An app with a high failure rate. */ +export interface TopFailingApp { + appName: string; + failCount: number; + totalCount: number; + successRate: number; +} + /** One serial-number bucket in the GetTpmPssUnsupported aggregation. All values are self-reported by devices through the unauthenticated distress channel — UNVERIFIED. */ export interface TpmPssUnsupportedItem { serialNumber: string; @@ -2709,6 +3793,19 @@ export interface UpsertSessionAnnotationResponse { annotation: SessionAnnotationItem; } +export interface UserMetrics { + /** Total unique users (available after Entra ID integration) */ + total: number; + /** Daily logins across all users */ + dailyLogins: number; + /** Active users in last 7 days */ + active7Days: number; + /** Active users in last 30 days */ + active30Days: number; + /** Note about availability */ + note: string; +} + /** Aggregated daily usage summary across endpoints (and optionally users). */ export interface UserUsageDailySummary { date: string; @@ -2729,6 +3826,97 @@ export interface UserUsageRecord { lastRequestAt: string; } +/** One ACTIVE verdict-calibration alert episode (operator-only). Persisted as the verdictcalibration|{kind}|{path}|{status} keyspace of the notification tracker — the row IS the dedup (one ops event per episode) and the alerts[] payload of the calibration endpoint. Deleted when the signal re-arms (share back under 1.5× baseline, path stops occurring, evidence-gap share back under 15 %) or by the tracker's 30-day retention sweep. Numbers refresh on every radar pass; FirstNotifiedAt never moves. Dimension concentration is CORRELATION only — every consumer says so. */ +export interface VerdictCalibrationAlert { + tenantId: string; + /** One of VerdictCalibrationAlertKinds. */ + kind: string; + /** Verdict path, or the group label for group kinds ("sweep+maxlife", "r6"). */ + verdictPath: string; + /** Status the path produced, or "*" for group kinds. */ + status: string; + windowHitCount: number; + windowSessionCount: number; + baselineHitCount: number; + baselineSessionCount: number; + windowRatePct: number; + baselineRatePct: number; + /** Window rate ÷ baseline rate. Null when the baseline rate is 0 or the kind is absolute (evidence gap). */ + lift?: number; + windowStartDate: string; + windowEndDate: string; + /** Dimension concentration captured when the alert FIRST fired; null = no clear concentration. */ + dimension?: RuleRegressionDimension; + firstNotifiedAt: string; + lastEvaluatedAt: string; +} + +/** One verdict path × status row of the calibration matrix. */ +export interface VerdictCalibrationPathRow { + verdictPath: string; + status: string; + count: number; + sharePct: number; + derivedCount: number; + eligible7d: number; + reEnrolled7d: number; + /** Null below the minimum eligible count — never a rate on a handful of sessions. */ + reEnrollRatePct?: number; + overriddenByAdmin: number; + overriddenByLateCompletion: number; + overriddenOther: number; + window7: VerdictCalibrationTrendWindow; + baseline28: VerdictCalibrationTrendWindow; + /** Window share ÷ baseline share; null when the baseline share is 0 (a new path has no finite lift — never invented). */ + lift?: number; +} + +/** Response of GET global/metrics/verdict-calibration: per verdict path, how many sessions it produced in the window, its share, overrides attributed to the prior path, the 7-day re-enrollment proxy, a 7d-vs-28d trend, and the active drift alerts. Operator-only classifier diagnostics. */ +export interface VerdictCalibrationResponse { + success: boolean; + /** Partition echo: a tenant GUID, or "global" for the cross-tenant aggregate. */ + tenantId: string; + windowDays: number; + /** Inclusive window start ("yyyy-MM-dd"). */ + windowStart: string; + /** Inclusive window end ("yyyy-MM-dd", today). */ + windowEnd: string; + /** Newest aggregate compute time in the window; absent when the window holds no rows. */ + computedAt?: string; + /** Distinct aggregation algorithm versions contributing to the window, ascending. */ + versions: number[]; + totals: VerdictCalibrationTotals; + trend: VerdictCalibrationTrendMeta; + /** Rows ordered by count descending, then path/status ordinal. */ + paths: VerdictCalibrationPathRow[]; + /** Active drift episodes, newest first. */ + alerts: VerdictCalibrationAlert[]; +} + +/** Window totals of the calibration matrix. */ +export interface VerdictCalibrationTotals { + sessions: number; + terminal: number; + derived: number; + /** Aggregate days that contributed to the window. */ + days: number; +} + +/** Trend denominators shared by every row (single source, never per-row copies). */ +export interface VerdictCalibrationTrendMeta { + windowDays: number; + baselineDays: number; + windowSessions: number; + baselineSessions: number; +} + +/** One trend window of a path row. */ +export interface VerdictCalibrationTrendWindow { + count: number; + sessions: number; + sharePct: number; +} + export interface VerificationIssue { /** Info / Warning / Error. */ severity: string; diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts index 54e39d1b..cbe971a8 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts @@ -14,8 +14,12 @@ import type { GetMcpUserUsageResponse, GetMyMcpUsageResponse, GetUnmatchedSoftwareResponse, + PlatformAgentMetricsResponse, QueryRawTableResponse, + RuleStatsResponse, + RuleStatsRuleAggregate, SoftwareInventoryResponse, + VerdictCalibrationResponse, } from '../generated/wire-types.generated.js'; /** @@ -457,31 +461,12 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo }, async (args) => withToolTelemetry('get_platform_metrics', args, async () => { try { - type SessionMetric = { - sessionId: string; tenantId: string; deviceName?: string; model?: string; status?: string; - agentVersion?: string; snapshotCount: number; - avgCpu: number; maxCpu: number; avgWorkingSet: number; maxWorkingSet: number; - avgPrivateBytes: number; avgLatency: number; - totalBytesUp: number; totalBytesDown: number; totalRequests: number; - avgSpoolDepth: number; maxSpoolDepth: number; peakSpoolDepth: number; - maxSpoolFileBytes: number; totalEventsEmitted: number; spoolPressureDetected: boolean; - }; // Backend fans out one storage query per session (bounded to 32 concurrent), so // 2000-session windows take longer than the default 30s client timeout — same // override precedent as get_session_summary. Warm (cached) calls return instantly. const raw = await apiFetch( `/api/global/metrics/platform${buildQuery({ days: args.days, limit: args.maxSessions })}`, - { signal: AbortSignal.timeout(90_000) }) as - { - sessions?: SessionMetric[]; windowDays?: number; sessionLimit?: number; sessionsScanned?: number; - deliveryLatency?: { p50Ms: number; p95Ms: number; p99Ms: number; avgMs: number; sampleCount: number; clockSkewPercent: number }; - crashRate?: { - totalStarts: number; cleanExits: number; exceptionCrashes: number; hardKills: number; - rebootKills: number; firstRuns: number; crashRatePercent: number; - topExceptions?: { exceptionType: string; count: number }[]; - }; - computedAt?: string; computeDurationMs?: number; fromCache?: boolean; - }; + { signal: AbortSignal.timeout(90_000) }) as PlatformAgentMetricsResponse; const sessions = raw?.sessions ?? []; const requested = { days: args.days, limit: args.maxSessions }; // Backend-computed blocks that exist independently of snapshot availability @@ -898,7 +883,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo async (args) => withToolTelemetry('get_verdict_calibration', args, async () => { try { const { tenantId, days, minSharePct, top } = args; - const data = await apiFetch(`/api/global/metrics/verdict-calibration${buildQuery({ tenantId, days })}`); + const data = await apiFetch(`/api/global/metrics/verdict-calibration${buildQuery({ tenantId, days })}`) as VerdictCalibrationResponse; return toolResultText(shapeVerdictCalibration(data, { minSharePct, top }), MAX_RESULT_SIZE_CHARS.adminStream); } catch (error: unknown) { return toolError('get_verdict_calibration', args, error); @@ -1490,15 +1475,15 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo }; if (tenantId) params.tenantId = tenantId; const prefix = pickGlobalOrTenantPath('/api/global/metrics', '/api/metrics', tenantId); - const data = await apiFetch(`${prefix}/rule-stats${buildQuery(params)}`) as { - rules?: Array>; - trendsNote?: string; - }; + const data = await apiFetch(`${prefix}/rule-stats${buildQuery(params)}`) as + RuleStatsResponse & { trendsNote?: string }; // The per-rule daily trend arrays are the size driver (one row per rule // per day; a 30-day platform-scope response reaches 150k+ chars and gets // host-truncated). Aggregates stay intact — trends only on request. + // (Dropping `trend` and adding `trendsNote` is this tool's OUTPUT contract, + // layered on the wire type — hence the Partial view for the delete.) if (!args.includeTrends && Array.isArray(data?.rules)) { - for (const rule of data.rules) delete rule.trend; + for (const rule of data.rules) delete (rule as Partial).trend; data.trendsNote = 'Per-rule daily trend rows omitted. Re-call with includeTrends=true (plus a narrow startDate/endDate window) to include them.'; } return toolResultText(data, MAX_RESULT_SIZE_CHARS.small); diff --git a/src/McpServer/autopilot-monitor-mcp/src/verdict-calibration-shape.ts b/src/McpServer/autopilot-monitor-mcp/src/verdict-calibration-shape.ts index c2bd32a4..c687e355 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/verdict-calibration-shape.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/verdict-calibration-shape.ts @@ -12,21 +12,23 @@ * in `omitted` so a filtered response never reads as complete coverage. */ -interface TrendCell { - count: number; +import type { + VerdictCalibrationPathRow as WirePathRow, + VerdictCalibrationTrendWindow as WireTrendWindow, +} from './generated/wire-types.generated.js'; + +// Derived from the generated wire types so a backend rename of any read field goes +// tsc-red here; the deltas below are THIS tool's output contract, not the wire. +interface TrendCell extends Omit { + /** Dropped in shaped output — the denominators live once in `trend`. */ sessions?: number; - sharePct: number; } -export interface VerdictCalibrationPathRow { - verdictPath: string; - status: string; - count: number; - sharePct: number; - overriddenByAdmin: number; - overriddenByLateCompletion: number; - overriddenOther: number; +export interface VerdictCalibrationPathRow + extends Omit { + /** Explicit null in shaped output (wire omits the key when withheld). */ reEnrollRatePct?: number | null; + /** Explicit null in shaped output (wire omits the key when withheld). */ lift?: number | null; window7: TrendCell; baseline28: TrendCell; diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Backup/ListBackupsResponse.cs b/src/Shared/AutopilotMonitor.Shared/Models/Backup/ListBackupsResponse.cs new file mode 100644 index 00000000..c6ecb0c5 --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Models/Backup/ListBackupsResponse.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; + +namespace AutopilotMonitor.Shared.Models.Backup +{ + /// + /// Response of GET global/backups: every backupId in the critical-table-backups + /// container, newest first. Serialized with + /// (the backup surface's own options), not the ApiJsonOptions pipeline. + /// + public class ListBackupsResponse : IApiResponse + { + public IReadOnlyList BackupIds { get; set; } = default!; + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantFeatureFlagsApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantFeatureFlagsApiModels.cs new file mode 100644 index 00000000..4ed8c83d --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantFeatureFlagsApiModels.cs @@ -0,0 +1,48 @@ +using System; + +namespace AutopilotMonitor.Shared.Models +{ + // Declaration order == wire order. + + /// + /// Response of GET config/{tenantId}/feature-flags: the member-readable subset of + /// the tenant configuration — UI display toggles, feature switches and the read-time + /// edition/entitlement surface. Adding a field here is a deliberate decision that the + /// field is non-sensitive (no webhook URLs, SAS tokens, allowlists, addresses). + /// + public class TenantFeatureFlagsResponse : IApiResponse + { + /// EFFECTIVE bootstrap availability (Pro includes it; the GA flag is the additive Community enable) — field name kept for web compatibility. + public bool BootstrapTokenEnabled { get; set; } + /// Whether an on-demand diagnostics upload can succeed right now (mode not Off + usable destination). Deliberately no destination detail. + public bool DiagnosticsUploadConfigured { get; set; } + /// Drives the "Autopilot Device Validation disabled" dashboard banner. + public bool ValidateAutopilotDevice { get; set; } + /// Dual app-reg self-service migration: consent flow targets the NEW app registration. Non-sensitive — exposes no client ids. + public bool AppHomingFunnelActive { get; set; } + public bool ShowScriptOutput { get; set; } + public bool EnableSoftwareInventoryAnalyzer { get; set; } + public bool EnableIntegrityBypassAnalyzer { get; set; } + /// EFFECTIVE gather-rule unrestricted mode (Pro edition + GA gate + tenant toggle). + public bool UnrestrictedMode { get; set; } + /// Resolved edition, lowercase ("community" / "pro"). + public string Edition { get; set; } = string.Empty; + public bool IsTrial { get; set; } + /// Absent unless the tenant is on an active trial. + public DateTime? TrialExpiresUtc { get; set; } + public bool TrialAvailable { get; set; } + /// Whether a contact address is stored (boolean only — the address stays admin-gated). + public bool ContactEmailSet { get; set; } + public TenantFeatureEntitlements Entitlements { get; set; } = default!; + } + + /// Read-time entitlement surface of the resolved edition. + public class TenantFeatureEntitlements + { + public int RetentionCapDays { get; set; } + /// Absent when the platform default applies. + public int? UserRateLimitPerMinute { get; set; } + public bool DelegatedAdminAllowed { get; set; } + public string McpUsagePlan { get; set; } = string.Empty; + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Diagnostics/DiagnosticsPathsApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/Diagnostics/DiagnosticsPathsApiModels.cs new file mode 100644 index 00000000..15c56206 --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Models/Diagnostics/DiagnosticsPathsApiModels.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; + +namespace AutopilotMonitor.Shared.Models +{ + // Declaration order == wire order. + + /// + /// Response of GET diagnostics/paths: what every diagnostics package collects + /// before a tenant's own entries — the built-in section catalog (compiled into the agent) + /// and the platform-wide global paths set by Global Admins. Member-readable by design. + /// + public class DiagnosticsPathsResponse : IApiResponse + { + public IReadOnlyList BuiltIn { get; set; } = default!; + public IReadOnlyList GlobalPaths { get; set; } = default!; + } + + /// + /// Wire projection of one built-in diagnostics section. Condition travels as the + /// enum NAME ("Always" | "RealmJoinWatcher" | "DevicePreparation"), never the integer — + /// the web switches on the string. + /// + public class DiagnosticsBuiltInSectionWire + { + public string Id { get; set; } = string.Empty; + public string ZipFolder { get; set; } = string.Empty; + /// UNEXPANDED source folder (may contain %ProgramData% or the user-profile token). + public string SourceFolder { get; set; } = string.Empty; + public IReadOnlyList Patterns { get; set; } = default!; + public bool IncludeSubfolders { get; set; } + public string Description { get; set; } = string.Empty; + public string Condition { get; set; } = string.Empty; + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Models/FeedbackApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/FeedbackApiModels.cs new file mode 100644 index 00000000..be33c610 --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Models/FeedbackApiModels.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; + +namespace AutopilotMonitor.Shared.Models +{ + // Declaration order == wire order. + + /// Response of GET feedback/eligibility: whether the caller should be shown the feedback prompt. + public class FeedbackEligibilityResponse : IApiResponse + { + public bool Eligible { get; set; } + } + + /// Response of GET feedback/all (Global Admin dashboard): every stored feedback entry. + public class FeedbackListResponse : IApiResponse + { + public IReadOnlyList Feedback { get; set; } = default!; + } + + /// One feedback interaction as rendered on the Global Admin dashboard. + public class FeedbackEntryWire + { + public string? Type { get; set; } + public string? Upn { get; set; } + public string? TenantId { get; set; } + public string? DisplayName { get; set; } + /// Absent on dismissals. + public int? Rating { get; set; } + /// Absent on dismissals. + public string? Comment { get; set; } + public bool Dismissed { get; set; } + public bool Submitted { get; set; } + /// ISO-8601 round-trip string; absent when never stamped. + public string? InteractedAt { get; set; } + public string? HistoryRowKey { get; set; } + public string? DomainName { get; set; } + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Models/FleetHealthModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/FleetHealthModels.cs index 02be53ac..120e135b 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/FleetHealthModels.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/FleetHealthModels.cs @@ -10,7 +10,7 @@ namespace AutopilotMonitor.Shared.Models /// Presentation-only derivations (bar-chart maxima, weekday/month axis labels) stay on the /// client; this payload carries the data, not the formatting. /// - public sealed class FleetHealthMetrics + public sealed class FleetHealthMetrics : IApiResponse { public bool Success { get; set; } = true; public int Days { get; set; } diff --git a/src/Shared/AutopilotMonitor.Shared/Models/GeographicApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/GeographicApiModels.cs index 4acfcaec..3ba15fb5 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/GeographicApiModels.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/GeographicApiModels.cs @@ -6,7 +6,7 @@ namespace AutopilotMonitor.Shared.Models /// /// Response containing geographic performance metrics aggregated by location. /// - public class GeographicMetricsResponse + public class GeographicMetricsResponse : IApiResponse { public bool Success { get; set; } public List Locations { get; set; } = new(); diff --git a/src/Shared/AutopilotMonitor.Shared/Models/ImePatternHealthModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/ImePatternHealthModels.cs index 4fc93cc4..c7997226 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/ImePatternHealthModels.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/ImePatternHealthModels.cs @@ -32,7 +32,7 @@ namespace AutopilotMonitor.Shared.Models } /// Response of GET metrics/ime-pattern-health. - public class ImePatternHealthResponse + public class ImePatternHealthResponse : IApiResponse { public string? BaselineVersion { get; set; } public int MinBaselineSessions { get; set; } diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Metrics/AgentPerformanceMetrics.cs b/src/Shared/AutopilotMonitor.Shared/Models/Metrics/AgentPerformanceMetrics.cs new file mode 100644 index 00000000..60b9ebaf --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Models/Metrics/AgentPerformanceMetrics.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Generic; + +namespace AutopilotMonitor.Shared.Models +{ + // Agent performance / efficiency wire DTOs, moved verbatim from the Functions-local + // service files (PlatformMetricsService.cs / AgentEfficiencyMetricsService.cs) so the + // manifest exports them: property names and DECLARATION ORDER are the wire contract + // and must not change in a move-only refactor. + + /// Response of GET global/metrics/platform (GetGlobalPlatformMetrics). + public class PlatformAgentMetricsResponse : IApiResponse + { + public List Sessions { get; set; } = new(); + public DeliveryLatencyMetrics? DeliveryLatency { get; set; } + public CrashRateMetrics? CrashRate { get; set; } + public DateTime ComputedAt { get; set; } + public int ComputeDurationMs { get; set; } + public bool FromCache { get; set; } + public int WindowDays { get; set; } + public int SessionLimit { get; set; } + /// + /// Sessions the scan actually covered in the window (before the has-snapshots filter + /// that shapes ). Callers must compare THIS against + /// to decide whether the window was truncated — + /// Sessions.Count understates truncation on fleets where many sessions + /// emit no agent_metrics_snapshot. + /// + public int SessionsScanned { get; set; } + } + + public class SessionAgentMetric + { + public string SessionId { get; set; } = string.Empty; + public string TenantId { get; set; } = string.Empty; + public string? DeviceName { get; set; } + public string? Manufacturer { get; set; } + public string? Model { get; set; } + public string? StartedAt { get; set; } + public string? Status { get; set; } + public string? AgentVersion { get; set; } + public int SnapshotCount { get; set; } + public double TotalBytesUp { get; set; } + public double TotalBytesDown { get; set; } + public double TotalRequests { get; set; } + public double AvgCpu { get; set; } + public double MaxCpu { get; set; } + public double AvgWorkingSet { get; set; } + public double MaxWorkingSet { get; set; } + public double AvgPrivateBytes { get; set; } + public double AvgLatency { get; set; } + public double AvgSpoolDepth { get; set; } + public double MaxSpoolDepth { get; set; } + public double PeakSpoolDepth { get; set; } + public double MaxSpoolFileBytes { get; set; } + public double TotalEventsEmitted { get; set; } + public bool SpoolPressureDetected { get; set; } + } + + public class DeliveryLatencyMetrics + { + public double P50Ms { get; set; } + public double P95Ms { get; set; } + public double P99Ms { get; set; } + public double AvgMs { get; set; } + public int SampleCount { get; set; } + public double ClockSkewPercent { get; set; } + } + + public class CrashRateMetrics + { + public int TotalStarts { get; set; } + public int CleanExits { get; set; } + public int ExceptionCrashes { get; set; } + public int HardKills { get; set; } + public int RebootKills { get; set; } + public int FirstRuns { get; set; } + public double CrashRatePercent { get; set; } + public List TopExceptions { get; set; } = new(); + } + + public class CrashExceptionSummary + { + public string ExceptionType { get; set; } = string.Empty; + public int Count { get; set; } + } + + /// Response of GET global/metrics/agent-efficiency (GetGlobalAgentEfficiency). + public class AgentEfficiencyMetricsResponse : IApiResponse + { + public int WindowDays { get; set; } + public int SessionLimit { get; set; } + /// Echo of the requested tenant filter; null = cross-tenant aggregate. + public string? TenantId { get; set; } + /// Sessions the scan covered — compare against for truncation. + public int SessionsScanned { get; set; } + public int SessionsWithSnapshots { get; set; } + public List ByVersion { get; set; } = new(); + public AgentVersionEfficiency? Overall { get; set; } + public DateTime ComputedAt { get; set; } + public int ComputeDurationMs { get; set; } + public bool FromCache { get; set; } + } + + public class AgentVersionEfficiency + { + /// Null on the cross-version "overall" bucket (omitted on the wire). + public string? AgentVersion { get; set; } + public int SessionsScanned { get; set; } + public int SessionsWithSnapshots { get; set; } + public int SpoolPressureSessions { get; set; } + public PercentileStats? AvgCpuPercent { get; set; } + public PercentileStats? MaxCpuPercent { get; set; } + public PercentileStats? MaxWorkingSetMb { get; set; } + public PercentileStats? MaxPrivateBytesMb { get; set; } + public PercentileStats? MaxThreadCount { get; set; } + public PercentileStats? MaxHandleCount { get; set; } + public PercentileStats? MaxSpoolDepth { get; set; } + public PercentileStats? MaxSpoolFileBytes { get; set; } + public PercentileStats? ApiLatencyMs { get; set; } + public PercentileStats? ApiRequestCount { get; set; } + public CrashRateMetrics? CrashRate { get; set; } + public List? TopOffenders { get; set; } + } + + /// Distribution of a per-session statistic across a version bucket. + public class PercentileStats + { + public double P50 { get; set; } + public double P95 { get; set; } + public double Max { get; set; } + public double Avg { get; set; } + public int SampleCount { get; set; } + } + + public class EfficiencyOffender + { + public string SessionId { get; set; } = string.Empty; + public string TenantId { get; set; } = string.Empty; + public string? DeviceName { get; set; } + public string Dimension { get; set; } = string.Empty; + public double Value { get; set; } + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Metrics/AppMetricsApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/Metrics/AppMetricsApiModels.cs new file mode 100644 index 00000000..15ae3132 --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Models/Metrics/AppMetricsApiModels.cs @@ -0,0 +1,68 @@ +using System.Collections.Generic; + +namespace AutopilotMonitor.Shared.Models +{ + // Declaration order == wire order. + + /// + /// Response of GET metrics/app and GET global/metrics/app: per-app install + /// health over the requested window (slowest apps by average FINAL-attempt duration, top + /// failing apps) plus the fleet Delivery Optimization rollup. + /// + public class AppMetricsResponse : IApiResponse + { + public bool Success { get; set; } + public int TotalApps { get; set; } + public int TotalInstalls { get; set; } + public int TotalSkipped { get; set; } + public int TotalUnmeasured { get; set; } + /// Rows excluded from per-app groups because their name-keyed row merged distinct appIds. + public int TotalCollisionExcluded { get; set; } + public IReadOnlyList SlowestApps { get; set; } = default!; + public IReadOnlyList TopFailingApps { get; set; } = default!; + public AppMetricsDeliveryOptimization DeliveryOptimization { get; set; } = default!; + } + + /// One app's aggregate across its install rows in the window. + public class AppMetricsAppGroup + { + public string AppName { get; set; } = string.Empty; + public int TotalInstalls { get; set; } + public int Succeeded { get; set; } + public int Skipped { get; set; } + public int Unmeasured { get; set; } + public int Failed { get; set; } + /// Failed / (failed + succeeded) as a percentage; skips never count as attempts. + public double FailureRate { get; set; } + /// Average measured FINAL-attempt duration (whole seconds); 0 with no measured installs. + public double AvgDurationSeconds { get; set; } + public int MaxDurationSeconds { get; set; } + public int MeasuredInstalls { get; set; } + public long AvgDownloadBytes { get; set; } + public long DoTotalBytesDownloaded { get; set; } + public long DoBytesFromPeers { get; set; } + public long DoBytesFromCacheServer { get; set; } + public long DoBytesFromHttp { get; set; } + public double PeerOffloadPercent { get; set; } + /// Top 3 failure codes by count, descending. + public IReadOnlyList TopFailureCodes { get; set; } = default!; + } + + /// One failure code with its occurrence count. + public class AppFailureCodeCount + { + public string Code { get; set; } = string.Empty; + public int Count { get; set; } + } + + /// Fleet-wide Delivery Optimization rollup across every install row in the window. + public class AppMetricsDeliveryOptimization + { + public long TotalBytesDownloaded { get; set; } + public long FromPeers { get; set; } + public long FromCacheServer { get; set; } + public long FromHttp { get; set; } + /// Share of bytes not pulled from the CDN (peers + Microsoft Connected Cache), 0-100 one decimal. + public double PeerOffloadPercent { get; set; } + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Metrics/AppsDashboardApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/Metrics/AppsDashboardApiModels.cs new file mode 100644 index 00000000..2873304f --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Models/Metrics/AppsDashboardApiModels.cs @@ -0,0 +1,178 @@ +using System; +using System.Collections.Generic; + +namespace AutopilotMonitor.Shared.Models +{ + // Declaration order == wire order. App Dashboard endpoints (apps/list, apps/{app}/analytics, + // apps/{app}/sessions and their global variants) — lifted from the Functions-local + // anonymous builders in AppsAnalyticsHelper so the manifest exports them. + + /// + /// Response of GET apps/list and GET global/apps/list. Legacy mode (no + /// pageSize) returns the full array with the paging keys absent; opt-in pagination adds + /// count/offset/pageSize and a nextLink while more pages remain. + /// + public class AppsListResponse : IApiResponse + { + public bool Success { get; set; } + public int TotalApps { get; set; } + public int TotalInstalls { get; set; } + /// Rows excluded because their name-keyed row merged distinct appIds. + public int CollisionExcluded { get; set; } + public int WindowDays { get; set; } + /// Rows on this page; absent in legacy full-array mode. + public int? Count { get; set; } + /// Absent in legacy full-array mode. + public int? Offset { get; set; } + /// Absent in legacy full-array mode. + public int? PageSize { get; set; } + public IReadOnlyList Apps { get; set; } = default!; + /// Next-page link; absent in legacy mode and on the last page. + public string? NextLink { get; set; } + } + + /// One app row of the apps list (failed desc, then failure rate, then name). + public class AppsListItem + { + public string AppName { get; set; } = string.Empty; + public string AppType { get; set; } = string.Empty; + public int TotalInstalls { get; set; } + public int Succeeded { get; set; } + public int Skipped { get; set; } + public int Unmeasured { get; set; } + public int Failed { get; set; } + public double FailureRate { get; set; } + public double AvgDurationSeconds { get; set; } + public int MaxDurationSeconds { get; set; } + public long AvgDownloadBytes { get; set; } + /// "improving" | "worsening" | "stable". + public string Trend { get; set; } = string.Empty; + /// Failure-rate delta between window halves; absent when either half has under 5 finished installs. + public double? TrendDelta { get; set; } + public DateTime LastSeenAt { get; set; } + } + + /// Response of GET apps/{appName}/analytics and its global variant. + public class AppAnalyticsResponse : IApiResponse + { + public bool Success { get; set; } + public string AppName { get; set; } = string.Empty; + public string AppType { get; set; } = string.Empty; + public int WindowDays { get; set; } + public int CollisionExcluded { get; set; } + /// "day" (windows up to 30 days) or "week". + public string Bucket { get; set; } = string.Empty; + public AppAnalyticsSummary Summary { get; set; } = default!; + public IReadOnlyList TimeSeries { get; set; } = default!; + public IReadOnlyList VersionBreakdown { get; set; } = default!; + public IReadOnlyList InstallerPhaseBreakdown { get; set; } = default!; + public IReadOnlyList TopFailureCodes { get; set; } = default!; + /// Succeeded installs whose detection re-check reported NotDetected. + public int DetectionLiesCount { get; set; } + public IReadOnlyList DeviceModelBreakdown { get; set; } = default!; + /// Active duration-regression episodes for this app (tracker rows). + public IReadOnlyList VersionRegressions { get; set; } = default!; + } + + /// Headline aggregate of one app's analytics window. + public class AppAnalyticsSummary + { + public int TotalInstalls { get; set; } + public int Succeeded { get; set; } + public int Skipped { get; set; } + public int Unmeasured { get; set; } + public int Failed { get; set; } + public double FailureRate { get; set; } + public double AvgDurationSeconds { get; set; } + public int P95DurationSeconds { get; set; } + public long AvgDownloadBytes { get; set; } + /// "improving" | "worsening" | "stable". + public string Trend { get; set; } = string.Empty; + /// Absent when either window half has under 5 finished installs. + public double? TrendDelta { get; set; } + /// Share of installs with AttemptNumber > 1 (0-1, three decimals). + public double FlakinessScore { get; set; } + } + + /// One day/week bucket of the analytics time series. + public class AppAnalyticsTimeBucket + { + public DateTime BucketStart { get; set; } + public int Installs { get; set; } + public int Succeeded { get; set; } + public int Failed { get; set; } + public double FailureRate { get; set; } + public double AvgDurationSeconds { get; set; } + } + + /// Per-app-version aggregate (installs descending). + public class AppVersionBreakdownItem + { + public string AppVersion { get; set; } = string.Empty; + public int Installs { get; set; } + public int Failed { get; set; } + public double FailureRate { get; set; } + public int MeasuredInstalls { get; set; } + public int MedianDurationSeconds { get; set; } + public int P95DurationSeconds { get; set; } + } + + /// Failed installs per installer phase (descending). + public class AppInstallerPhaseCount + { + public string Phase { get; set; } = string.Empty; + public int Failed { get; set; } + } + + /// One of the top 5 failure codes of the analytics window. + public class AppAnalyticsFailureCode + { + public string Code { get; set; } = string.Empty; + /// First observed exit code; absent when none of the rows carried one. + public int? ExitCode { get; set; } + public int Count { get; set; } + public string SampleMessage { get; set; } = string.Empty; + } + + /// Per device-model failure aggregate (lift vs the app's baseline rate, descending). + public class AppDeviceModelBreakdownItem + { + public string Manufacturer { get; set; } = string.Empty; + public string Model { get; set; } = string.Empty; + public int Installs { get; set; } + public int Failed { get; set; } + public double FailureRate { get; set; } + public double LiftVsBaseline { get; set; } + } + + /// Response of GET apps/{appName}/sessions and its global variant (offset-paged). + public class AppSessionsResponse : IApiResponse + { + public bool Success { get; set; } + public int Total { get; set; } + public int Offset { get; set; } + public int Limit { get; set; } + public IReadOnlyList Items { get; set; } = default!; + } + + /// One install row of the app-sessions drilldown (failed first, then in-progress, newest first). + public class AppSessionItem + { + public string SessionId { get; set; } = string.Empty; + public string TenantId { get; set; } = string.Empty; + public string DeviceName { get; set; } = string.Empty; + public string Manufacturer { get; set; } = string.Empty; + public string Model { get; set; } = string.Empty; + public string AppVersion { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public string InstallerPhase { get; set; } = string.Empty; + public string FailureCode { get; set; } = string.Empty; + /// Absent when the install carried no exit code. + public int? ExitCode { get; set; } + public int AttemptNumber { get; set; } + public DateTime StartedAt { get; set; } + public int DurationSeconds { get; set; } + /// 2+ = the IME processed this app in multiple passes (device-ESP evaluation + real install). + public int InstallPassCount { get; set; } + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Metrics/InsightsMetricsApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/Metrics/InsightsMetricsApiModels.cs new file mode 100644 index 00000000..ddd4d2a6 --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Models/Metrics/InsightsMetricsApiModels.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; + +namespace AutopilotMonitor.Shared.Models +{ + // Declaration order == wire order. Envelopes of the F1/F2 insights fleet endpoints, + // lifted from the Functions-local anonymous builders so the manifest exports them. + + /// + /// Response of GET metrics/time-attribution and GET global/metrics/time-attribution: + /// the rolling 30-day range statistics per enrollment class plus the daily rows for the + /// per-day trend. The range window is FIXED at the sweep's 30 days. + /// + public class TimeAttributionMetricsResponse : IApiResponse + { + public bool Success { get; set; } + public int WindowDays { get; set; } + /// Range statistics per enrollment class (never mixed), class-name ordinal order. + public IReadOnlyList Classes { get; set; } = default!; + /// Daily rows of the window, date ordinal order. + public IReadOnlyList Daily { get; set; } = default!; + } + + /// + /// Response of GET metrics/device-journeys and GET global/metrics/device-journeys: + /// daily First-Time-Right rows of the window plus their sums, the merged attempt histogram, + /// and the repeat-devices violator list (absent on the cross-tenant aggregate). + /// + public class DeviceJourneyMetricsResponse : IApiResponse + { + public bool Success { get; set; } + public int WindowDays { get; set; } + public DeviceJourneyWindowTotals Totals { get; set; } = default!; + /// Daily rows of the window, date ordinal order. + public IReadOnlyList Daily { get; set; } = default!; + /// Devices whose current journey took at least 2 attempts; absent on the cross-tenant aggregate (no per-device drill there). + public IReadOnlyList? RepeatDevices { get; set; } + } + + /// Window totals of the device-journey response (additive sums over the daily rows). + public class DeviceJourneyWindowTotals + { + public int CompletedJourneys { get; set; } + public int FirstTimeRight { get; set; } + + /// Null with zero completed journeys — no rate claim, never 0 (truthfulness rule 1). + public double? FtrRatePct { get; set; } + + public int ExcludedSessions { get; set; } + public List AttemptHistogram { get; set; } = new(); + } + + /// One repeat-device violator row (current journey took at least 2 attempts, newest terminal session in the window). + public class DeviceJourneyRepeatDevice + { + public string SerialNumber { get; set; } = string.Empty; + public string Manufacturer { get; set; } = string.Empty; + public string Model { get; set; } = string.Empty; + public int Attempts { get; set; } + public int JourneyCount { get; set; } + public string LastStatus { get; set; } = string.Empty; + public string LastSessionId { get; set; } = string.Empty; + public DateTime LastStartedAt { get; set; } + /// Failure reason of the newest failed attempt; empty when unavailable (fail-soft point-read). + public string LastFailureReason { get; set; } = string.Empty; + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Metrics/RuleStatsApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/Metrics/RuleStatsApiModels.cs new file mode 100644 index 00000000..dedff74b --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Models/Metrics/RuleStatsApiModels.cs @@ -0,0 +1,67 @@ +using System.Collections.Generic; + +namespace AutopilotMonitor.Shared.Models +{ + // Declaration order == wire order (STJ serializes in declaration order; the MCP hands + // raw response JSON to an LLM, so key order is part of the contract). + + /// + /// Response of GET metrics/rule-stats and GET global/metrics/rule-stats: + /// per-rule firing aggregates over the requested window, the active rule-frequency + /// regression episodes (tenant scope only — empty on the cross-tenant aggregate), and + /// window totals. + /// + public class RuleStatsResponse : IApiResponse + { + public IReadOnlyList Rules { get; set; } = default!; + public IReadOnlyList Regressions { get; set; } = default!; + public RuleStatsSummary Summary { get; set; } = default!; + } + + /// One rule's aggregate across all dates in the window (fires descending). + public class RuleStatsRuleAggregate + { + public string RuleId { get; set; } = string.Empty; + public string RuleType { get; set; } = string.Empty; + public string RuleTitle { get; set; } = string.Empty; + public string Category { get; set; } = string.Empty; + public string Severity { get; set; } = string.Empty; + public int FireCount { get; set; } + public int EvaluationCount { get; set; } + public int SessionsEvaluated { get; set; } + /// Fires per evaluation as a percentage (one decimal); 0 with no evaluations. + public double HitRate { get; set; } + /// Average confidence score across fires (one decimal); 0 with no fires. + public double AvgConfidenceScore { get; set; } + /// Per-day trend rows, oldest first (one row per stored date). + public IReadOnlyList Trend { get; set; } = default!; + } + + /// One day of a rule's trend ("yyyy-MM-dd"). + public class RuleTrendPoint + { + public string Date { get; set; } = string.Empty; + public int FireCount { get; set; } + public int EvaluationCount { get; set; } + } + + /// Window totals of a rule-stats response. + public class RuleStatsSummary + { + public int TotalEvaluations { get; set; } + public int TotalFires { get; set; } + public double OverallHitRate { get; set; } + /// Rule id with the most fires; absent when the window holds no rules. + public string? TopRuleByFireCount { get; set; } + /// Distinct rule count — set on the global route only (the key is absent on the tenant route, preserving its historical shape). + public int? UniqueRules { get; set; } + public RuleStatsPeriod Period { get; set; } = default!; + } + + /// Echo of the effective date window ("yyyy-MM-dd"). + public class RuleStatsPeriod + { + public string Start { get; set; } = string.Empty; + public string End { get; set; } = string.Empty; + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Metrics/SlaMetrics.cs b/src/Shared/AutopilotMonitor.Shared/Models/Metrics/SlaMetrics.cs index b76f51c7..6e053a68 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Metrics/SlaMetrics.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Metrics/SlaMetrics.cs @@ -6,7 +6,7 @@ namespace AutopilotMonitor.Shared.Models.Metrics /// /// SLA metrics response for a given tenant and time window. /// - public class SlaMetricsResponse + public class SlaMetricsResponse : IApiResponse { // Target echo (so the dashboard knows the configured targets) public decimal? TargetSuccessRate { get; set; } diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Metrics/UsageMetrics.cs b/src/Shared/AutopilotMonitor.Shared/Models/Metrics/UsageMetrics.cs index 92502a9c..81f9486b 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Metrics/UsageMetrics.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Metrics/UsageMetrics.cs @@ -7,7 +7,7 @@ namespace AutopilotMonitor.Shared.Models /// /// Platform usage metrics response /// - public class PlatformUsageMetrics + public class PlatformUsageMetrics : IApiResponse { /// /// Session metrics diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Metrics/VerdictCalibrationApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/Metrics/VerdictCalibrationApiModels.cs new file mode 100644 index 00000000..c1d200ca --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Models/Metrics/VerdictCalibrationApiModels.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; + +namespace AutopilotMonitor.Shared.Models +{ + // Declaration order == wire order. Moved verbatim from the Functions-local + // VerdictCalibrationResponse builder (GetVerdictCalibrationFunction.cs) so the shared + // manifest exports the calibration matrix as wire types. + + /// + /// Response of GET global/metrics/verdict-calibration: per verdict path, how many + /// sessions it produced in the window, its share, overrides attributed to the prior path, + /// the 7-day re-enrollment proxy, a 7d-vs-28d trend, and the active drift alerts. + /// Operator-only classifier diagnostics. + /// + public class VerdictCalibrationResponse : IApiResponse + { + public bool Success { get; set; } + /// Partition echo: a tenant GUID, or "global" for the cross-tenant aggregate. + public string TenantId { get; set; } = string.Empty; + public int WindowDays { get; set; } + /// Inclusive window start ("yyyy-MM-dd"). + public string WindowStart { get; set; } = string.Empty; + /// Inclusive window end ("yyyy-MM-dd", today). + public string WindowEnd { get; set; } = string.Empty; + /// Newest aggregate compute time in the window; absent when the window holds no rows. + public DateTime? ComputedAt { get; set; } + /// Distinct aggregation algorithm versions contributing to the window, ascending. + public int[] Versions { get; set; } = default!; + public VerdictCalibrationTotals Totals { get; set; } = default!; + public VerdictCalibrationTrendMeta Trend { get; set; } = default!; + /// Rows ordered by count descending, then path/status ordinal. + public IReadOnlyList Paths { get; set; } = default!; + /// Active drift episodes, newest first. + public IReadOnlyList Alerts { get; set; } = default!; + } + + /// Window totals of the calibration matrix. + public class VerdictCalibrationTotals + { + public int Sessions { get; set; } + public int Terminal { get; set; } + public int Derived { get; set; } + /// Aggregate days that contributed to the window. + public int Days { get; set; } + } + + /// Trend denominators shared by every row (single source, never per-row copies). + public class VerdictCalibrationTrendMeta + { + public int WindowDays { get; set; } + public int BaselineDays { get; set; } + public int WindowSessions { get; set; } + public int BaselineSessions { get; set; } + } + + /// One verdict path × status row of the calibration matrix. + public class VerdictCalibrationPathRow + { + public string VerdictPath { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public int Count { get; set; } + public double SharePct { get; set; } + public int DerivedCount { get; set; } + public int Eligible7d { get; set; } + public int ReEnrolled7d { get; set; } + /// Null below the minimum eligible count — never a rate on a handful of sessions. + public double? ReEnrollRatePct { get; set; } + public int OverriddenByAdmin { get; set; } + public int OverriddenByLateCompletion { get; set; } + public int OverriddenOther { get; set; } + public VerdictCalibrationTrendWindow Window7 { get; set; } = new(); + public VerdictCalibrationTrendWindow Baseline28 { get; set; } = new(); + /// Window share ÷ baseline share; null when the baseline share is 0 (a new path has no finite lift — never invented). + public double? Lift { get; set; } + } + + /// One trend window of a path row. + public class VerdictCalibrationTrendWindow + { + public int Count { get; set; } + public int Sessions { get; set; } + public double SharePct { get; set; } + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Models/OffboardApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/OffboardApiModels.cs new file mode 100644 index 00000000..288ce3d6 --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Models/OffboardApiModels.cs @@ -0,0 +1,25 @@ +using System; + +namespace AutopilotMonitor.Shared.Models +{ + // Declaration order == wire order. + + /// + /// 202/200 response body for the offboarding endpoint. Fields point the caller at the + /// History row so subsequent reporting / status polling can resolve back to the audit + /// trail. drives the "data deletion starts in mm ss" + /// countdown in the Web UI's drain-barrier state. + /// + public class OffboardResponse : IApiResponse + { + public string TenantId { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public string HistoryPartitionKey { get; set; } = string.Empty; + public string HistoryRowKey { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; + + /// UTC timestamp before which the worker MUST NOT start Phase 2. Drives the + /// cache-drain-barrier countdown UI. Absent on the idempotent-Completed/Failed branches. + public DateTime? EarliestProcessingAt { get; set; } + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Models/SessionApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/SessionApiModels.cs index bff9634e..21dbd3b8 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/SessionApiModels.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/SessionApiModels.cs @@ -671,6 +671,20 @@ namespace AutopilotMonitor.Shared.Models public string? MsiSourceUrl { get; set; } } + /// + /// Redacted IME version row served to non-global callers of GET metrics/ime-versions: + /// the platform-wide rollout facts without the first-seen tenant/session identifiers or + /// the archive columns (see GetImeVersionHistoryFunction.BuildResponsePayload). + /// + [WireContract] + public class ImeVersionHistoryLeanEntry + { + public string Version { get; set; } = default!; + public DateTime FirstSeenAt { get; set; } + public DateTime LastSeenAt { get; set; } + public int SessionCount { get; set; } + } + /// /// Result of recording one IME version sighting (RecordImeVersionAsync): whether /// the version is new to the fleet, plus the archive columns the ingest path needs to diff --git a/src/Shared/AutopilotMonitor.Shared/Models/SessionRestoreApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/SessionRestoreApiModels.cs new file mode 100644 index 00000000..d06bc148 --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Models/SessionRestoreApiModels.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; + +namespace AutopilotMonitor.Shared.Models +{ + // Declaration order == wire order. + + /// + /// Body of POST global/sessions/{id}/restore for every outcome (success AND reject — + /// the outcome/status mapping decides the HTTP code, the shape stays one). + /// + public class SessionRestoreResponse : IApiResponse + { + public bool Success { get; set; } + /// SessionRestoreOutcome name (e.g. "Restored", "DryRunOk", "RejectManifestNotFound"). + public string Outcome { get; set; } = string.Empty; + /// "full" | "partial" | "dryRun"; absent on early rejects. + public string? Mode { get; set; } + /// Operator-readable reason; absent on clean successes. + public string? Message { get; set; } + /// Reject diagnostics; absent otherwise. + public string? CurrentState { get; set; } + /// Reject diagnostics; absent otherwise. + public string? PendingManifestId { get; set; } + public Dictionary RowsRestoredByTable { get; set; } = default!; + public Dictionary RowsSkippedByTable { get; set; } = default!; + public Dictionary WouldRestoreByTable { get; set; } = default!; + public int InventoryReIncrements { get; set; } + public long DurationMs { get; set; } + } + + /// + /// Success body (202 Accepted) of the V2 cascade-delete enqueue — the non-success arms + /// stay anonymous error bodies by design (one shape: success=false + message). + /// + public class SessionDeletionQueuedResponse : IApiResponse + { + public bool Success { get; set; } + /// Always "queued". + public string Status { get; set; } = string.Empty; + public string? ManifestId { get; set; } + public string Message { get; set; } = string.Empty; + } +} diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.json b/src/Web/autopilot-monitor-web/utils/shared-manifests.json index 46bf2ed7..435c129f 100644 --- a/src/Web/autopilot-monitor-web/utils/shared-manifests.json +++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.json @@ -1021,6 +1021,223 @@ } ] }, + "AgentEfficiencyMetricsResponse": { + "kind": "object", + "doc": "Response of GET global/metrics/agent-efficiency (GetGlobalAgentEfficiency).", + "fields": [ + { + "name": "windowDays", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "sessionLimit", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "tenantId", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true, + "doc": "Echo of the requested tenant filter; null = cross-tenant aggregate." + }, + { + "name": "sessionsScanned", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Sessions the scan covered — compare against SessionLimit for truncation." + }, + { + "name": "sessionsWithSnapshots", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "byVersion", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "AgentVersionEfficiency" + } + } + }, + { + "name": "overall", + "type": { + "kind": "ref", + "name": "AgentVersionEfficiency" + }, + "optional": true + }, + { + "name": "computedAt", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "computeDurationMs", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "fromCache", + "type": { + "kind": "primitive", + "name": "boolean" + } + } + ] + }, + "AgentVersionEfficiency": { + "kind": "object", + "fields": [ + { + "name": "agentVersion", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true, + "doc": "Null on the cross-version \"overall\" bucket (omitted on the wire)." + }, + { + "name": "sessionsScanned", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "sessionsWithSnapshots", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "spoolPressureSessions", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "avgCpuPercent", + "type": { + "kind": "ref", + "name": "PercentileStats" + }, + "optional": true + }, + { + "name": "maxCpuPercent", + "type": { + "kind": "ref", + "name": "PercentileStats" + }, + "optional": true + }, + { + "name": "maxWorkingSetMb", + "type": { + "kind": "ref", + "name": "PercentileStats" + }, + "optional": true + }, + { + "name": "maxPrivateBytesMb", + "type": { + "kind": "ref", + "name": "PercentileStats" + }, + "optional": true + }, + { + "name": "maxThreadCount", + "type": { + "kind": "ref", + "name": "PercentileStats" + }, + "optional": true + }, + { + "name": "maxHandleCount", + "type": { + "kind": "ref", + "name": "PercentileStats" + }, + "optional": true + }, + { + "name": "maxSpoolDepth", + "type": { + "kind": "ref", + "name": "PercentileStats" + }, + "optional": true + }, + { + "name": "maxSpoolFileBytes", + "type": { + "kind": "ref", + "name": "PercentileStats" + }, + "optional": true + }, + { + "name": "apiLatencyMs", + "type": { + "kind": "ref", + "name": "PercentileStats" + }, + "optional": true + }, + { + "name": "apiRequestCount", + "type": { + "kind": "ref", + "name": "PercentileStats" + }, + "optional": true + }, + { + "name": "crashRate", + "type": { + "kind": "ref", + "name": "CrashRateMetrics" + }, + "optional": true + }, + { + "name": "topOffenders", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "EfficiencyOffender" + } + }, + "optional": true + } + ] + }, "AnalyzeRule": { "kind": "object", "doc": "Defines how to analyze collected events to detect issues Analyze rules run server-side during event ingestion", @@ -1331,6 +1548,377 @@ } ] }, + "AppAnalyticsFailureCode": { + "kind": "object", + "doc": "One of the top 5 failure codes of the analytics window.", + "fields": [ + { + "name": "code", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "exitCode", + "type": { + "kind": "primitive", + "name": "number" + }, + "optional": true, + "doc": "First observed exit code; absent when none of the rows carried one." + }, + { + "name": "count", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "sampleMessage", + "type": { + "kind": "primitive", + "name": "string" + } + } + ] + }, + "AppAnalyticsResponse": { + "kind": "object", + "doc": "Response of GET apps/{appName}/analytics and its global variant.", + "fields": [ + { + "name": "success", + "type": { + "kind": "primitive", + "name": "boolean" + } + }, + { + "name": "appName", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "appType", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "windowDays", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "collisionExcluded", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "bucket", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "\"day\" (windows up to 30 days) or \"week\"." + }, + { + "name": "summary", + "type": { + "kind": "ref", + "name": "AppAnalyticsSummary" + } + }, + { + "name": "timeSeries", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "AppAnalyticsTimeBucket" + } + } + }, + { + "name": "versionBreakdown", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "AppVersionBreakdownItem" + } + } + }, + { + "name": "installerPhaseBreakdown", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "AppInstallerPhaseCount" + } + } + }, + { + "name": "topFailureCodes", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "AppAnalyticsFailureCode" + } + } + }, + { + "name": "detectionLiesCount", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Succeeded installs whose detection re-check reported NotDetected." + }, + { + "name": "deviceModelBreakdown", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "AppDeviceModelBreakdownItem" + } + } + }, + { + "name": "versionRegressions", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "AppVersionRegressionAlert" + } + }, + "doc": "Active duration-regression episodes for this app (tracker rows)." + } + ] + }, + "AppAnalyticsSummary": { + "kind": "object", + "doc": "Headline aggregate of one app's analytics window.", + "fields": [ + { + "name": "totalInstalls", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "succeeded", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "skipped", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "unmeasured", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "failed", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "failureRate", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "avgDurationSeconds", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "p95DurationSeconds", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "avgDownloadBytes", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "trend", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "\"improving\" | \"worsening\" | \"stable\"." + }, + { + "name": "trendDelta", + "type": { + "kind": "primitive", + "name": "number" + }, + "optional": true, + "doc": "Absent when either window half has under 5 finished installs." + }, + { + "name": "flakinessScore", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Share of installs with AttemptNumber > 1 (0-1, three decimals)." + } + ] + }, + "AppAnalyticsTimeBucket": { + "kind": "object", + "doc": "One day/week bucket of the analytics time series.", + "fields": [ + { + "name": "bucketStart", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "installs", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "succeeded", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "failed", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "failureRate", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "avgDurationSeconds", + "type": { + "kind": "primitive", + "name": "number" + } + } + ] + }, + "AppDeviceModelBreakdownItem": { + "kind": "object", + "doc": "Per device-model failure aggregate (lift vs the app's baseline rate, descending).", + "fields": [ + { + "name": "manufacturer", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "model", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "installs", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "failed", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "failureRate", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "liftVsBaseline", + "type": { + "kind": "primitive", + "name": "number" + } + } + ] + }, + "AppFailureCodeCount": { + "kind": "object", + "doc": "One failure code with its occurrence count.", + "fields": [ + { + "name": "code", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "count", + "type": { + "kind": "primitive", + "name": "number" + } + } + ] + }, "AppHomingProbeWire": { "kind": "object", "doc": "Consent-probe verdict embedded in app-homing responses (success and deny alike). Built by AppHomingFunction.ProbePayload.", @@ -1359,6 +1947,864 @@ } ] }, + "AppInstallSlaSnapshot": { + "kind": "object", + "doc": "App install SLA snapshot.", + "fields": [ + { + "name": "totalInstalls", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "succeeded", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "failed", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "successRate", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "targetMet", + "type": { + "kind": "primitive", + "name": "boolean" + } + }, + { + "name": "topFailingApps", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "TopFailingApp" + } + }, + "doc": "Top failing apps by failure count." + } + ] + }, + "AppInstallerPhaseCount": { + "kind": "object", + "doc": "Failed installs per installer phase (descending).", + "fields": [ + { + "name": "phase", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "failed", + "type": { + "kind": "primitive", + "name": "number" + } + } + ] + }, + "AppMetricsAppGroup": { + "kind": "object", + "doc": "One app's aggregate across its install rows in the window.", + "fields": [ + { + "name": "appName", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "totalInstalls", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "succeeded", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "skipped", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "unmeasured", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "failed", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "failureRate", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Failed / (failed + succeeded) as a percentage; skips never count as attempts." + }, + { + "name": "avgDurationSeconds", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Average measured FINAL-attempt duration (whole seconds); 0 with no measured installs." + }, + { + "name": "maxDurationSeconds", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "measuredInstalls", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "avgDownloadBytes", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "doTotalBytesDownloaded", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "doBytesFromPeers", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "doBytesFromCacheServer", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "doBytesFromHttp", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "peerOffloadPercent", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "topFailureCodes", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "AppFailureCodeCount" + } + }, + "doc": "Top 3 failure codes by count, descending." + } + ] + }, + "AppMetricsDeliveryOptimization": { + "kind": "object", + "doc": "Fleet-wide Delivery Optimization rollup across every install row in the window.", + "fields": [ + { + "name": "totalBytesDownloaded", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "fromPeers", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "fromCacheServer", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "fromHttp", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "peerOffloadPercent", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Share of bytes not pulled from the CDN (peers + Microsoft Connected Cache), 0-100 one decimal." + } + ] + }, + "AppMetricsResponse": { + "kind": "object", + "doc": "Response of GET metrics/app and GET global/metrics/app: per-app install health over the requested window (slowest apps by average FINAL-attempt duration, top failing apps) plus the fleet Delivery Optimization rollup.", + "fields": [ + { + "name": "success", + "type": { + "kind": "primitive", + "name": "boolean" + } + }, + { + "name": "totalApps", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "totalInstalls", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "totalSkipped", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "totalUnmeasured", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "totalCollisionExcluded", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Rows excluded from per-app groups because their name-keyed row merged distinct appIds." + }, + { + "name": "slowestApps", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "AppMetricsAppGroup" + } + } + }, + { + "name": "topFailingApps", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "AppMetricsAppGroup" + } + } + }, + { + "name": "deliveryOptimization", + "type": { + "kind": "ref", + "name": "AppMetricsDeliveryOptimization" + } + } + ] + }, + "AppScriptMetrics": { + "kind": "object", + "fields": [ + { + "name": "avgAppsPerSession", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "totalUniqueApps", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "avgPlatformScriptsPerSession", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "avgRemediationScriptsPerSession", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "totalPlatformScripts", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "totalRemediationScripts", + "type": { + "kind": "primitive", + "name": "number" + } + } + ] + }, + "AppSessionItem": { + "kind": "object", + "doc": "One install row of the app-sessions drilldown (failed first, then in-progress, newest first).", + "fields": [ + { + "name": "sessionId", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "tenantId", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "deviceName", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "manufacturer", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "model", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "appVersion", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "status", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "installerPhase", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "failureCode", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "exitCode", + "type": { + "kind": "primitive", + "name": "number" + }, + "optional": true, + "doc": "Absent when the install carried no exit code." + }, + { + "name": "attemptNumber", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "startedAt", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "durationSeconds", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "installPassCount", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "2+ = the IME processed this app in multiple passes (device-ESP evaluation + real install)." + } + ] + }, + "AppSessionsResponse": { + "kind": "object", + "doc": "Response of GET apps/{appName}/sessions and its global variant (offset-paged).", + "fields": [ + { + "name": "success", + "type": { + "kind": "primitive", + "name": "boolean" + } + }, + { + "name": "total", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "offset", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "limit", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "items", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "AppSessionItem" + } + } + } + ] + }, + "AppVersionBreakdownItem": { + "kind": "object", + "doc": "Per-app-version aggregate (installs descending).", + "fields": [ + { + "name": "appVersion", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "installs", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "failed", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "failureRate", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "measuredInstalls", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "medianDurationSeconds", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "p95DurationSeconds", + "type": { + "kind": "primitive", + "name": "number" + } + } + ] + }, + "AppVersionRegressionAlert": { + "kind": "object", + "doc": "One ACTIVE app-version duration regression: an app whose newest version's median install duration rose ≥2× (and ≥5 minutes absolute) over the previous version's median, both sides with enough measured installs. Persisted as the appversionregression|{app}|{version} keyspace of the notification tracker table — the row IS the dedup (one bell per episode), and the versionRegressions[] payload on the app-analytics response. Deleted when the episode re-arms (median falls back under 1.5× or the version drains out of the horizon) or by the tracker's 30-day retention sweep. Numbers refresh on every radar pass while the episode stays active; FirstNotifiedAt never moves.", + "fields": [ + { + "name": "tenantId", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "appName", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "currentVersion", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "The regressed (newer) version — episode key together with the app." + }, + { + "name": "previousVersion", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "The comparison version: latest first-seen strictly before the current version's first-seen." + }, + { + "name": "currentMedianSeconds", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "previousMedianSeconds", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "currentMeasuredCount", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Measured installs backing each median (gate: both ≥10)." + }, + { + "name": "previousMeasuredCount", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "lift", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Current median ÷ previous median (gate: ≥2.0; previous median is never 0 — measured durations are ≥1s)." + }, + { + "name": "firstNotifiedAt", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "When the episode first fired (bell + ops event moment). Never moves on refresh; drives the 30d retention re-arm." + }, + { + "name": "lastEvaluatedAt", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "Last radar pass that re-confirmed/refreshed this episode." + } + ] + }, + "AppsListItem": { + "kind": "object", + "doc": "One app row of the apps list (failed desc, then failure rate, then name).", + "fields": [ + { + "name": "appName", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "appType", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "totalInstalls", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "succeeded", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "skipped", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "unmeasured", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "failed", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "failureRate", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "avgDurationSeconds", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "maxDurationSeconds", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "avgDownloadBytes", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "trend", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "\"improving\" | \"worsening\" | \"stable\"." + }, + { + "name": "trendDelta", + "type": { + "kind": "primitive", + "name": "number" + }, + "optional": true, + "doc": "Failure-rate delta between window halves; absent when either half has under 5 finished installs." + }, + { + "name": "lastSeenAt", + "type": { + "kind": "primitive", + "name": "string" + } + } + ] + }, + "AppsListResponse": { + "kind": "object", + "doc": "Response of GET apps/list and GET global/apps/list. Legacy mode (no pageSize) returns the full array with the paging keys absent; opt-in pagination adds count/offset/pageSize and a nextLink while more pages remain.", + "fields": [ + { + "name": "success", + "type": { + "kind": "primitive", + "name": "boolean" + } + }, + { + "name": "totalApps", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "totalInstalls", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "collisionExcluded", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Rows excluded because their name-keyed row merged distinct appIds." + }, + { + "name": "windowDays", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "count", + "type": { + "kind": "primitive", + "name": "number" + }, + "optional": true, + "doc": "Rows on this page; absent in legacy full-array mode." + }, + { + "name": "offset", + "type": { + "kind": "primitive", + "name": "number" + }, + "optional": true, + "doc": "Absent in legacy full-array mode." + }, + { + "name": "pageSize", + "type": { + "kind": "primitive", + "name": "number" + }, + "optional": true, + "doc": "Absent in legacy full-array mode." + }, + { + "name": "apps", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "AppsListItem" + } + } + }, + { + "name": "nextLink", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true, + "doc": "Next-page link; absent in legacy mode and on the last page." + } + ] + }, "AuditLogEntry": { "kind": "object", "fields": [ @@ -2035,6 +3481,89 @@ } ] }, + "CrashExceptionSummary": { + "kind": "object", + "fields": [ + { + "name": "exceptionType", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "count", + "type": { + "kind": "primitive", + "name": "number" + } + } + ] + }, + "CrashRateMetrics": { + "kind": "object", + "fields": [ + { + "name": "totalStarts", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "cleanExits", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "exceptionCrashes", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "hardKills", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "rebootKills", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "firstRuns", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "crashRatePercent", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "topExceptions", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "CrashExceptionSummary" + } + } + } + ] + }, "CreateAnalyzeRuleFromTemplateResponse": { "kind": "object", "doc": "Response of POST rules/analyze/{ruleId}/create-from-template: the newly created custom rule instantiated from the template.", @@ -2636,6 +4165,86 @@ } ] }, + "DeliveryLatencyMetrics": { + "kind": "object", + "fields": [ + { + "name": "p50Ms", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "p95Ms", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "p99Ms", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "avgMs", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "sampleCount", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "clockSkewPercent", + "type": { + "kind": "primitive", + "name": "number" + } + } + ] + }, + "DeploymentTypeMetrics": { + "kind": "object", + "fields": [ + { + "name": "userDriven", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "whiteGlove", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "userDrivenPercentage", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "whiteGlovePercentage", + "type": { + "kind": "primitive", + "name": "number" + } + } + ] + }, "DetailedHealthCheckResponse": { "kind": "object", "doc": "Response of GET health/detailed: the full system health report (always 200; per-check status is in the body). Items are HealthCheck objects (backend-project type, serialized by runtime type); non-GA callers get a filtered list.", @@ -2779,6 +4388,264 @@ } ] }, + "DeviceJourneyAttemptBucket": { + "kind": "object", + "doc": "One attempt-histogram bucket: how many completed journeys took exactly Attempts attempts.", + "fields": [ + { + "name": "attempts", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "journeyCount", + "type": { + "kind": "primitive", + "name": "number" + } + } + ] + }, + "DeviceJourneyDailyAggregate": { + "kind": "object", + "doc": "Daily First-Time-Right rollup for one (tenant, date) — F2 PR4 (insights spec §F2). A journey counts on the StartedAt date of its COMPLETING success session (same StartedAt-date bucketing as every other daily aggregate; the rolling sweep re-buckets late-terminating sessions idempotently). Only journeys that ended with a terminal success are counted — open journeys (no success yet, incl. WhiteGlove waiting for its user session) and gap-abandoned journeys never enter numerator or denominator. Counts are additive across days, so a window rate is the sum of daily rows — no rolling-window row is needed (unlike the median-based time-attribution aggregates). Rows are written even below the ≥20-completed-journeys UI gate: the UI needs the n to say \"insufficient data (n=3)\" (truthfulness rule 4). Junk-serial exclusions are disclosed per day (rule 7). \"global\" mirror rows sum all tenants.", + "fields": [ + { + "name": "date", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "Calendar day (UTC, \"yyyy-MM-dd\") the completing success sessions STARTED." + }, + { + "name": "tenantId", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "Tenant GUID, or \"global\" for the cross-tenant row." + }, + { + "name": "journeyVersion", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Journey-grouping algorithm version the counts were computed with (rule 8)." + }, + { + "name": "completedJourneyCount", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Journeys completed (first terminal success) on this date — the FTR denominator." + }, + { + "name": "firstTimeRightCount", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Completed journeys with attempt count == 1 — the FTR numerator." + }, + { + "name": "attemptHistogram", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "DeviceJourneyAttemptBucket" + } + }, + "doc": "Attempt distribution across the completed journeys, ordered by attempts ascending." + }, + { + "name": "excludedSessionCount", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Terminal sessions on this date excluded for junk/placeholder serials — disclosed, never silent (rule 7)." + }, + { + "name": "computedAt", + "type": { + "kind": "primitive", + "name": "string" + } + } + ] + }, + "DeviceJourneyMetricsResponse": { + "kind": "object", + "doc": "Response of GET metrics/device-journeys and GET global/metrics/device-journeys: daily First-Time-Right rows of the window plus their sums, the merged attempt histogram, and the repeat-devices violator list (absent on the cross-tenant aggregate).", + "fields": [ + { + "name": "success", + "type": { + "kind": "primitive", + "name": "boolean" + } + }, + { + "name": "windowDays", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "totals", + "type": { + "kind": "ref", + "name": "DeviceJourneyWindowTotals" + } + }, + { + "name": "daily", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "DeviceJourneyDailyAggregate" + } + }, + "doc": "Daily rows of the window, date ordinal order." + }, + { + "name": "repeatDevices", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "DeviceJourneyRepeatDevice" + } + }, + "optional": true, + "doc": "Devices whose current journey took at least 2 attempts; absent on the cross-tenant aggregate (no per-device drill there)." + } + ] + }, + "DeviceJourneyRepeatDevice": { + "kind": "object", + "doc": "One repeat-device violator row (current journey took at least 2 attempts, newest terminal session in the window).", + "fields": [ + { + "name": "serialNumber", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "manufacturer", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "model", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "attempts", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "journeyCount", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "lastStatus", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "lastSessionId", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "lastStartedAt", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "lastFailureReason", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "Failure reason of the newest failed attempt; empty when unavailable (fail-soft point-read)." + } + ] + }, + "DeviceJourneyWindowTotals": { + "kind": "object", + "doc": "Window totals of the device-journey response (additive sums over the daily rows).", + "fields": [ + { + "name": "completedJourneys", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "firstTimeRight", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "ftrRatePct", + "type": { + "kind": "primitive", + "name": "number" + }, + "optional": true, + "doc": "Null with zero completed journeys — no rate claim, never 0 (truthfulness rule 1)." + }, + { + "name": "excludedSessions", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "attemptHistogram", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "DeviceJourneyAttemptBucket" + } + } + } + ] + }, "DeviceNotRegisteredItem": { "kind": "object", "doc": "One serial-number bucket in the GetDeviceNotRegistered aggregation. All values are self-reported by devices through the unauthenticated distress channel — UNVERIFIED.", @@ -2942,6 +4809,65 @@ } ] }, + "DiagnosticsBuiltInSectionWire": { + "kind": "object", + "doc": "Wire projection of one built-in diagnostics section. Condition travels as the enum NAME (\"Always\" | \"RealmJoinWatcher\" | \"DevicePreparation\"), never the integer — the web switches on the string.", + "fields": [ + { + "name": "id", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "zipFolder", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "sourceFolder", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "UNEXPANDED source folder (may contain %ProgramData% or the user-profile token)." + }, + { + "name": "patterns", + "type": { + "kind": "array", + "element": { + "kind": "primitive", + "name": "string" + } + } + }, + { + "name": "includeSubfolders", + "type": { + "kind": "primitive", + "name": "boolean" + } + }, + { + "name": "description", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "condition", + "type": { + "kind": "primitive", + "name": "string" + } + } + ] + }, "DiagnosticsDownloadTicketResponse": { "kind": "object", "doc": "Response of POST diagnostics/download-ticket: a short-lived, self-authenticating download URL for one diagnostics blob (HMAC ticket in the query string).", @@ -2994,6 +4920,70 @@ } ] }, + "DiagnosticsLogPath": { + "kind": "object", + "doc": "Represents a log file path (or wildcard pattern) to include in the diagnostics ZIP package. Global (built-in) entries are defined by Global Admins; tenants may add their own.", + "fields": [ + { + "name": "path", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "File system path or wildcard pattern. Environment variables are expanded by the agent. Wildcards are only allowed in the last path segment (e.g. \"C:\\Windows\\Panther\\*.log\")." + }, + { + "name": "description", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "Human-readable description shown in the portal." + }, + { + "name": "isBuiltIn", + "type": { + "kind": "primitive", + "name": "boolean" + }, + "doc": "True when defined globally by a Global Admin — displayed as read-only for tenants. False when added by the tenant itself." + }, + { + "name": "includeSubfolders", + "type": { + "kind": "primitive", + "name": "boolean" + }, + "doc": "When true, the agent also collects matching files from subdirectories recursively. Subdirectory structure is preserved in the ZIP (e.g. AdditionalLogs/Logs/subfolder/file.log). Default is false (top-level only)." + } + ] + }, + "DiagnosticsPathsResponse": { + "kind": "object", + "doc": "Response of GET diagnostics/paths: what every diagnostics package collects before a tenant's own entries — the built-in section catalog (compiled into the agent) and the platform-wide global paths set by Global Admins. Member-readable by design.", + "fields": [ + { + "name": "builtIn", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "DiagnosticsBuiltInSectionWire" + } + } + }, + { + "name": "globalPaths", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "DiagnosticsLogPath" + } + } + } + ] + }, "DismissAllNotificationsResponse": { "kind": "object", "doc": "Shared response of POST global/notifications/dismiss-all and POST notifications/dismiss-all: how many notifications were dismissed.", @@ -3217,6 +5207,47 @@ } ] }, + "EfficiencyOffender": { + "kind": "object", + "fields": [ + { + "name": "sessionId", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "tenantId", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "deviceName", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true + }, + { + "name": "dimension", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "value", + "type": { + "kind": "primitive", + "name": "number" + } + } + ] + }, "EmailTemplateResetResponse": { "kind": "object", "doc": "Response of DELETE global/email-templates/{kind}: reset to the built-in template.", @@ -3607,6 +5638,433 @@ } ] }, + "FeedbackEligibilityResponse": { + "kind": "object", + "doc": "Response of GET feedback/eligibility: whether the caller should be shown the feedback prompt.", + "fields": [ + { + "name": "eligible", + "type": { + "kind": "primitive", + "name": "boolean" + } + } + ] + }, + "FeedbackEntryWire": { + "kind": "object", + "doc": "One feedback interaction as rendered on the Global Admin dashboard.", + "fields": [ + { + "name": "type", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true + }, + { + "name": "upn", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true + }, + { + "name": "tenantId", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true + }, + { + "name": "displayName", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true + }, + { + "name": "rating", + "type": { + "kind": "primitive", + "name": "number" + }, + "optional": true, + "doc": "Absent on dismissals." + }, + { + "name": "comment", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true, + "doc": "Absent on dismissals." + }, + { + "name": "dismissed", + "type": { + "kind": "primitive", + "name": "boolean" + } + }, + { + "name": "submitted", + "type": { + "kind": "primitive", + "name": "boolean" + } + }, + { + "name": "interactedAt", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true, + "doc": "ISO-8601 round-trip string; absent when never stamped." + }, + { + "name": "historyRowKey", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true + }, + { + "name": "domainName", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true + } + ] + }, + "FeedbackListResponse": { + "kind": "object", + "doc": "Response of GET feedback/all (Global Admin dashboard): every stored feedback entry.", + "fields": [ + { + "name": "feedback", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "FeedbackEntryWire" + } + } + } + ] + }, + "FleetDailyPoint": { + "kind": "object", + "fields": [ + { + "name": "date", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "success", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "failed", + "type": { + "kind": "primitive", + "name": "number" + } + } + ] + }, + "FleetFailingModel": { + "kind": "object", + "fields": [ + { + "name": "model", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "failed", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "total", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "All sessions on this model in the window, including in-flight ones." + }, + { + "name": "failureRate", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Failed / (Failed + Succeeded) * 100, rounded — finished enrollments only." + } + ] + }, + "FleetFailureReason": { + "kind": "object", + "fields": [ + { + "name": "reason", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "count", + "type": { + "kind": "primitive", + "name": "number" + } + } + ] + }, + "FleetHealthMetrics": { + "kind": "object", + "doc": "Server-computed Fleet Health aggregates. Replaces the client-side pass that drained up to 200k raw sessions into the browser and aggregated on the main thread. Built once per request from the windowed session list — see MetricsMath.BuildFleetHealthPayload. Presentation-only derivations (bar-chart maxima, weekday/month axis labels) stay on the client; this payload carries the data, not the formatting.", + "fields": [ + { + "name": "success", + "type": { + "kind": "primitive", + "name": "boolean" + } + }, + { + "name": "days", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "stats", + "type": { + "kind": "ref", + "name": "FleetHealthStats" + } + }, + { + "name": "dailyData", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "FleetDailyPoint" + } + }, + "doc": "One entry per day in the window, oldest-first. Date is UTC \"yyyy-MM-dd\"." + }, + { + "name": "failureReasons", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "FleetFailureReason" + } + }, + "doc": "Top failure reasons by count (descending)." + }, + { + "name": "modelHealth", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "FleetModelHealth" + } + }, + "doc": "Device models by enrollment volume (descending)." + }, + { + "name": "slowestModels", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "FleetSlowModel" + } + }, + "doc": "Device models by average successful enrollment duration (descending)." + }, + { + "name": "topFailingModels", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "FleetFailingModel" + } + }, + "doc": "Device models by failure count (descending)." + }, + { + "name": "computedAt", + "type": { + "kind": "primitive", + "name": "string" + } + } + ] + }, + "FleetHealthStats": { + "kind": "object", + "fields": [ + { + "name": "total", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "succeeded", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "failed", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "inProgress", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "incomplete", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Terminal, non-failure sessions (timeout reclassification). Surfaced as its own count; not a failure." + }, + { + "name": "successRate", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Succeeded / (Succeeded + Failed) * 100 (one decimal) — finished enrollments only, matching the SLA convention. 0 when nothing has finished yet (clients render \"—\")." + }, + { + "name": "avgDurationMinutes", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Average duration in minutes over non-in-progress sessions that carry a duration. Kept for API compatibility; the cards lead with the median, which a handful of multi-hour outliers (overnight ESP, WhiteGlove late user phase) cannot drag around." + }, + { + "name": "medianDurationMinutes", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Median duration in minutes over the same population as AvgDurationMinutes." + }, + { + "name": "p90DurationMinutes", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "90th-percentile duration in minutes over the same population — the tail signal the median alone would hide." + } + ] + }, + "FleetModelHealth": { + "kind": "object", + "fields": [ + { + "name": "model", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "total", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "All sessions on this model in the window, including in-flight ones." + }, + { + "name": "succeeded", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "failed", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Clients derive the per-model rate as Succeeded / (Succeeded + Failed)." + } + ] + }, + "FleetSlowModel": { + "kind": "object", + "fields": [ + { + "name": "model", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "avgMinutes", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "count", + "type": { + "kind": "primitive", + "name": "number" + } + } + ] + }, "GatherRule": { "kind": "object", "doc": "Defines what data the agent should collect Gather rules are delivered to the agent via the config API and can be managed (enabled/disabled, created) through the portal", @@ -3910,6 +6368,65 @@ } ] }, + "GeographicMetricsResponse": { + "kind": "object", + "doc": "Response containing geographic performance metrics aggregated by location.", + "fields": [ + { + "name": "success", + "type": { + "kind": "primitive", + "name": "boolean" + } + }, + { + "name": "locations", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "LocationMetrics" + } + } + }, + { + "name": "globalAverages", + "type": { + "kind": "ref", + "name": "GlobalAverages" + } + }, + { + "name": "computedAt", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "totalSessions", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "locationsWithData", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "geoLocationEnabled", + "type": { + "kind": "primitive", + "name": "boolean" + }, + "doc": "Whether geo-location collection is enabled for this tenant" + } + ] + }, "GetActiveUsersResponse": { "kind": "object", "doc": "Response of GET global/presence: users active within the requested window.", @@ -5360,6 +7877,87 @@ } ] }, + "GlobalAverages": { + "kind": "object", + "doc": "Global average benchmarks for geographic comparison.", + "fields": [ + { + "name": "avgDurationMinutes", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "medianDurationMinutes", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "avgMinutesPerApp", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "avgThroughputBytesPerSec", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "stdDevDurationMinutes", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "avgApiLatencyMs", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Global request-weighted average agent→backend API latency (ms); 0 = no data yet" + }, + { + "name": "medianApiLatencyMs", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Global median of per-session average API latency (ms), robust against outliers; 0 = no data yet" + }, + { + "name": "avgDoPercentPeerCaching", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Global weighted average peer caching percentage" + }, + { + "name": "totalDoBytesFromPeers", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Total peer bytes across all locations with DO data" + }, + { + "name": "totalDoBytesFromHttp", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Total HTTP bytes across all locations with DO data" + } + ] + }, "GraphFeatureStatusItem": { "kind": "object", "doc": "One feature row of the graph-permissions status matrix: the feature identifier, the granted verdict (null while the snapshot is transient) and the Graph application permissions the feature requires.", @@ -5392,6 +7990,57 @@ } ] }, + "HardwareCount": { + "kind": "object", + "fields": [ + { + "name": "name", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "count", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "percentage", + "type": { + "kind": "primitive", + "name": "number" + } + } + ] + }, + "HardwareMetrics": { + "kind": "object", + "fields": [ + { + "name": "topManufacturers", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "HardwareCount" + } + } + }, + { + "name": "topModels", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "HardwareCount" + } + } + } + ] + }, "HardwareRejectedItem": { "kind": "object", "doc": "One manufacturer+model bucket in the GetHardwareRejected aggregation. All values are self-reported by devices through the unauthenticated distress channel — UNVERIFIED.", @@ -5697,6 +8346,280 @@ } ] }, + "ImePatternDriftAlert": { + "kind": "object", + "fields": [ + { + "name": "version", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "patternId", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "baselineVersion", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "baselineRate", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "sessions", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "flaggedAt", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true + } + ] + }, + "ImePatternHealthCell": { + "kind": "object", + "fields": [ + { + "name": "version", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "patternId", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "sessions", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "sessionsWithHit", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "hits", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "rate", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "driftFlaggedAt", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true + } + ] + }, + "ImePatternHealthPattern": { + "kind": "object", + "fields": [ + { + "name": "patternId", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "category", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true + }, + { + "name": "enabled", + "type": { + "kind": "primitive", + "name": "boolean" + } + }, + { + "name": "baselineRate", + "type": { + "kind": "primitive", + "name": "number" + }, + "optional": true, + "doc": "Hit rate on the baseline version; null without a baseline." + }, + { + "name": "expected", + "type": { + "kind": "primitive", + "name": "boolean" + }, + "doc": "True when the baseline rate is at or above the expected threshold." + } + ] + }, + "ImePatternHealthResponse": { + "kind": "object", + "doc": "Response of GET metrics/ime-pattern-health.", + "fields": [ + { + "name": "baselineVersion", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true + }, + { + "name": "minBaselineSessions", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "expectedHitRate", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "minCandidateSessions", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "versions", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "ImePatternHealthVersion" + } + } + }, + { + "name": "patterns", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "ImePatternHealthPattern" + } + } + }, + { + "name": "cells", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "ImePatternHealthCell" + } + } + }, + { + "name": "alerts", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "ImePatternDriftAlert" + } + } + }, + { + "name": "generatedAt", + "type": { + "kind": "primitive", + "name": "string" + } + } + ] + }, + "ImePatternHealthVersion": { + "kind": "object", + "fields": [ + { + "name": "version", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "sessions", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Sessions that delivered a histogram on this version (not the ImeVersionHistory session count)." + }, + { + "name": "firstSeenAt", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true + }, + { + "name": "lastSeenAt", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true + }, + { + "name": "fleetSessions", + "type": { + "kind": "primitive", + "name": "number" + }, + "optional": true, + "doc": "Total sessions per ImeVersionHistory (includes sessions without a terminal run)." + } + ] + }, "ImeVersionHistoryEntry": { "kind": "object", "doc": "A tracked IME version sighting. Permanent archive that survives data retention.", @@ -5808,6 +8731,40 @@ } ] }, + "ImeVersionHistoryLeanEntry": { + "kind": "object", + "doc": "Redacted IME version row served to non-global callers of GET metrics/ime-versions: the platform-wide rollout facts without the first-seen tenant/session identifiers or the archive columns (see GetImeVersionHistoryFunction.BuildResponsePayload).", + "fields": [ + { + "name": "version", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "firstSeenAt", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "lastSeenAt", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "sessionCount", + "type": { + "kind": "primitive", + "name": "number" + } + } + ] + }, "IsGlobalAdminResponse": { "kind": "object", "doc": "Response of GET auth/is-global-admin: whether the caller holds the Global Admin platform role, echoing the caller's UPN.", @@ -5830,6 +8787,22 @@ } ] }, + "ListBackupsResponse": { + "kind": "object", + "doc": "Response of GET global/backups: every backupId in the critical-table-backups container, newest first. Serialized with SerializerOptions (the backup surface's own options), not the ApiJsonOptions pipeline.", + "fields": [ + { + "name": "backupIds", + "type": { + "kind": "array", + "element": { + "kind": "primitive", + "name": "string" + } + } + } + ] + }, "ListRawTablesResponse": { "kind": "object", "doc": "Success body of GET /api/global/raw/tables (ListRawTables): the queryable table names.", @@ -5876,6 +8849,271 @@ } ] }, + "LocationMetrics": { + "kind": "object", + "doc": "Performance metrics for a single geographic location.", + "fields": [ + { + "name": "locationKey", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "country", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "region", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "city", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "loc", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "sessionCount", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "succeeded", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "failed", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "successRate", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "avgDurationMinutes", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "medianDurationMinutes", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "p95DurationMinutes", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "avgAppCount", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Average number of apps installed per session at this location" + }, + { + "name": "minutesPerApp", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Average minutes per app (AvgDurationMinutes / AvgAppCount)" + }, + { + "name": "appLoadScore", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Normalized score: 100 = global median, lower is better" + }, + { + "name": "avgThroughputBytesPerSec", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Average download throughput in bytes/sec at this location" + }, + { + "name": "totalDownloadBytes", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "durationVsGlobalPct", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Percentage difference from global avg duration (negative = faster)" + }, + { + "name": "throughputVsGlobalPct", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Percentage difference from global avg throughput (positive = faster)" + }, + { + "name": "avgApiLatencyMs", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "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). A single corrupt session average (e.g. a request spanning a sleep/hibernate) can dominate this figure — use MedianApiLatencyMs as the display statistic." + }, + { + "name": "medianApiLatencyMs", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Median of the per-session average agent→backend round-trips (ms) at this location — robust against outlier sessions. 0 when no session here carries latency data." + }, + { + "name": "apiLatencySessionCount", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Sessions at this location that carry API-latency data" + }, + { + "name": "apiLatencyVsGlobalPct", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Percentage difference from global median API latency (positive = slower/farther)" + }, + { + "name": "isOutlier", + "type": { + "kind": "primitive", + "name": "boolean" + } + }, + { + "name": "outlierDirection", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true, + "doc": "\"fast\", \"slow\", or null" + }, + { + "name": "doSessionCount", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Sessions at this location that have DO telemetry data" + }, + { + "name": "avgDoPercentPeerCaching", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Weighted percentage of bytes from peers (0-100), computed from total peer/total DO bytes" + }, + { + "name": "totalDoBytesFromPeers", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Total bytes downloaded from all peer sources" + }, + { + "name": "totalDoBytesFromHttp", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Total bytes downloaded from HTTP/CDN" + }, + { + "name": "totalDoBytesFromLanPeers", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Bytes from LAN peers" + }, + { + "name": "totalDoBytesFromGroupPeers", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Bytes from group peers" + }, + { + "name": "totalDoBytesFromInternetPeers", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Bytes from internet peers" + }, + { + "name": "totalDoBytesFromLinkLocalPeers", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Bytes from link-local peers (same subnet)" + }, + { + "name": "totalDoBytesFromCacheServer", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Bytes served from a Microsoft Connected Cache (MCC) — separate from BytesFromPeers" + } + ] + }, "LocationSessionLeanRow": { "kind": "object", "doc": "Lean projection of LocationSessionRow used as the default drilldown row. Fields chosen to support the typical MCP triage flow (which session, when, where, how big a deal, was DO active). Built by GetGeographicLocationSessionsFunction.ToLeanRow.", @@ -7045,6 +10283,56 @@ } ] }, + "OffboardResponse": { + "kind": "object", + "doc": "202/200 response body for the offboarding endpoint. Fields point the caller at the History row so subsequent reporting / status polling can resolve back to the audit trail. EarliestProcessingAt drives the \"data deletion starts in mm ss\" countdown in the Web UI's drain-barrier state.", + "fields": [ + { + "name": "tenantId", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "status", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "historyPartitionKey", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "historyRowKey", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "message", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "earliestProcessingAt", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true, + "doc": "UTC timestamp before which the worker MUST NOT start Phase 2. Drives the cache-drain-barrier countdown UI. Absent on the idempotent-Completed/Failed branches." + } + ] + }, "OpsEventEntry": { "kind": "object", "fields": [ @@ -7155,6 +10443,96 @@ } ] }, + "PercentileStats": { + "kind": "object", + "doc": "Distribution of a per-session statistic across a version bucket.", + "fields": [ + { + "name": "p50", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "p95", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "max", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "avg", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "sampleCount", + "type": { + "kind": "primitive", + "name": "number" + } + } + ] + }, + "PerformanceMetrics": { + "kind": "object", + "fields": [ + { + "name": "avgDurationMinutes", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "medianDurationMinutes", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "p95DurationMinutes", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "p99DurationMinutes", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "sampleCount", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Number of sessions contributing to the duration distribution (after the >0 filter)." + }, + { + "name": "clampedSessionCount", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Number of sessions whose raw duration exceeded the clamp ceiling and were capped before aggregation. A non-zero value flags stuck/non-terminal sessions skewing the window — the percentiles above are computed on the clamped values, not the runaway wall-clock duration." + } + ] + }, "PlanTierDefinition": { "kind": "object", "doc": "Defines a usage plan tier with request limits. Stored as JSON array in AdminConfiguration.PlanTierDefinitionsJson.", @@ -7205,6 +10583,270 @@ } ] }, + "PlatformAgentMetricsResponse": { + "kind": "object", + "doc": "Response of GET global/metrics/platform (GetGlobalPlatformMetrics).", + "fields": [ + { + "name": "sessions", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "SessionAgentMetric" + } + } + }, + { + "name": "deliveryLatency", + "type": { + "kind": "ref", + "name": "DeliveryLatencyMetrics" + }, + "optional": true + }, + { + "name": "crashRate", + "type": { + "kind": "ref", + "name": "CrashRateMetrics" + }, + "optional": true + }, + { + "name": "computedAt", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "computeDurationMs", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "fromCache", + "type": { + "kind": "primitive", + "name": "boolean" + } + }, + { + "name": "windowDays", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "sessionLimit", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "sessionsScanned", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Sessions the scan actually covered in the window (before the has-snapshots filter that shapes Sessions). Callers must compare THIS against SessionLimit to decide whether the window was truncated — Sessions.Count understates truncation on fleets where many sessions emit no agent_metrics_snapshot." + } + ] + }, + "PlatformStats": { + "kind": "object", + "doc": "Pre-computed platform-wide statistics for the public landing page. Stored as a single row (PartitionKey: \"global\", RowKey: \"current\"). Incremented during registration/ingest/login; the daily maintenance recompute treats every cumulative counter as a monotonic \"since release\" high-water-mark (raise-only — the scanned tables are retention-pruned, so a raw recompute would regress the figures). Only TotalSignedUpTenants is current-state (its source table is not retention-pruned). See MaintenanceService.BuildMonotonicPlatformStats.", + "fields": [ + { + "name": "totalEnrollments", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Total enrollment sessions monitored since launch" + }, + { + "name": "totalUsers", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Total unique users who logged in" + }, + { + "name": "totalTenants", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Total unique tenants using the platform (have at least one session)" + }, + { + "name": "totalSignedUpTenants", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Total tenants signed up (have a tenant configuration entry)" + }, + { + "name": "uniqueDeviceModels", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Total unique device models seen (manufacturer + model)" + }, + { + "name": "totalEventsProcessed", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Total events processed across all tenants" + }, + { + "name": "successfulEnrollments", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Total successful enrollments" + }, + { + "name": "issuesDetected", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Total analysis issues detected" + }, + { + "name": "lastFullCompute", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "When these stats were last fully recomputed" + }, + { + "name": "lastUpdated", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "When these stats were last updated (including incremental)" + } + ] + }, + "PlatformUsageMetrics": { + "kind": "object", + "doc": "Platform usage metrics response", + "fields": [ + { + "name": "sessions", + "type": { + "kind": "ref", + "name": "SessionMetrics" + }, + "doc": "Session metrics" + }, + { + "name": "tenants", + "type": { + "kind": "ref", + "name": "TenantMetrics" + }, + "doc": "Tenant metrics" + }, + { + "name": "users", + "type": { + "kind": "ref", + "name": "UserMetrics" + }, + "doc": "User metrics (requires Entra ID authentication)" + }, + { + "name": "performance", + "type": { + "kind": "ref", + "name": "PerformanceMetrics" + }, + "doc": "Performance metrics" + }, + { + "name": "hardware", + "type": { + "kind": "ref", + "name": "HardwareMetrics" + }, + "doc": "Hardware metrics" + }, + { + "name": "deploymentTypes", + "type": { + "kind": "ref", + "name": "DeploymentTypeMetrics" + }, + "doc": "Deployment type metrics (User Driven vs White Glove)" + }, + { + "name": "appScripts", + "type": { + "kind": "ref", + "name": "AppScriptMetrics" + }, + "doc": "App and script count metrics" + }, + { + "name": "platformStats", + "type": { + "kind": "ref", + "name": "PlatformStats" + }, + "optional": true, + "doc": "Platform statistics (cumulative since release)" + }, + { + "name": "computedAt", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "When these metrics were computed" + }, + { + "name": "computeDurationMs", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "How long it took to compute (milliseconds)" + }, + { + "name": "fromCache", + "type": { + "kind": "primitive", + "name": "boolean" + }, + "doc": "Whether result is from cache" + }, + { + "name": "windowDays", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Time window (in days) the metrics were computed over." + } + ] + }, "ProgressGetSessionEventsResponse": { "kind": "object", "doc": "Response of GET /api/progress/sessions/{sessionId}/events — the session's event stream after the serial knowledge proof passed.", @@ -8114,6 +11756,175 @@ } ] }, + "RuleRegressionAlert": { + "kind": "object", + "doc": "One ACTIVE rule-frequency regression (F3, insights spec §F3): an analyze rule whose 7-day hit rate rose ≥2× over its 28-day baseline with disjoint Wilson intervals. Persisted as the ruleregression|{ruleId} keyspace of the notification tracker table — the row IS the dedup (one bell per episode), the badge state (rules page) and the regressions[] payload (rule-stats response). Deleted when the rate re-arms (falls under 1.5× baseline or stops firing) or by the tracker's 30-day retention sweep (spec: retention cleanup re-arms). Numbers are refreshed on every radar pass while the episode stays active; FirstNotifiedAt never moves.", + "fields": [ + { + "name": "tenantId", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "ruleId", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "ruleTitle", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "windowFireCount", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "windowSessionCount", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "baselineFireCount", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "baselineSessionCount", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "windowRatePct", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "baselineRatePct", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "lift", + "type": { + "kind": "primitive", + "name": "number" + }, + "optional": true, + "doc": "Window rate ÷ baseline rate. Null when the baseline rate is 0 (a NEW signal has no finite lift — never invented)." + }, + { + "name": "windowStartDate", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "Trailing 7-day window (\"yyyy-MM-dd\", inclusive) the current numbers describe." + }, + { + "name": "windowEndDate", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "dimension", + "type": { + "kind": "ref", + "name": "RuleRegressionDimension" + }, + "optional": true, + "doc": "Dimension concentration captured when the alert FIRST fired (stable story); null = no clear concentration." + }, + { + "name": "firstNotifiedAt", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "When the episode first fired (bell + ops event moment). Never moves on refresh; drives the 30d retention re-arm." + }, + { + "name": "lastEvaluatedAt", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "Last radar pass that re-confirmed/refreshed this episode." + } + ] + }, + "RuleRegressionDimension": { + "kind": "object", + "doc": "The dominant dimension value among a regression's hit sessions — CORRELATION only, and every consumer's wording must say so (\"correlated — not necessarily causal\", insights spec §F3 / truthfulness rule 6). Null on an alert means \"no clear dimension concentration\" — the radar never stretches for one.", + "fields": [ + { + "name": "dimension", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "\"osBuild\", \"model\", \"agentVersion\" or \"imeVersion\"." + }, + { + "name": "value", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "hitCount", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Hit sessions carrying this value (gate: ≥5)." + }, + { + "name": "hitSharePct", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "allSharePct", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "lift", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "HitShare ÷ AllShare (gate: ≥2.0)." + } + ] + }, "RuleResult": { "kind": "object", "doc": "Result of an analyze rule evaluation against a session's events Stored in the RuleResults table and displayed in the session detail UI", @@ -8277,6 +12088,227 @@ } ] }, + "RuleStatsPeriod": { + "kind": "object", + "doc": "Echo of the effective date window (\"yyyy-MM-dd\").", + "fields": [ + { + "name": "start", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "end", + "type": { + "kind": "primitive", + "name": "string" + } + } + ] + }, + "RuleStatsResponse": { + "kind": "object", + "doc": "Response of GET metrics/rule-stats and GET global/metrics/rule-stats: per-rule firing aggregates over the requested window, the active rule-frequency regression episodes (tenant scope only — empty on the cross-tenant aggregate), and window totals.", + "fields": [ + { + "name": "rules", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "RuleStatsRuleAggregate" + } + } + }, + { + "name": "regressions", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "RuleRegressionAlert" + } + } + }, + { + "name": "summary", + "type": { + "kind": "ref", + "name": "RuleStatsSummary" + } + } + ] + }, + "RuleStatsRuleAggregate": { + "kind": "object", + "doc": "One rule's aggregate across all dates in the window (fires descending).", + "fields": [ + { + "name": "ruleId", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "ruleType", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "ruleTitle", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "category", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "severity", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "fireCount", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "evaluationCount", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "sessionsEvaluated", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "hitRate", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Fires per evaluation as a percentage (one decimal); 0 with no evaluations." + }, + { + "name": "avgConfidenceScore", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Average confidence score across fires (one decimal); 0 with no fires." + }, + { + "name": "trend", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "RuleTrendPoint" + } + }, + "doc": "Per-day trend rows, oldest first (one row per stored date)." + } + ] + }, + "RuleStatsSummary": { + "kind": "object", + "doc": "Window totals of a rule-stats response.", + "fields": [ + { + "name": "totalEvaluations", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "totalFires", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "overallHitRate", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "topRuleByFireCount", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true, + "doc": "Rule id with the most fires; absent when the window holds no rules." + }, + { + "name": "uniqueRules", + "type": { + "kind": "primitive", + "name": "number" + }, + "optional": true, + "doc": "Distinct rule count — set on the global route only (the key is absent on the tenant route, preserving its historical shape)." + }, + { + "name": "period", + "type": { + "kind": "ref", + "name": "RuleStatsPeriod" + } + } + ] + }, + "RuleTrendPoint": { + "kind": "object", + "doc": "One day of a rule's trend (\"yyyy-MM-dd\").", + "fields": [ + { + "name": "date", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "fireCount", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "evaluationCount", + "type": { + "kind": "primitive", + "name": "number" + } + } + ] + }, "SaveCustomCpeMappingResponse": { "kind": "object", "doc": "Response of POST vulnerability/cpe-mapping on a successful save.", @@ -8344,6 +12376,185 @@ } ] }, + "SessionAgentMetric": { + "kind": "object", + "fields": [ + { + "name": "sessionId", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "tenantId", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "deviceName", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true + }, + { + "name": "manufacturer", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true + }, + { + "name": "model", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true + }, + { + "name": "startedAt", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true + }, + { + "name": "status", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true + }, + { + "name": "agentVersion", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true + }, + { + "name": "snapshotCount", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "totalBytesUp", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "totalBytesDown", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "totalRequests", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "avgCpu", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "maxCpu", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "avgWorkingSet", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "maxWorkingSet", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "avgPrivateBytes", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "avgLatency", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "avgSpoolDepth", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "maxSpoolDepth", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "peakSpoolDepth", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "maxSpoolFileBytes", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "totalEventsEmitted", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "spoolPressureDetected", + "type": { + "kind": "primitive", + "name": "boolean" + } + } + ] + }, "SessionAnnotationItem": { "kind": "object", "doc": "Wire shape of one session annotation as returned by the session-scoped annotation endpoints (lane implied by context, tenant/session implied by the route). Built by AnnotationWire.ToWire.", @@ -8692,6 +12903,42 @@ } ] }, + "SessionDeletionQueuedResponse": { + "kind": "object", + "doc": "Success body (202 Accepted) of the V2 cascade-delete enqueue — the non-success arms stay anonymous error bodies by design (one shape: success=false + message).", + "fields": [ + { + "name": "success", + "type": { + "kind": "primitive", + "name": "boolean" + } + }, + { + "name": "status", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "Always \"queued\"." + }, + { + "name": "manifestId", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true + }, + { + "name": "message", + "type": { + "kind": "primitive", + "name": "string" + } + } + ] + }, "SessionListResponse": { "kind": "object", "doc": "Paged session listing envelope shared by GetSessions, GetAllSessions, SearchSessionsByCve and SearchSessionsByEvent (identical wire shape).", @@ -8731,6 +12978,84 @@ } ] }, + "SessionMetrics": { + "kind": "object", + "fields": [ + { + "name": "total", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "totalAllTime", + "type": { + "kind": "primitive", + "name": "number" + }, + "optional": true, + "doc": "Cumulative enrollment sessions since the tenant signed up (retention-independent per-tenant counter, see TenantStats). Null on the platform-wide (all-tenants) metrics, which carry the cumulative figure in PlatformStats instead." + }, + { + "name": "today", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "last7Days", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "last30Days", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "succeeded", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "failed", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "inProgress", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "incomplete", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Terminal, non-failure sessions (timeout reclassification): the sweep classified them as Incomplete instead of Failed. Surfaced as its own count and deliberately excluded from SuccessRate (denominator = Succeeded + Failed only), mirroring SessionStats and FleetHealthStats. See tasks/enrollment-status-reclassification.md §5." + }, + { + "name": "successRate", + "type": { + "kind": "primitive", + "name": "number" + } + } + ] + }, "SessionReportDownloadUrlResponse": { "kind": "object", "doc": "Success body of GET global/session-reports/download-url: short-lived SAS download URL for a session report blob.", @@ -8885,6 +13210,107 @@ } ] }, + "SessionRestoreResponse": { + "kind": "object", + "doc": "Body of POST global/sessions/{id}/restore for every outcome (success AND reject — the outcome/status mapping decides the HTTP code, the shape stays one).", + "fields": [ + { + "name": "success", + "type": { + "kind": "primitive", + "name": "boolean" + } + }, + { + "name": "outcome", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "SessionRestoreOutcome name (e.g. \"Restored\", \"DryRunOk\", \"RejectManifestNotFound\")." + }, + { + "name": "mode", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true, + "doc": "\"full\" | \"partial\" | \"dryRun\"; absent on early rejects." + }, + { + "name": "message", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true, + "doc": "Operator-readable reason; absent on clean successes." + }, + { + "name": "currentState", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true, + "doc": "Reject diagnostics; absent otherwise." + }, + { + "name": "pendingManifestId", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true, + "doc": "Reject diagnostics; absent otherwise." + }, + { + "name": "rowsRestoredByTable", + "type": { + "kind": "map", + "value": { + "kind": "primitive", + "name": "number" + } + } + }, + { + "name": "rowsSkippedByTable", + "type": { + "kind": "map", + "value": { + "kind": "primitive", + "name": "number" + } + } + }, + { + "name": "wouldRestoreByTable", + "type": { + "kind": "map", + "value": { + "kind": "primitive", + "name": "number" + } + } + }, + { + "name": "inventoryReIncrements", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "durationMs", + "type": { + "kind": "primitive", + "name": "number" + } + } + ] + }, "SessionStats": { "kind": "object", "doc": "Aggregated session counters for the dashboard stats cards. Computed server-side over a windowed scan of the SessionsIndex so the numbers don't drift with whatever the client happens to have paginated into memory.", @@ -9825,6 +14251,176 @@ } ] }, + "SlaMetricsResponse": { + "kind": "object", + "doc": "SLA metrics response for a given tenant and time window.", + "fields": [ + { + "name": "targetSuccessRate", + "type": { + "kind": "primitive", + "name": "number" + }, + "optional": true + }, + { + "name": "targetMaxDurationMinutes", + "type": { + "kind": "primitive", + "name": "number" + }, + "optional": true + }, + { + "name": "targetAppInstallSuccessRate", + "type": { + "kind": "primitive", + "name": "number" + }, + "optional": true + }, + { + "name": "currentWeek", + "type": { + "kind": "ref", + "name": "SlaSnapshot" + }, + "doc": "Current ISO week SLA snapshot." + }, + { + "name": "weeklyTrend", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "SlaWeeklyTrend" + } + }, + "doc": "Weekly trend (newest first)." + }, + { + "name": "violators", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "SlaViolatorSession" + } + }, + "doc": "Sessions that breached SLA targets (failed or exceeded duration)." + }, + { + "name": "appInstallSla", + "type": { + "kind": "ref", + "name": "AppInstallSlaSnapshot" + }, + "optional": true, + "doc": "App install SLA snapshot (null if no app install target configured)." + }, + { + "name": "computedAt", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "fromCache", + "type": { + "kind": "primitive", + "name": "boolean" + } + }, + { + "name": "computeDurationMs", + "type": { + "kind": "primitive", + "name": "number" + } + } + ] + }, + "SlaSnapshot": { + "kind": "object", + "doc": "SLA compliance snapshot for a single period (ISO week).", + "fields": [ + { + "name": "week", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "ISO week identifier, e.g. \"2026-W15\"." + }, + { + "name": "totalCompleted", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "succeeded", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "failed", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "successRate", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "avgDurationMinutes", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "p95DurationMinutes", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "durationViolationCount", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Number of sessions that exceeded the duration target." + }, + { + "name": "successRateMet", + "type": { + "kind": "primitive", + "name": "boolean" + }, + "doc": "Whether the success rate target is met." + }, + { + "name": "durationTargetMet", + "type": { + "kind": "primitive", + "name": "boolean" + }, + "doc": "Whether the P95 duration target is met." + } + ] + }, "SlaViolatorSession": { "kind": "object", "doc": "A session that violated SLA targets.", @@ -9905,6 +14501,69 @@ } ] }, + "SlaWeeklyTrend": { + "kind": "object", + "doc": "SLA compliance trend entry for one ISO week.", + "fields": [ + { + "name": "week", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "ISO week identifier, e.g. \"2026-W15\"." + }, + { + "name": "successRate", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "p95DurationMinutes", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "appInstallSuccessRate", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "totalCompleted", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "successRateMet", + "type": { + "kind": "primitive", + "name": "boolean" + } + }, + { + "name": "durationTargetMet", + "type": { + "kind": "primitive", + "name": "boolean" + } + }, + { + "name": "appInstallTargetMet", + "type": { + "kind": "primitive", + "name": "boolean" + } + } + ] + }, "SleepSpan": { "kind": "object", "doc": "One completed sleep episode observed during the session (system_sleep_episode payload — enteredAt/exitedAt are the authoritative instants, never the event's own timestamp, which is stamped at wake and may be clamped). Cross-cutting annotation like RebootSpan: sleep overlaps the segment it started in and is NOT part of the wall-clock partition — the session really took that long; the span explains where the time went.", @@ -11184,6 +15843,155 @@ } ] }, + "TenantFeatureEntitlements": { + "kind": "object", + "doc": "Read-time entitlement surface of the resolved edition.", + "fields": [ + { + "name": "retentionCapDays", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "userRateLimitPerMinute", + "type": { + "kind": "primitive", + "name": "number" + }, + "optional": true, + "doc": "Absent when the platform default applies." + }, + { + "name": "delegatedAdminAllowed", + "type": { + "kind": "primitive", + "name": "boolean" + } + }, + { + "name": "mcpUsagePlan", + "type": { + "kind": "primitive", + "name": "string" + } + } + ] + }, + "TenantFeatureFlagsResponse": { + "kind": "object", + "doc": "Response of GET config/{tenantId}/feature-flags: the member-readable subset of the tenant configuration — UI display toggles, feature switches and the read-time edition/entitlement surface. Adding a field here is a deliberate decision that the field is non-sensitive (no webhook URLs, SAS tokens, allowlists, addresses).", + "fields": [ + { + "name": "bootstrapTokenEnabled", + "type": { + "kind": "primitive", + "name": "boolean" + }, + "doc": "EFFECTIVE bootstrap availability (Pro includes it; the GA flag is the additive Community enable) — field name kept for web compatibility." + }, + { + "name": "diagnosticsUploadConfigured", + "type": { + "kind": "primitive", + "name": "boolean" + }, + "doc": "Whether an on-demand diagnostics upload can succeed right now (mode not Off + usable destination). Deliberately no destination detail." + }, + { + "name": "validateAutopilotDevice", + "type": { + "kind": "primitive", + "name": "boolean" + }, + "doc": "Drives the \"Autopilot Device Validation disabled\" dashboard banner." + }, + { + "name": "appHomingFunnelActive", + "type": { + "kind": "primitive", + "name": "boolean" + }, + "doc": "Dual app-reg self-service migration: consent flow targets the NEW app registration. Non-sensitive — exposes no client ids." + }, + { + "name": "showScriptOutput", + "type": { + "kind": "primitive", + "name": "boolean" + } + }, + { + "name": "enableSoftwareInventoryAnalyzer", + "type": { + "kind": "primitive", + "name": "boolean" + } + }, + { + "name": "enableIntegrityBypassAnalyzer", + "type": { + "kind": "primitive", + "name": "boolean" + } + }, + { + "name": "unrestrictedMode", + "type": { + "kind": "primitive", + "name": "boolean" + }, + "doc": "EFFECTIVE gather-rule unrestricted mode (Pro edition + GA gate + tenant toggle)." + }, + { + "name": "edition", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "Resolved edition, lowercase (\"community\" / \"pro\")." + }, + { + "name": "isTrial", + "type": { + "kind": "primitive", + "name": "boolean" + } + }, + { + "name": "trialExpiresUtc", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true, + "doc": "Absent unless the tenant is on an active trial." + }, + { + "name": "trialAvailable", + "type": { + "kind": "primitive", + "name": "boolean" + } + }, + { + "name": "contactEmailSet", + "type": { + "kind": "primitive", + "name": "boolean" + }, + "doc": "Whether a contact address is stored (boolean only — the address stays admin-gated)." + }, + { + "name": "entitlements", + "type": { + "kind": "ref", + "name": "TenantFeatureEntitlements" + } + } + ] + }, "TenantGroup": { "kind": "object", "doc": "A Tenant Group: an app-internal named bundle of tenants. A delegated admin assigned to the group (see TenantGroupAssignment) gains read scope to every tenant in TenantIds. Adding a tenant to the group grants it to all assignees at once.", @@ -11313,6 +16121,32 @@ } ] }, + "TenantMetrics": { + "kind": "object", + "fields": [ + { + "name": "total", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "active7Days", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "active30Days", + "type": { + "kind": "primitive", + "name": "number" + } + } + ] + }, "TenantOffboardingCustomsArchiveEntry": { "kind": "object", "doc": "Snapshot of a tenant's custom rule row written by the offboarding handler during Phase 2.D-archive, BEFORE the original row is safe-wiped. Survives forever so a Global Admin can review (and selectively delete) the tenant's custom rules from /admin/customs-archive after offboarding completes. Storage layout (see PR3.B plan §3.2): PartitionKey: \"{normalizedTenantId}_{historyRowKey}\". One partition per offboarding run — Re-Re-Offboarding produces a fresh, immutable partition each time so multiple runs co-exist without RowKey collisions.RowKey: \"{originalTable}_{base64url(originalRowKey)}\". The table-name prefix disambiguates collisions across the three rules tables; the base64url encoding of avoids the Azure-Tables RowKey-forbidden characters (#, ?, /, \\, control chars).", @@ -11453,6 +16287,152 @@ } ] }, + "TimeAttributionBlockingAppStat": { + "kind": "object", + "doc": "Per-app rollup of ESP-blocking install intervals across the clean sessions of an aggregate row. The what-if numbers are the per-session critical-path savings from removing this app (recomputed union end without it) — an upper BOUND by construction: UI copy must say \"up to\", never \"you will save\" (truthfulness rule 3).", + "fields": [ + { + "name": "appId", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "appName", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "sessionCount", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Clean sessions with a measured interval for this app (row gate: ≥5)." + }, + { + "name": "medianSeconds", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "p75Seconds", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "medianSavingSeconds", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "p75SavingSeconds", + "type": { + "kind": "primitive", + "name": "number" + } + } + ] + }, + "TimeAttributionDailyAggregate": { + "kind": "object", + "doc": "Daily fleet rollup of session time attribution for one (tenant, enrollment class, date) — F1 PR2 (insights spec §F1 \"Data & compute changes\"). Enrollment classes are never mixed (a WhiteGlove flow has a structurally different time profile than user-driven). Only breakdowns with None enter the statistics; flagged sessions are excluded WITH a disclosed count (truthfulness rule 7). Rows are written even below the ≥20-session UI gate — the UI needs the n to say \"insufficient data (n=3)\" instead of silently rendering a small-n median (rule 4). Recomputed idempotently by the rolling 30-day maintenance sweep.", + "fields": [ + { + "name": "date", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "Calendar day (UTC) the sessions STARTED, \"yyyy-MM-dd\" — same bucketing as the usage snapshots." + }, + { + "name": "tenantId", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "Tenant GUID, or \"global\" for the cross-tenant row." + }, + { + "name": "enrollmentClass", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "Enrollment class: \"user_driven\", \"whiteglove\", \"self_deploying\" or \"device_preparation\" (WDP v2)." + }, + { + "name": "attributionVersion", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Attribution algorithm version the underlying breakdowns were computed with (rule 8: never mix definitions)." + }, + { + "name": "cleanSessionCount", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Breakdowns with QualityFlags == None that formed the statistics." + }, + { + "name": "flaggedExcludedCount", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Flagged breakdowns excluded from the statistics (disclosed, never silent)." + }, + { + "name": "missingBreakdownCount", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Terminal sessions of this bucket without a computable breakdown (e.g. events aged out before backfill)." + }, + { + "name": "segmentStats", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "TimeAttributionSegmentStat" + } + } + }, + { + "name": "topBlockingApps", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "TimeAttributionBlockingAppStat" + } + }, + "doc": "Top blocking apps by median interval, gated at ≥5 sessions per app, capped at 20." + }, + { + "name": "computedAt", + "type": { + "kind": "primitive", + "name": "string" + } + } + ] + }, "TimeAttributionFlags": { "kind": "enum", "doc": "Data-quality flags for a SessionTimeBreakdown (truthfulness rule 7: problems flag the record and exclude it from fleet aggregates with a disclosed count, rather than silently skewing them).", @@ -11466,6 +16446,83 @@ "PriorEnrollmentResidue" ] }, + "TimeAttributionMetricsResponse": { + "kind": "object", + "doc": "Response of GET metrics/time-attribution and GET global/metrics/time-attribution: the rolling 30-day range statistics per enrollment class plus the daily rows for the per-day trend. The range window is FIXED at the sweep's 30 days.", + "fields": [ + { + "name": "success", + "type": { + "kind": "primitive", + "name": "boolean" + } + }, + { + "name": "windowDays", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "classes", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "TimeAttributionDailyAggregate" + } + }, + "doc": "Range statistics per enrollment class (never mixed), class-name ordinal order." + }, + { + "name": "daily", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "TimeAttributionDailyAggregate" + } + }, + "doc": "Daily rows of the window, date ordinal order." + } + ] + }, + "TimeAttributionSegmentStat": { + "kind": "object", + "doc": "Distribution of one attribution segment across the clean sessions of an aggregate row. Values are whole seconds; a session that has no span of this segment contributes 0 — the distribution answers \"time spent in this segment per enrollment of this class\", not \"per enrollment that happened to enter it\".", + "fields": [ + { + "name": "segmentKey", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "Segment key (TimeAttributionSegments), including \"unattributed\" — the stack must sum to the wall clock (truthfulness rule 2)." + }, + { + "name": "medianSeconds", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "p75Seconds", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "p90Seconds", + "type": { + "kind": "primitive", + "name": "number" + } + } + ] + }, "TimeAttributionSpan": { "kind": "object", "doc": "One contiguous attributed span inside an observation window.", @@ -11568,6 +16625,40 @@ } ] }, + "TopFailingApp": { + "kind": "object", + "doc": "An app with a high failure rate.", + "fields": [ + { + "name": "appName", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "failCount", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "totalCount", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "successRate", + "type": { + "kind": "primitive", + "name": "number" + } + } + ] + }, "TpmPssUnsupportedItem": { "kind": "object", "doc": "One serial-number bucket in the GetTpmPssUnsupported aggregation. All values are self-reported by devices through the unauthenticated distress channel — UNVERIFIED.", @@ -12153,6 +17244,51 @@ } ] }, + "UserMetrics": { + "kind": "object", + "fields": [ + { + "name": "total", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Total unique users (available after Entra ID integration)" + }, + { + "name": "dailyLogins", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Daily logins across all users" + }, + { + "name": "active7Days", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Active users in last 7 days" + }, + { + "name": "active30Days", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Active users in last 30 days" + }, + { + "name": "note", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "Note about availability" + } + ] + }, "UserUsageDailySummary": { "kind": "object", "doc": "Aggregated daily usage summary across endpoints (and optionally users).", @@ -12250,6 +17386,435 @@ } ] }, + "VerdictCalibrationAlert": { + "kind": "object", + "doc": "One ACTIVE verdict-calibration alert episode (operator-only). Persisted as the verdictcalibration|{kind}|{path}|{status} keyspace of the notification tracker — the row IS the dedup (one ops event per episode) and the alerts[] payload of the calibration endpoint. Deleted when the signal re-arms (share back under 1.5× baseline, path stops occurring, evidence-gap share back under 15 %) or by the tracker's 30-day retention sweep. Numbers refresh on every radar pass; FirstNotifiedAt never moves. Dimension concentration is CORRELATION only — every consumer says so.", + "fields": [ + { + "name": "tenantId", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "kind", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "One of VerdictCalibrationAlertKinds." + }, + { + "name": "verdictPath", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "Verdict path, or the group label for group kinds (\"sweep+maxlife\", \"r6\")." + }, + { + "name": "status", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "Status the path produced, or \"*\" for group kinds." + }, + { + "name": "windowHitCount", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "windowSessionCount", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "baselineHitCount", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "baselineSessionCount", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "windowRatePct", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "baselineRatePct", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "lift", + "type": { + "kind": "primitive", + "name": "number" + }, + "optional": true, + "doc": "Window rate ÷ baseline rate. Null when the baseline rate is 0 or the kind is absolute (evidence gap)." + }, + { + "name": "windowStartDate", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "windowEndDate", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "dimension", + "type": { + "kind": "ref", + "name": "RuleRegressionDimension" + }, + "optional": true, + "doc": "Dimension concentration captured when the alert FIRST fired; null = no clear concentration." + }, + { + "name": "firstNotifiedAt", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "lastEvaluatedAt", + "type": { + "kind": "primitive", + "name": "string" + } + } + ] + }, + "VerdictCalibrationPathRow": { + "kind": "object", + "doc": "One verdict path × status row of the calibration matrix.", + "fields": [ + { + "name": "verdictPath", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "status", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "count", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "sharePct", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "derivedCount", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "eligible7d", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "reEnrolled7d", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "reEnrollRatePct", + "type": { + "kind": "primitive", + "name": "number" + }, + "optional": true, + "doc": "Null below the minimum eligible count — never a rate on a handful of sessions." + }, + { + "name": "overriddenByAdmin", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "overriddenByLateCompletion", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "overriddenOther", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "window7", + "type": { + "kind": "ref", + "name": "VerdictCalibrationTrendWindow" + } + }, + { + "name": "baseline28", + "type": { + "kind": "ref", + "name": "VerdictCalibrationTrendWindow" + } + }, + { + "name": "lift", + "type": { + "kind": "primitive", + "name": "number" + }, + "optional": true, + "doc": "Window share ÷ baseline share; null when the baseline share is 0 (a new path has no finite lift — never invented)." + } + ] + }, + "VerdictCalibrationResponse": { + "kind": "object", + "doc": "Response of GET global/metrics/verdict-calibration: per verdict path, how many sessions it produced in the window, its share, overrides attributed to the prior path, the 7-day re-enrollment proxy, a 7d-vs-28d trend, and the active drift alerts. Operator-only classifier diagnostics.", + "fields": [ + { + "name": "success", + "type": { + "kind": "primitive", + "name": "boolean" + } + }, + { + "name": "tenantId", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "Partition echo: a tenant GUID, or \"global\" for the cross-tenant aggregate." + }, + { + "name": "windowDays", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "windowStart", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "Inclusive window start (\"yyyy-MM-dd\")." + }, + { + "name": "windowEnd", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "Inclusive window end (\"yyyy-MM-dd\", today)." + }, + { + "name": "computedAt", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true, + "doc": "Newest aggregate compute time in the window; absent when the window holds no rows." + }, + { + "name": "versions", + "type": { + "kind": "array", + "element": { + "kind": "primitive", + "name": "number" + } + }, + "doc": "Distinct aggregation algorithm versions contributing to the window, ascending." + }, + { + "name": "totals", + "type": { + "kind": "ref", + "name": "VerdictCalibrationTotals" + } + }, + { + "name": "trend", + "type": { + "kind": "ref", + "name": "VerdictCalibrationTrendMeta" + } + }, + { + "name": "paths", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "VerdictCalibrationPathRow" + } + }, + "doc": "Rows ordered by count descending, then path/status ordinal." + }, + { + "name": "alerts", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "VerdictCalibrationAlert" + } + }, + "doc": "Active drift episodes, newest first." + } + ] + }, + "VerdictCalibrationTotals": { + "kind": "object", + "doc": "Window totals of the calibration matrix.", + "fields": [ + { + "name": "sessions", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "terminal", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "derived", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "days", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Aggregate days that contributed to the window." + } + ] + }, + "VerdictCalibrationTrendMeta": { + "kind": "object", + "doc": "Trend denominators shared by every row (single source, never per-row copies).", + "fields": [ + { + "name": "windowDays", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "baselineDays", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "windowSessions", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "baselineSessions", + "type": { + "kind": "primitive", + "name": "number" + } + } + ] + }, + "VerdictCalibrationTrendWindow": { + "kind": "object", + "doc": "One trend window of a path row.", + "fields": [ + { + "name": "count", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "sessions", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "sharePct", + "type": { + "kind": "primitive", + "name": "number" + } + } + ] + }, "VerificationIssue": { "kind": "object", "fields": [ diff --git a/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts b/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts index 6b8bed1a..6eabadfd 100644 --- a/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts +++ b/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts @@ -155,6 +155,42 @@ export interface AdminIdentityBinding { readonly isObjectIdPinned: boolean; } +/** Response of GET global/metrics/agent-efficiency (GetGlobalAgentEfficiency). */ +export interface AgentEfficiencyMetricsResponse { + windowDays: number; + sessionLimit: number; + /** Echo of the requested tenant filter; null = cross-tenant aggregate. */ + tenantId?: string; + /** Sessions the scan covered — compare against SessionLimit for truncation. */ + sessionsScanned: number; + sessionsWithSnapshots: number; + byVersion: AgentVersionEfficiency[]; + overall?: AgentVersionEfficiency; + computedAt: string; + computeDurationMs: number; + fromCache: boolean; +} + +export interface AgentVersionEfficiency { + /** Null on the cross-version "overall" bucket (omitted on the wire). */ + agentVersion?: string; + sessionsScanned: number; + sessionsWithSnapshots: number; + spoolPressureSessions: number; + avgCpuPercent?: PercentileStats; + maxCpuPercent?: PercentileStats; + maxWorkingSetMb?: PercentileStats; + maxPrivateBytesMb?: PercentileStats; + maxThreadCount?: PercentileStats; + maxHandleCount?: PercentileStats; + maxSpoolDepth?: PercentileStats; + maxSpoolFileBytes?: PercentileStats; + apiLatencyMs?: PercentileStats; + apiRequestCount?: PercentileStats; + crashRate?: CrashRateMetrics; + topOffenders?: EfficiencyOffender[]; +} + /** Defines how to analyze collected events to detect issues Analyze rules run server-side during event ingestion */ export interface AnalyzeRule { /** Unique rule identifier (e.g., "ANALYZE-NET-001") */ @@ -227,6 +263,81 @@ export interface AnalyzeRuleListResponse { rules: AnalyzeRule[]; } +/** One of the top 5 failure codes of the analytics window. */ +export interface AppAnalyticsFailureCode { + code: string; + /** First observed exit code; absent when none of the rows carried one. */ + exitCode?: number; + count: number; + sampleMessage: string; +} + +/** Response of GET apps/{appName}/analytics and its global variant. */ +export interface AppAnalyticsResponse { + success: boolean; + appName: string; + appType: string; + windowDays: number; + collisionExcluded: number; + /** "day" (windows up to 30 days) or "week". */ + bucket: string; + summary: AppAnalyticsSummary; + timeSeries: AppAnalyticsTimeBucket[]; + versionBreakdown: AppVersionBreakdownItem[]; + installerPhaseBreakdown: AppInstallerPhaseCount[]; + topFailureCodes: AppAnalyticsFailureCode[]; + /** Succeeded installs whose detection re-check reported NotDetected. */ + detectionLiesCount: number; + deviceModelBreakdown: AppDeviceModelBreakdownItem[]; + /** Active duration-regression episodes for this app (tracker rows). */ + versionRegressions: AppVersionRegressionAlert[]; +} + +/** Headline aggregate of one app's analytics window. */ +export interface AppAnalyticsSummary { + totalInstalls: number; + succeeded: number; + skipped: number; + unmeasured: number; + failed: number; + failureRate: number; + avgDurationSeconds: number; + p95DurationSeconds: number; + avgDownloadBytes: number; + /** "improving" | "worsening" | "stable". */ + trend: string; + /** Absent when either window half has under 5 finished installs. */ + trendDelta?: number; + /** Share of installs with AttemptNumber > 1 (0-1, three decimals). */ + flakinessScore: number; +} + +/** One day/week bucket of the analytics time series. */ +export interface AppAnalyticsTimeBucket { + bucketStart: string; + installs: number; + succeeded: number; + failed: number; + failureRate: number; + avgDurationSeconds: number; +} + +/** Per device-model failure aggregate (lift vs the app's baseline rate, descending). */ +export interface AppDeviceModelBreakdownItem { + manufacturer: string; + model: string; + installs: number; + failed: number; + failureRate: number; + liftVsBaseline: number; +} + +/** One failure code with its occurrence count. */ +export interface AppFailureCodeCount { + code: string; + count: number; +} + /** Consent-probe verdict embedded in app-homing responses (success and deny alike). Built by AppHomingFunction.ProbePayload. */ export interface AppHomingProbeWire { /** False when the decision needed no probe (e.g. GA force flip). */ @@ -235,6 +346,180 @@ export interface AppHomingProbeWire { isTransient: boolean; } +/** App install SLA snapshot. */ +export interface AppInstallSlaSnapshot { + totalInstalls: number; + succeeded: number; + failed: number; + successRate: number; + targetMet: boolean; + /** Top failing apps by failure count. */ + topFailingApps: TopFailingApp[]; +} + +/** Failed installs per installer phase (descending). */ +export interface AppInstallerPhaseCount { + phase: string; + failed: number; +} + +/** One app's aggregate across its install rows in the window. */ +export interface AppMetricsAppGroup { + appName: string; + totalInstalls: number; + succeeded: number; + skipped: number; + unmeasured: number; + failed: number; + /** Failed / (failed + succeeded) as a percentage; skips never count as attempts. */ + failureRate: number; + /** Average measured FINAL-attempt duration (whole seconds); 0 with no measured installs. */ + avgDurationSeconds: number; + maxDurationSeconds: number; + measuredInstalls: number; + avgDownloadBytes: number; + doTotalBytesDownloaded: number; + doBytesFromPeers: number; + doBytesFromCacheServer: number; + doBytesFromHttp: number; + peerOffloadPercent: number; + /** Top 3 failure codes by count, descending. */ + topFailureCodes: AppFailureCodeCount[]; +} + +/** Fleet-wide Delivery Optimization rollup across every install row in the window. */ +export interface AppMetricsDeliveryOptimization { + totalBytesDownloaded: number; + fromPeers: number; + fromCacheServer: number; + fromHttp: number; + /** Share of bytes not pulled from the CDN (peers + Microsoft Connected Cache), 0-100 one decimal. */ + peerOffloadPercent: number; +} + +/** Response of GET metrics/app and GET global/metrics/app: per-app install health over the requested window (slowest apps by average FINAL-attempt duration, top failing apps) plus the fleet Delivery Optimization rollup. */ +export interface AppMetricsResponse { + success: boolean; + totalApps: number; + totalInstalls: number; + totalSkipped: number; + totalUnmeasured: number; + /** Rows excluded from per-app groups because their name-keyed row merged distinct appIds. */ + totalCollisionExcluded: number; + slowestApps: AppMetricsAppGroup[]; + topFailingApps: AppMetricsAppGroup[]; + deliveryOptimization: AppMetricsDeliveryOptimization; +} + +export interface AppScriptMetrics { + avgAppsPerSession: number; + totalUniqueApps: number; + avgPlatformScriptsPerSession: number; + avgRemediationScriptsPerSession: number; + totalPlatformScripts: number; + totalRemediationScripts: number; +} + +/** One install row of the app-sessions drilldown (failed first, then in-progress, newest first). */ +export interface AppSessionItem { + sessionId: string; + tenantId: string; + deviceName: string; + manufacturer: string; + model: string; + appVersion: string; + status: string; + installerPhase: string; + failureCode: string; + /** Absent when the install carried no exit code. */ + exitCode?: number; + attemptNumber: number; + startedAt: string; + durationSeconds: number; + /** 2+ = the IME processed this app in multiple passes (device-ESP evaluation + real install). */ + installPassCount: number; +} + +/** Response of GET apps/{appName}/sessions and its global variant (offset-paged). */ +export interface AppSessionsResponse { + success: boolean; + total: number; + offset: number; + limit: number; + items: AppSessionItem[]; +} + +/** Per-app-version aggregate (installs descending). */ +export interface AppVersionBreakdownItem { + appVersion: string; + installs: number; + failed: number; + failureRate: number; + measuredInstalls: number; + medianDurationSeconds: number; + p95DurationSeconds: number; +} + +/** One ACTIVE app-version duration regression: an app whose newest version's median install duration rose ≥2× (and ≥5 minutes absolute) over the previous version's median, both sides with enough measured installs. Persisted as the appversionregression|{app}|{version} keyspace of the notification tracker table — the row IS the dedup (one bell per episode), and the versionRegressions[] payload on the app-analytics response. Deleted when the episode re-arms (median falls back under 1.5× or the version drains out of the horizon) or by the tracker's 30-day retention sweep. Numbers refresh on every radar pass while the episode stays active; FirstNotifiedAt never moves. */ +export interface AppVersionRegressionAlert { + tenantId: string; + appName: string; + /** The regressed (newer) version — episode key together with the app. */ + currentVersion: string; + /** The comparison version: latest first-seen strictly before the current version's first-seen. */ + previousVersion: string; + currentMedianSeconds: number; + previousMedianSeconds: number; + /** Measured installs backing each median (gate: both ≥10). */ + currentMeasuredCount: number; + previousMeasuredCount: number; + /** Current median ÷ previous median (gate: ≥2.0; previous median is never 0 — measured durations are ≥1s). */ + lift: number; + /** When the episode first fired (bell + ops event moment). Never moves on refresh; drives the 30d retention re-arm. */ + firstNotifiedAt: string; + /** Last radar pass that re-confirmed/refreshed this episode. */ + lastEvaluatedAt: string; +} + +/** One app row of the apps list (failed desc, then failure rate, then name). */ +export interface AppsListItem { + appName: string; + appType: string; + totalInstalls: number; + succeeded: number; + skipped: number; + unmeasured: number; + failed: number; + failureRate: number; + avgDurationSeconds: number; + maxDurationSeconds: number; + avgDownloadBytes: number; + /** "improving" | "worsening" | "stable". */ + trend: string; + /** Failure-rate delta between window halves; absent when either half has under 5 finished installs. */ + trendDelta?: number; + lastSeenAt: string; +} + +/** Response of GET apps/list and GET global/apps/list. Legacy mode (no pageSize) returns the full array with the paging keys absent; opt-in pagination adds count/offset/pageSize and a nextLink while more pages remain. */ +export interface AppsListResponse { + success: boolean; + totalApps: number; + totalInstalls: number; + /** Rows excluded because their name-keyed row merged distinct appIds. */ + collisionExcluded: number; + windowDays: number; + /** Rows on this page; absent in legacy full-array mode. */ + count?: number; + /** Absent in legacy full-array mode. */ + offset?: number; + /** Absent in legacy full-array mode. */ + pageSize?: number; + apps: AppsListItem[]; + /** Next-page link; absent in legacy mode and on the last page. */ + nextLink?: string; +} + export interface AuditLogEntry { id: string; tenantId: string; @@ -390,6 +675,22 @@ export interface CpeMappingItem { createdAt: string; } +export interface CrashExceptionSummary { + exceptionType: string; + count: number; +} + +export interface CrashRateMetrics { + totalStarts: number; + cleanExits: number; + exceptionCrashes: number; + hardKills: number; + rebootKills: number; + firstRuns: number; + crashRatePercent: number; + topExceptions: CrashExceptionSummary[]; +} + /** Response of POST rules/analyze/{ruleId}/create-from-template: the newly created custom rule instantiated from the template. */ export interface CreateAnalyzeRuleFromTemplateResponse { success: boolean; @@ -534,6 +835,22 @@ export interface DeletionRowKeySample { rk: string; } +export interface DeliveryLatencyMetrics { + p50Ms: number; + p95Ms: number; + p99Ms: number; + avgMs: number; + sampleCount: number; + clockSkewPercent: number; +} + +export interface DeploymentTypeMetrics { + userDriven: number; + whiteGlove: number; + userDrivenPercentage: number; + whiteGlovePercentage: number; +} + /** Response of GET health/detailed: the full system health report (always 200; per-check status is in the body). Items are HealthCheck objects (backend-project type, serialized by runtime type); non-GA callers get a filtered list. */ export interface DetailedHealthCheckResponse { service: string; @@ -565,6 +882,66 @@ export interface DeviceHistory { lastUpdated: string; } +/** One attempt-histogram bucket: how many completed journeys took exactly Attempts attempts. */ +export interface DeviceJourneyAttemptBucket { + attempts: number; + journeyCount: number; +} + +/** Daily First-Time-Right rollup for one (tenant, date) — F2 PR4 (insights spec §F2). A journey counts on the StartedAt date of its COMPLETING success session (same StartedAt-date bucketing as every other daily aggregate; the rolling sweep re-buckets late-terminating sessions idempotently). Only journeys that ended with a terminal success are counted — open journeys (no success yet, incl. WhiteGlove waiting for its user session) and gap-abandoned journeys never enter numerator or denominator. Counts are additive across days, so a window rate is the sum of daily rows — no rolling-window row is needed (unlike the median-based time-attribution aggregates). Rows are written even below the ≥20-completed-journeys UI gate: the UI needs the n to say "insufficient data (n=3)" (truthfulness rule 4). Junk-serial exclusions are disclosed per day (rule 7). "global" mirror rows sum all tenants. */ +export interface DeviceJourneyDailyAggregate { + /** Calendar day (UTC, "yyyy-MM-dd") the completing success sessions STARTED. */ + date: string; + /** Tenant GUID, or "global" for the cross-tenant row. */ + tenantId: string; + /** Journey-grouping algorithm version the counts were computed with (rule 8). */ + journeyVersion: number; + /** Journeys completed (first terminal success) on this date — the FTR denominator. */ + completedJourneyCount: number; + /** Completed journeys with attempt count == 1 — the FTR numerator. */ + firstTimeRightCount: number; + /** Attempt distribution across the completed journeys, ordered by attempts ascending. */ + attemptHistogram: DeviceJourneyAttemptBucket[]; + /** Terminal sessions on this date excluded for junk/placeholder serials — disclosed, never silent (rule 7). */ + excludedSessionCount: number; + computedAt: string; +} + +/** Response of GET metrics/device-journeys and GET global/metrics/device-journeys: daily First-Time-Right rows of the window plus their sums, the merged attempt histogram, and the repeat-devices violator list (absent on the cross-tenant aggregate). */ +export interface DeviceJourneyMetricsResponse { + success: boolean; + windowDays: number; + totals: DeviceJourneyWindowTotals; + /** Daily rows of the window, date ordinal order. */ + daily: DeviceJourneyDailyAggregate[]; + /** Devices whose current journey took at least 2 attempts; absent on the cross-tenant aggregate (no per-device drill there). */ + repeatDevices?: DeviceJourneyRepeatDevice[]; +} + +/** One repeat-device violator row (current journey took at least 2 attempts, newest terminal session in the window). */ +export interface DeviceJourneyRepeatDevice { + serialNumber: string; + manufacturer: string; + model: string; + attempts: number; + journeyCount: number; + lastStatus: string; + lastSessionId: string; + lastStartedAt: string; + /** Failure reason of the newest failed attempt; empty when unavailable (fail-soft point-read). */ + lastFailureReason: string; +} + +/** Window totals of the device-journey response (additive sums over the daily rows). */ +export interface DeviceJourneyWindowTotals { + completedJourneys: number; + firstTimeRight: number; + /** Null with zero completed journeys — no rate claim, never 0 (truthfulness rule 1). */ + ftrRatePct?: number; + excludedSessions: number; + attemptHistogram: DeviceJourneyAttemptBucket[]; +} + /** One serial-number bucket in the GetDeviceNotRegistered aggregation. All values are self-reported by devices through the unauthenticated distress channel — UNVERIFIED. */ export interface DeviceNotRegisteredItem { serialNumber: string; @@ -603,6 +980,18 @@ export interface DeviceSessionRef { adminMarked: boolean; } +/** Wire projection of one built-in diagnostics section. Condition travels as the enum NAME ("Always" | "RealmJoinWatcher" | "DevicePreparation"), never the integer — the web switches on the string. */ +export interface DiagnosticsBuiltInSectionWire { + id: string; + zipFolder: string; + /** UNEXPANDED source folder (may contain %ProgramData% or the user-profile token). */ + sourceFolder: string; + patterns: string[]; + includeSubfolders: boolean; + description: string; + condition: string; +} + /** Response of POST diagnostics/download-ticket: a short-lived, self-authenticating download URL for one diagnostics blob (HMAC ticket in the query string). */ export interface DiagnosticsDownloadTicketResponse { success: boolean; @@ -616,6 +1005,24 @@ export interface DiagnosticsDownloadTicketResponse { sizeBytes?: number; } +/** Represents a log file path (or wildcard pattern) to include in the diagnostics ZIP package. Global (built-in) entries are defined by Global Admins; tenants may add their own. */ +export interface DiagnosticsLogPath { + /** File system path or wildcard pattern. Environment variables are expanded by the agent. Wildcards are only allowed in the last path segment (e.g. "C:\Windows\Panther\*.log"). */ + path: string; + /** Human-readable description shown in the portal. */ + description: string; + /** True when defined globally by a Global Admin — displayed as read-only for tenants. False when added by the tenant itself. */ + isBuiltIn: boolean; + /** When true, the agent also collects matching files from subdirectories recursively. Subdirectory structure is preserved in the ZIP (e.g. AdditionalLogs/Logs/subfolder/file.log). Default is false (top-level only). */ + includeSubfolders: boolean; +} + +/** Response of GET diagnostics/paths: what every diagnostics package collects before a tenant's own entries — the built-in section catalog (compiled into the agent) and the platform-wide global paths set by Global Admins. Member-readable by design. */ +export interface DiagnosticsPathsResponse { + builtIn: DiagnosticsBuiltInSectionWire[]; + globalPaths: DiagnosticsLogPath[]; +} + /** Shared response of POST global/notifications/dismiss-all and POST notifications/dismiss-all: how many notifications were dismissed. */ export interface DismissAllNotificationsResponse { success: boolean; @@ -659,6 +1066,14 @@ export interface DryRunAnalyzeRuleResponse { result: unknown; } +export interface EfficiencyOffender { + sessionId: string; + tenantId: string; + deviceName?: string; + dimension: string; + value: number; +} + /** Response of DELETE global/email-templates/{kind}: reset to the built-in template. */ export interface EmailTemplateResetResponse { /** "welcome" or "farewell". */ @@ -756,6 +1171,104 @@ export interface EpssRefreshRunWire { finishedUtc?: string; } +/** Response of GET feedback/eligibility: whether the caller should be shown the feedback prompt. */ +export interface FeedbackEligibilityResponse { + eligible: boolean; +} + +/** One feedback interaction as rendered on the Global Admin dashboard. */ +export interface FeedbackEntryWire { + type?: string; + upn?: string; + tenantId?: string; + displayName?: string; + /** Absent on dismissals. */ + rating?: number; + /** Absent on dismissals. */ + comment?: string; + dismissed: boolean; + submitted: boolean; + /** ISO-8601 round-trip string; absent when never stamped. */ + interactedAt?: string; + historyRowKey?: string; + domainName?: string; +} + +/** Response of GET feedback/all (Global Admin dashboard): every stored feedback entry. */ +export interface FeedbackListResponse { + feedback: FeedbackEntryWire[]; +} + +export interface FleetDailyPoint { + date: string; + success: number; + failed: number; +} + +export interface FleetFailingModel { + model: string; + failed: number; + /** All sessions on this model in the window, including in-flight ones. */ + total: number; + /** Failed / (Failed + Succeeded) * 100, rounded — finished enrollments only. */ + failureRate: number; +} + +export interface FleetFailureReason { + reason: string; + count: number; +} + +/** Server-computed Fleet Health aggregates. Replaces the client-side pass that drained up to 200k raw sessions into the browser and aggregated on the main thread. Built once per request from the windowed session list — see MetricsMath.BuildFleetHealthPayload. Presentation-only derivations (bar-chart maxima, weekday/month axis labels) stay on the client; this payload carries the data, not the formatting. */ +export interface FleetHealthMetrics { + success: boolean; + days: number; + stats: FleetHealthStats; + /** One entry per day in the window, oldest-first. Date is UTC "yyyy-MM-dd". */ + dailyData: FleetDailyPoint[]; + /** Top failure reasons by count (descending). */ + failureReasons: FleetFailureReason[]; + /** Device models by enrollment volume (descending). */ + modelHealth: FleetModelHealth[]; + /** Device models by average successful enrollment duration (descending). */ + slowestModels: FleetSlowModel[]; + /** Device models by failure count (descending). */ + topFailingModels: FleetFailingModel[]; + computedAt: string; +} + +export interface FleetHealthStats { + total: number; + succeeded: number; + failed: number; + inProgress: number; + /** Terminal, non-failure sessions (timeout reclassification). Surfaced as its own count; not a failure. */ + incomplete: number; + /** Succeeded / (Succeeded + Failed) * 100 (one decimal) — finished enrollments only, matching the SLA convention. 0 when nothing has finished yet (clients render "—"). */ + successRate: number; + /** Average duration in minutes over non-in-progress sessions that carry a duration. Kept for API compatibility; the cards lead with the median, which a handful of multi-hour outliers (overnight ESP, WhiteGlove late user phase) cannot drag around. */ + avgDurationMinutes: number; + /** Median duration in minutes over the same population as AvgDurationMinutes. */ + medianDurationMinutes: number; + /** 90th-percentile duration in minutes over the same population — the tail signal the median alone would hide. */ + p90DurationMinutes: number; +} + +export interface FleetModelHealth { + model: string; + /** All sessions on this model in the window, including in-flight ones. */ + total: number; + succeeded: number; + /** Clients derive the per-model rate as Succeeded / (Succeeded + Failed). */ + failed: number; +} + +export interface FleetSlowModel { + model: string; + avgMinutes: number; + count: number; +} + /** Defines what data the agent should collect Gather rules are delivered to the agent via the config API and can be managed (enabled/disabled, created) through the portal */ export interface GatherRule { /** Unique rule identifier (e.g., "GATHER-NET-001") */ @@ -830,6 +1343,18 @@ export interface GeographicLocationSessionsResponse { totalCount: number; } +/** Response containing geographic performance metrics aggregated by location. */ +export interface GeographicMetricsResponse { + success: boolean; + locations: LocationMetrics[]; + globalAverages: GlobalAverages; + computedAt: string; + totalSessions: number; + locationsWithData: number; + /** Whether geo-location collection is enabled for this tenant */ + geoLocationEnabled: boolean; +} + /** Response of GET global/presence: users active within the requested window. */ export interface GetActiveUsersResponse { success: boolean; @@ -1169,6 +1694,25 @@ export interface GetVulnerabilitySyncStatusResponse { epssLastRun?: EpssRefreshRunWire; } +/** Global average benchmarks for geographic comparison. */ +export interface GlobalAverages { + avgDurationMinutes: number; + medianDurationMinutes: number; + avgMinutesPerApp: number; + avgThroughputBytesPerSec: number; + stdDevDurationMinutes: number; + /** Global request-weighted average agent→backend API latency (ms); 0 = no data yet */ + avgApiLatencyMs: number; + /** Global median of per-session average API latency (ms), robust against outliers; 0 = no data yet */ + medianApiLatencyMs: number; + /** Global weighted average peer caching percentage */ + avgDoPercentPeerCaching: number; + /** Total peer bytes across all locations with DO data */ + totalDoBytesFromPeers: number; + /** Total HTTP bytes across all locations with DO data */ + totalDoBytesFromHttp: number; +} + /** One feature row of the graph-permissions status matrix: the feature identifier, the granted verdict (null while the snapshot is transient) and the Graph application permissions the feature requires. */ export interface GraphFeatureStatusItem { name: string; @@ -1177,6 +1721,17 @@ export interface GraphFeatureStatusItem { requiredPermissions: string[]; } +export interface HardwareCount { + name: string; + count: number; + percentage: number; +} + +export interface HardwareMetrics { + topManufacturers: HardwareCount[]; + topModels: HardwareCount[]; +} + /** One manufacturer+model bucket in the GetHardwareRejected aggregation. All values are self-reported by devices through the unauthenticated distress channel — UNVERIFIED. */ export interface HardwareRejectedItem { manufacturer: string; @@ -1253,6 +1808,58 @@ export interface ImeLogPatternListResponse { patterns: ImeLogPattern[]; } +export interface ImePatternDriftAlert { + version: string; + patternId: string; + baselineVersion: string; + baselineRate: number; + sessions: number; + flaggedAt?: string; +} + +export interface ImePatternHealthCell { + version: string; + patternId: string; + sessions: number; + sessionsWithHit: number; + hits: number; + rate: number; + driftFlaggedAt?: string; +} + +export interface ImePatternHealthPattern { + patternId: string; + category?: string; + enabled: boolean; + /** Hit rate on the baseline version; null without a baseline. */ + baselineRate?: number; + /** True when the baseline rate is at or above the expected threshold. */ + expected: boolean; +} + +/** Response of GET metrics/ime-pattern-health. */ +export interface ImePatternHealthResponse { + baselineVersion?: string; + minBaselineSessions: number; + expectedHitRate: number; + minCandidateSessions: number; + versions: ImePatternHealthVersion[]; + patterns: ImePatternHealthPattern[]; + cells: ImePatternHealthCell[]; + alerts: ImePatternDriftAlert[]; + generatedAt: string; +} + +export interface ImePatternHealthVersion { + version: string; + /** Sessions that delivered a histogram on this version (not the ImeVersionHistory session count). */ + sessions: number; + firstSeenAt?: string; + lastSeenAt?: string; + /** Total sessions per ImeVersionHistory (includes sessions without a terminal run). */ + fleetSessions?: number; +} + /** A tracked IME version sighting. Permanent archive that survives data retention. */ export interface ImeVersionHistoryEntry { version: string; @@ -1277,6 +1884,14 @@ export interface ImeVersionHistoryEntry { msiSourceUrl?: string; } +/** Redacted IME version row served to non-global callers of GET metrics/ime-versions: the platform-wide rollout facts without the first-seen tenant/session identifiers or the archive columns (see GetImeVersionHistoryFunction.BuildResponsePayload). */ +export interface ImeVersionHistoryLeanEntry { + version: string; + firstSeenAt: string; + lastSeenAt: string; + sessionCount: number; +} + /** Response of GET auth/is-global-admin: whether the caller holds the Global Admin platform role, echoing the caller's UPN. */ export interface IsGlobalAdminResponse { isGlobalAdmin: boolean; @@ -1284,6 +1899,11 @@ export interface IsGlobalAdminResponse { upn?: string; } +/** Response of GET global/backups: every backupId in the critical-table-backups container, newest first. Serialized with SerializerOptions (the backup surface's own options), not the ApiJsonOptions pipeline. */ +export interface ListBackupsResponse { + backupIds: string[]; +} + /** Success body of GET /api/global/raw/tables (ListRawTables): the queryable table names. */ export interface ListRawTablesResponse { count: number; @@ -1296,6 +1916,64 @@ export interface ListTenantConfigBackupsResponse { backups: TenantConfigBackupItem[]; } +/** Performance metrics for a single geographic location. */ +export interface LocationMetrics { + locationKey: string; + country: string; + region: string; + city: string; + loc: string; + sessionCount: number; + succeeded: number; + failed: number; + successRate: number; + avgDurationMinutes: number; + medianDurationMinutes: number; + p95DurationMinutes: number; + /** Average number of apps installed per session at this location */ + avgAppCount: number; + /** Average minutes per app (AvgDurationMinutes / AvgAppCount) */ + minutesPerApp: number; + /** Normalized score: 100 = global median, lower is better */ + appLoadScore: number; + /** Average download throughput in bytes/sec at this location */ + avgThroughputBytesPerSec: number; + totalDownloadBytes: number; + /** Percentage difference from global avg duration (negative = faster) */ + durationVsGlobalPct: number; + /** Percentage difference from global avg throughput (positive = faster) */ + throughputVsGlobalPct: number; + /** 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). A single corrupt session average (e.g. a request spanning a sleep/hibernate) can dominate this figure — use MedianApiLatencyMs as the display statistic. */ + avgApiLatencyMs: number; + /** Median of the per-session average agent→backend round-trips (ms) at this location — robust against outlier sessions. 0 when no session here carries latency data. */ + medianApiLatencyMs: number; + /** Sessions at this location that carry API-latency data */ + apiLatencySessionCount: number; + /** Percentage difference from global median API latency (positive = slower/farther) */ + apiLatencyVsGlobalPct: number; + isOutlier: boolean; + /** "fast", "slow", or null */ + outlierDirection?: string; + /** Sessions at this location that have DO telemetry data */ + doSessionCount: number; + /** Weighted percentage of bytes from peers (0-100), computed from total peer/total DO bytes */ + avgDoPercentPeerCaching: number; + /** Total bytes downloaded from all peer sources */ + totalDoBytesFromPeers: number; + /** Total bytes downloaded from HTTP/CDN */ + totalDoBytesFromHttp: number; + /** Bytes from LAN peers */ + totalDoBytesFromLanPeers: number; + /** Bytes from group peers */ + totalDoBytesFromGroupPeers: number; + /** Bytes from internet peers */ + totalDoBytesFromInternetPeers: number; + /** Bytes from link-local peers (same subnet) */ + totalDoBytesFromLinkLocalPeers: number; + /** Bytes served from a Microsoft Connected Cache (MCC) — separate from BytesFromPeers */ + totalDoBytesFromCacheServer: number; +} + /** Lean projection of LocationSessionRow used as the default drilldown row. Fields chosen to support the typical MCP triage flow (which session, when, where, how big a deal, was DO active). Built by GetGeographicLocationSessionsFunction.ToLeanRow. */ export interface LocationSessionLeanRow { sessionId: string; @@ -1513,6 +2191,17 @@ export interface NvdRefreshRunWire { finishedUtc?: string; } +/** 202/200 response body for the offboarding endpoint. Fields point the caller at the History row so subsequent reporting / status polling can resolve back to the audit trail. EarliestProcessingAt drives the "data deletion starts in mm ss" countdown in the Web UI's drain-barrier state. */ +export interface OffboardResponse { + tenantId: string; + status: string; + historyPartitionKey: string; + historyRowKey: string; + message: string; + /** UTC timestamp before which the worker MUST NOT start Phase 2. Drives the cache-drain-barrier countdown UI. Absent on the idempotent-Completed/Failed branches. */ + earliestProcessingAt?: string; +} + export interface OpsEventEntry { id: string; category: string; @@ -1534,6 +2223,26 @@ export interface OpsEventListResponse { nextLink?: string; } +/** Distribution of a per-session statistic across a version bucket. */ +export interface PercentileStats { + p50: number; + p95: number; + max: number; + avg: number; + sampleCount: number; +} + +export interface PerformanceMetrics { + avgDurationMinutes: number; + medianDurationMinutes: number; + p95DurationMinutes: number; + p99DurationMinutes: number; + /** Number of sessions contributing to the duration distribution (after the >0 filter). */ + sampleCount: number; + /** Number of sessions whose raw duration exceeded the clamp ceiling and were capped before aggregation. A non-zero value flags stuck/non-terminal sessions skewing the window — the percentiles above are computed on the clamped values, not the runaway wall-clock duration. */ + clampedSessionCount: number; +} + /** Defines a usage plan tier with request limits. Stored as JSON array in AdminConfiguration.PlanTierDefinitionsJson. */ export interface PlanTierDefinition { name: string; @@ -1547,6 +2256,72 @@ export interface PlanTierDefinitionsResponse { tiers: PlanTierDefinition[]; } +/** Response of GET global/metrics/platform (GetGlobalPlatformMetrics). */ +export interface PlatformAgentMetricsResponse { + sessions: SessionAgentMetric[]; + deliveryLatency?: DeliveryLatencyMetrics; + crashRate?: CrashRateMetrics; + computedAt: string; + computeDurationMs: number; + fromCache: boolean; + windowDays: number; + sessionLimit: number; + /** Sessions the scan actually covered in the window (before the has-snapshots filter that shapes Sessions). Callers must compare THIS against SessionLimit to decide whether the window was truncated — Sessions.Count understates truncation on fleets where many sessions emit no agent_metrics_snapshot. */ + sessionsScanned: number; +} + +/** Pre-computed platform-wide statistics for the public landing page. Stored as a single row (PartitionKey: "global", RowKey: "current"). Incremented during registration/ingest/login; the daily maintenance recompute treats every cumulative counter as a monotonic "since release" high-water-mark (raise-only — the scanned tables are retention-pruned, so a raw recompute would regress the figures). Only TotalSignedUpTenants is current-state (its source table is not retention-pruned). See MaintenanceService.BuildMonotonicPlatformStats. */ +export interface PlatformStats { + /** Total enrollment sessions monitored since launch */ + totalEnrollments: number; + /** Total unique users who logged in */ + totalUsers: number; + /** Total unique tenants using the platform (have at least one session) */ + totalTenants: number; + /** Total tenants signed up (have a tenant configuration entry) */ + totalSignedUpTenants: number; + /** Total unique device models seen (manufacturer + model) */ + uniqueDeviceModels: number; + /** Total events processed across all tenants */ + totalEventsProcessed: number; + /** Total successful enrollments */ + successfulEnrollments: number; + /** Total analysis issues detected */ + issuesDetected: number; + /** When these stats were last fully recomputed */ + lastFullCompute: string; + /** When these stats were last updated (including incremental) */ + lastUpdated: string; +} + +/** Platform usage metrics response */ +export interface PlatformUsageMetrics { + /** Session metrics */ + sessions: SessionMetrics; + /** Tenant metrics */ + tenants: TenantMetrics; + /** User metrics (requires Entra ID authentication) */ + users: UserMetrics; + /** Performance metrics */ + performance: PerformanceMetrics; + /** Hardware metrics */ + hardware: HardwareMetrics; + /** Deployment type metrics (User Driven vs White Glove) */ + deploymentTypes: DeploymentTypeMetrics; + /** App and script count metrics */ + appScripts: AppScriptMetrics; + /** Platform statistics (cumulative since release) */ + platformStats?: PlatformStats; + /** When these metrics were computed */ + computedAt: string; + /** How long it took to compute (milliseconds) */ + computeDurationMs: number; + /** Whether result is from cache */ + fromCache: boolean; + /** Time window (in days) the metrics were computed over. */ + windowDays: number; +} + /** Response of GET /api/progress/sessions/{sessionId}/events — the session's event stream after the serial knowledge proof passed. */ export interface ProgressGetSessionEventsResponse { success: boolean; @@ -1771,6 +2546,43 @@ export interface RulePrecondition { description?: string; } +/** One ACTIVE rule-frequency regression (F3, insights spec §F3): an analyze rule whose 7-day hit rate rose ≥2× over its 28-day baseline with disjoint Wilson intervals. Persisted as the ruleregression|{ruleId} keyspace of the notification tracker table — the row IS the dedup (one bell per episode), the badge state (rules page) and the regressions[] payload (rule-stats response). Deleted when the rate re-arms (falls under 1.5× baseline or stops firing) or by the tracker's 30-day retention sweep (spec: retention cleanup re-arms). Numbers are refreshed on every radar pass while the episode stays active; FirstNotifiedAt never moves. */ +export interface RuleRegressionAlert { + tenantId: string; + ruleId: string; + ruleTitle: string; + windowFireCount: number; + windowSessionCount: number; + baselineFireCount: number; + baselineSessionCount: number; + windowRatePct: number; + baselineRatePct: number; + /** Window rate ÷ baseline rate. Null when the baseline rate is 0 (a NEW signal has no finite lift — never invented). */ + lift?: number; + /** Trailing 7-day window ("yyyy-MM-dd", inclusive) the current numbers describe. */ + windowStartDate: string; + windowEndDate: string; + /** Dimension concentration captured when the alert FIRST fired (stable story); null = no clear concentration. */ + dimension?: RuleRegressionDimension; + /** When the episode first fired (bell + ops event moment). Never moves on refresh; drives the 30d retention re-arm. */ + firstNotifiedAt: string; + /** Last radar pass that re-confirmed/refreshed this episode. */ + lastEvaluatedAt: string; +} + +/** The dominant dimension value among a regression's hit sessions — CORRELATION only, and every consumer's wording must say so ("correlated — not necessarily causal", insights spec §F3 / truthfulness rule 6). Null on an alert means "no clear dimension concentration" — the radar never stretches for one. */ +export interface RuleRegressionDimension { + /** "osBuild", "model", "agentVersion" or "imeVersion". */ + dimension: string; + value: string; + /** Hit sessions carrying this value (gate: ≥5). */ + hitCount: number; + hitSharePct: number; + allSharePct: number; + /** HitShare ÷ AllShare (gate: ≥2.0). */ + lift: number; +} + /** Result of an analyze rule evaluation against a session's events Stored in the RuleResults table and displayed in the session detail UI */ export interface RuleResult { /** Unique identifier for this result */ @@ -1811,6 +2623,56 @@ export interface RuleResult { notifiedAt?: string; } +/** Echo of the effective date window ("yyyy-MM-dd"). */ +export interface RuleStatsPeriod { + start: string; + end: string; +} + +/** Response of GET metrics/rule-stats and GET global/metrics/rule-stats: per-rule firing aggregates over the requested window, the active rule-frequency regression episodes (tenant scope only — empty on the cross-tenant aggregate), and window totals. */ +export interface RuleStatsResponse { + rules: RuleStatsRuleAggregate[]; + regressions: RuleRegressionAlert[]; + summary: RuleStatsSummary; +} + +/** One rule's aggregate across all dates in the window (fires descending). */ +export interface RuleStatsRuleAggregate { + ruleId: string; + ruleType: string; + ruleTitle: string; + category: string; + severity: string; + fireCount: number; + evaluationCount: number; + sessionsEvaluated: number; + /** Fires per evaluation as a percentage (one decimal); 0 with no evaluations. */ + hitRate: number; + /** Average confidence score across fires (one decimal); 0 with no fires. */ + avgConfidenceScore: number; + /** Per-day trend rows, oldest first (one row per stored date). */ + trend: RuleTrendPoint[]; +} + +/** Window totals of a rule-stats response. */ +export interface RuleStatsSummary { + totalEvaluations: number; + totalFires: number; + overallHitRate: number; + /** Rule id with the most fires; absent when the window holds no rules. */ + topRuleByFireCount?: string; + /** Distinct rule count — set on the global route only (the key is absent on the tenant route, preserving its historical shape). */ + uniqueRules?: number; + period: RuleStatsPeriod; +} + +/** One day of a rule's trend ("yyyy-MM-dd"). */ +export interface RuleTrendPoint { + date: string; + fireCount: number; + evaluationCount: number; +} + /** Response of POST vulnerability/cpe-mapping on a successful save. */ export interface SaveCustomCpeMappingResponse { success: boolean; @@ -1828,6 +2690,33 @@ export interface SearchSessionsResponse { nextLink?: string; } +export interface SessionAgentMetric { + sessionId: string; + tenantId: string; + deviceName?: string; + manufacturer?: string; + model?: string; + startedAt?: string; + status?: string; + agentVersion?: string; + snapshotCount: number; + totalBytesUp: number; + totalBytesDown: number; + totalRequests: number; + avgCpu: number; + maxCpu: number; + avgWorkingSet: number; + maxWorkingSet: number; + avgPrivateBytes: number; + avgLatency: number; + avgSpoolDepth: number; + maxSpoolDepth: number; + peakSpoolDepth: number; + maxSpoolFileBytes: number; + totalEventsEmitted: number; + spoolPressureDetected: boolean; +} + /** Wire shape of one session annotation as returned by the session-scoped annotation endpoints (lane implied by context, tenant/session implied by the route). Built by AnnotationWire.ToWire. */ export interface SessionAnnotationItem { lane: string; @@ -1898,6 +2787,15 @@ export interface SessionDeletionProgressWire { lastResidualSampleJson?: string; } +/** Success body (202 Accepted) of the V2 cascade-delete enqueue — the non-success arms stay anonymous error bodies by design (one shape: success=false + message). */ +export interface SessionDeletionQueuedResponse { + success: boolean; + /** Always "queued". */ + status: string; + manifestId?: string; + message: string; +} + /** Paged session listing envelope shared by GetSessions, GetAllSessions, SearchSessionsByCve and SearchSessionsByEvent (identical wire shape). */ export interface SessionListResponse { success: boolean; @@ -1907,6 +2805,21 @@ export interface SessionListResponse { nextLink?: string; } +export interface SessionMetrics { + total: number; + /** Cumulative enrollment sessions since the tenant signed up (retention-independent per-tenant counter, see TenantStats). Null on the platform-wide (all-tenants) metrics, which carry the cumulative figure in PlatformStats instead. */ + totalAllTime?: number; + today: number; + last7Days: number; + last30Days: number; + succeeded: number; + failed: number; + inProgress: number; + /** Terminal, non-failure sessions (timeout reclassification): the sweep classified them as Incomplete instead of Failed. Surfaced as its own count and deliberately excluded from SuccessRate (denominator = Succeeded + Failed only), mirroring SessionStats and FleetHealthStats. See tasks/enrollment-status-reclassification.md §5. */ + incomplete: number; + successRate: number; +} + /** Success body of GET global/session-reports/download-url: short-lived SAS download URL for a session report blob. */ export interface SessionReportDownloadUrlResponse { success: boolean; @@ -1941,6 +2854,26 @@ export interface SessionReportMetadata { diagnosticsCopyStatus?: string; } +/** Body of POST global/sessions/{id}/restore for every outcome (success AND reject — the outcome/status mapping decides the HTTP code, the shape stays one). */ +export interface SessionRestoreResponse { + success: boolean; + /** SessionRestoreOutcome name (e.g. "Restored", "DryRunOk", "RejectManifestNotFound"). */ + outcome: string; + /** "full" | "partial" | "dryRun"; absent on early rejects. */ + mode?: string; + /** Operator-readable reason; absent on clean successes. */ + message?: string; + /** Reject diagnostics; absent otherwise. */ + currentState?: string; + /** Reject diagnostics; absent otherwise. */ + pendingManifestId?: string; + rowsRestoredByTable: Record; + rowsSkippedByTable: Record; + wouldRestoreByTable: Record; + inventoryReIncrements: number; + durationMs: number; +} + /** Aggregated session counters for the dashboard stats cards. Computed server-side over a windowed scan of the SessionsIndex so the numbers don't drift with whatever the client happens to have paginated into memory. */ export interface SessionStats { /** Window the windowed counters were computed over (matches request). */ @@ -2149,6 +3082,42 @@ export interface SignalRecord { payloadJson: string; } +/** SLA metrics response for a given tenant and time window. */ +export interface SlaMetricsResponse { + targetSuccessRate?: number; + targetMaxDurationMinutes?: number; + targetAppInstallSuccessRate?: number; + /** Current ISO week SLA snapshot. */ + currentWeek: SlaSnapshot; + /** Weekly trend (newest first). */ + weeklyTrend: SlaWeeklyTrend[]; + /** Sessions that breached SLA targets (failed or exceeded duration). */ + violators: SlaViolatorSession[]; + /** App install SLA snapshot (null if no app install target configured). */ + appInstallSla?: AppInstallSlaSnapshot; + computedAt: string; + fromCache: boolean; + computeDurationMs: number; +} + +/** SLA compliance snapshot for a single period (ISO week). */ +export interface SlaSnapshot { + /** ISO week identifier, e.g. "2026-W15". */ + week: string; + totalCompleted: number; + succeeded: number; + failed: number; + successRate: number; + avgDurationMinutes: number; + p95DurationMinutes: number; + /** Number of sessions that exceeded the duration target. */ + durationViolationCount: number; + /** Whether the success rate target is met. */ + successRateMet: boolean; + /** Whether the P95 duration target is met. */ + durationTargetMet: boolean; +} + /** A session that violated SLA targets. */ export interface SlaViolatorSession { sessionId: string; @@ -2164,6 +3133,19 @@ export interface SlaViolatorSession { violationType: string; } +/** SLA compliance trend entry for one ISO week. */ +export interface SlaWeeklyTrend { + /** ISO week identifier, e.g. "2026-W15". */ + week: string; + successRate: number; + p95DurationMinutes: number; + appInstallSuccessRate: number; + totalCompleted: number; + successRateMet: boolean; + durationTargetMet: boolean; + appInstallTargetMet: boolean; +} + /** One completed sleep episode observed during the session (system_sleep_episode payload — enteredAt/exitedAt are the authoritative instants, never the event's own timestamp, which is stamped at wake and may be clamped). Cross-cutting annotation like RebootSpan: sleep overlaps the segment it started in and is NOT part of the wall-clock partition — the session really took that long; the span explains where the time went. */ export interface SleepSpan { startUtc: string; @@ -2482,6 +3464,41 @@ export interface TenantDeletionManifestSessionNode { manifests: TenantDeletionManifestItem[]; } +/** Read-time entitlement surface of the resolved edition. */ +export interface TenantFeatureEntitlements { + retentionCapDays: number; + /** Absent when the platform default applies. */ + userRateLimitPerMinute?: number; + delegatedAdminAllowed: boolean; + mcpUsagePlan: string; +} + +/** Response of GET config/{tenantId}/feature-flags: the member-readable subset of the tenant configuration — UI display toggles, feature switches and the read-time edition/entitlement surface. Adding a field here is a deliberate decision that the field is non-sensitive (no webhook URLs, SAS tokens, allowlists, addresses). */ +export interface TenantFeatureFlagsResponse { + /** EFFECTIVE bootstrap availability (Pro includes it; the GA flag is the additive Community enable) — field name kept for web compatibility. */ + bootstrapTokenEnabled: boolean; + /** Whether an on-demand diagnostics upload can succeed right now (mode not Off + usable destination). Deliberately no destination detail. */ + diagnosticsUploadConfigured: boolean; + /** Drives the "Autopilot Device Validation disabled" dashboard banner. */ + validateAutopilotDevice: boolean; + /** Dual app-reg self-service migration: consent flow targets the NEW app registration. Non-sensitive — exposes no client ids. */ + appHomingFunnelActive: boolean; + showScriptOutput: boolean; + enableSoftwareInventoryAnalyzer: boolean; + enableIntegrityBypassAnalyzer: boolean; + /** EFFECTIVE gather-rule unrestricted mode (Pro edition + GA gate + tenant toggle). */ + unrestrictedMode: boolean; + /** Resolved edition, lowercase ("community" / "pro"). */ + edition: string; + isTrial: boolean; + /** Absent unless the tenant is on an active trial. */ + trialExpiresUtc?: string; + trialAvailable: boolean; + /** Whether a contact address is stored (boolean only — the address stays admin-gated). */ + contactEmailSet: boolean; + entitlements: TenantFeatureEntitlements; +} + /** A Tenant Group: an app-internal named bundle of tenants. A delegated admin assigned to the group (see TenantGroupAssignment) gains read scope to every tenant in TenantIds. Adding a tenant to the group grants it to all assignees at once. */ export interface TenantGroup { groupId: string; @@ -2512,6 +3529,12 @@ export interface TenantGroupListResponse { groups: TenantGroup[]; } +export interface TenantMetrics { + total: number; + active7Days: number; + active30Days: number; +} + /** Snapshot of a tenant's custom rule row written by the offboarding handler during Phase 2.D-archive, BEFORE the original row is safe-wiped. Survives forever so a Global Admin can review (and selectively delete) the tenant's custom rules from /admin/customs-archive after offboarding completes. Storage layout (see PR3.B plan §3.2): PartitionKey: "{normalizedTenantId}_{historyRowKey}". One partition per offboarding run — Re-Re-Offboarding produces a fresh, immutable partition each time so multiple runs co-exist without RowKey collisions.RowKey: "{originalTable}_{base64url(originalRowKey)}". The table-name prefix disambiguates collisions across the three rules tables; the base64url encoding of avoids the Azure-Tables RowKey-forbidden characters (#, ?, /, \, control chars). */ export interface TenantOffboardingCustomsArchiveEntry { /** "{normalizedTenantId}_{historyRowKey}". */ @@ -2549,9 +3572,62 @@ export interface TestWebhookNotificationResponse { message: string; } +/** Per-app rollup of ESP-blocking install intervals across the clean sessions of an aggregate row. The what-if numbers are the per-session critical-path savings from removing this app (recomputed union end without it) — an upper BOUND by construction: UI copy must say "up to", never "you will save" (truthfulness rule 3). */ +export interface TimeAttributionBlockingAppStat { + appId: string; + appName: string; + /** Clean sessions with a measured interval for this app (row gate: ≥5). */ + sessionCount: number; + medianSeconds: number; + p75Seconds: number; + medianSavingSeconds: number; + p75SavingSeconds: number; +} + +/** Daily fleet rollup of session time attribution for one (tenant, enrollment class, date) — F1 PR2 (insights spec §F1 "Data & compute changes"). Enrollment classes are never mixed (a WhiteGlove flow has a structurally different time profile than user-driven). Only breakdowns with None enter the statistics; flagged sessions are excluded WITH a disclosed count (truthfulness rule 7). Rows are written even below the ≥20-session UI gate — the UI needs the n to say "insufficient data (n=3)" instead of silently rendering a small-n median (rule 4). Recomputed idempotently by the rolling 30-day maintenance sweep. */ +export interface TimeAttributionDailyAggregate { + /** Calendar day (UTC) the sessions STARTED, "yyyy-MM-dd" — same bucketing as the usage snapshots. */ + date: string; + /** Tenant GUID, or "global" for the cross-tenant row. */ + tenantId: string; + /** Enrollment class: "user_driven", "whiteglove", "self_deploying" or "device_preparation" (WDP v2). */ + enrollmentClass: string; + /** Attribution algorithm version the underlying breakdowns were computed with (rule 8: never mix definitions). */ + attributionVersion: number; + /** Breakdowns with QualityFlags == None that formed the statistics. */ + cleanSessionCount: number; + /** Flagged breakdowns excluded from the statistics (disclosed, never silent). */ + flaggedExcludedCount: number; + /** Terminal sessions of this bucket without a computable breakdown (e.g. events aged out before backfill). */ + missingBreakdownCount: number; + segmentStats: TimeAttributionSegmentStat[]; + /** Top blocking apps by median interval, gated at ≥5 sessions per app, capped at 20. */ + topBlockingApps: TimeAttributionBlockingAppStat[]; + computedAt: string; +} + /** Data-quality flags for a SessionTimeBreakdown (truthfulness rule 7: problems flag the record and exclude it from fleet aggregates with a disclosed count, rather than silently skewing them). */ export type TimeAttributionFlags = "None" | "ClockSkewDropped" | "PartialObservation" | "BlockingSetUnknown" | "BlockingSetTruncated" | "WhiteGloveAnchorsIncomplete" | "PriorEnrollmentResidue"; +/** Response of GET metrics/time-attribution and GET global/metrics/time-attribution: the rolling 30-day range statistics per enrollment class plus the daily rows for the per-day trend. The range window is FIXED at the sweep's 30 days. */ +export interface TimeAttributionMetricsResponse { + success: boolean; + windowDays: number; + /** Range statistics per enrollment class (never mixed), class-name ordinal order. */ + classes: TimeAttributionDailyAggregate[]; + /** Daily rows of the window, date ordinal order. */ + daily: TimeAttributionDailyAggregate[]; +} + +/** Distribution of one attribution segment across the clean sessions of an aggregate row. Values are whole seconds; a session that has no span of this segment contributes 0 — the distribution answers "time spent in this segment per enrollment of this class", not "per enrollment that happened to enter it". */ +export interface TimeAttributionSegmentStat { + /** Segment key (TimeAttributionSegments), including "unattributed" — the stack must sum to the wall clock (truthfulness rule 2). */ + segmentKey: string; + medianSeconds: number; + p75Seconds: number; + p90Seconds: number; +} + /** One contiguous attributed span inside an observation window. */ export interface TimeAttributionSpan { /** Segment key — one of the TimeAttributionSegments constants (never "unattributed": the remainder is a scalar, not a span claim). */ @@ -2574,6 +3650,14 @@ export interface TopCve { sampleSoftware: string[]; } +/** An app with a high failure rate. */ +export interface TopFailingApp { + appName: string; + failCount: number; + totalCount: number; + successRate: number; +} + /** One serial-number bucket in the GetTpmPssUnsupported aggregation. All values are self-reported by devices through the unauthenticated distress channel — UNVERIFIED. */ export interface TpmPssUnsupportedItem { serialNumber: string; @@ -2709,6 +3793,19 @@ export interface UpsertSessionAnnotationResponse { annotation: SessionAnnotationItem; } +export interface UserMetrics { + /** Total unique users (available after Entra ID integration) */ + total: number; + /** Daily logins across all users */ + dailyLogins: number; + /** Active users in last 7 days */ + active7Days: number; + /** Active users in last 30 days */ + active30Days: number; + /** Note about availability */ + note: string; +} + /** Aggregated daily usage summary across endpoints (and optionally users). */ export interface UserUsageDailySummary { date: string; @@ -2729,6 +3826,97 @@ export interface UserUsageRecord { lastRequestAt: string; } +/** One ACTIVE verdict-calibration alert episode (operator-only). Persisted as the verdictcalibration|{kind}|{path}|{status} keyspace of the notification tracker — the row IS the dedup (one ops event per episode) and the alerts[] payload of the calibration endpoint. Deleted when the signal re-arms (share back under 1.5× baseline, path stops occurring, evidence-gap share back under 15 %) or by the tracker's 30-day retention sweep. Numbers refresh on every radar pass; FirstNotifiedAt never moves. Dimension concentration is CORRELATION only — every consumer says so. */ +export interface VerdictCalibrationAlert { + tenantId: string; + /** One of VerdictCalibrationAlertKinds. */ + kind: string; + /** Verdict path, or the group label for group kinds ("sweep+maxlife", "r6"). */ + verdictPath: string; + /** Status the path produced, or "*" for group kinds. */ + status: string; + windowHitCount: number; + windowSessionCount: number; + baselineHitCount: number; + baselineSessionCount: number; + windowRatePct: number; + baselineRatePct: number; + /** Window rate ÷ baseline rate. Null when the baseline rate is 0 or the kind is absolute (evidence gap). */ + lift?: number; + windowStartDate: string; + windowEndDate: string; + /** Dimension concentration captured when the alert FIRST fired; null = no clear concentration. */ + dimension?: RuleRegressionDimension; + firstNotifiedAt: string; + lastEvaluatedAt: string; +} + +/** One verdict path × status row of the calibration matrix. */ +export interface VerdictCalibrationPathRow { + verdictPath: string; + status: string; + count: number; + sharePct: number; + derivedCount: number; + eligible7d: number; + reEnrolled7d: number; + /** Null below the minimum eligible count — never a rate on a handful of sessions. */ + reEnrollRatePct?: number; + overriddenByAdmin: number; + overriddenByLateCompletion: number; + overriddenOther: number; + window7: VerdictCalibrationTrendWindow; + baseline28: VerdictCalibrationTrendWindow; + /** Window share ÷ baseline share; null when the baseline share is 0 (a new path has no finite lift — never invented). */ + lift?: number; +} + +/** Response of GET global/metrics/verdict-calibration: per verdict path, how many sessions it produced in the window, its share, overrides attributed to the prior path, the 7-day re-enrollment proxy, a 7d-vs-28d trend, and the active drift alerts. Operator-only classifier diagnostics. */ +export interface VerdictCalibrationResponse { + success: boolean; + /** Partition echo: a tenant GUID, or "global" for the cross-tenant aggregate. */ + tenantId: string; + windowDays: number; + /** Inclusive window start ("yyyy-MM-dd"). */ + windowStart: string; + /** Inclusive window end ("yyyy-MM-dd", today). */ + windowEnd: string; + /** Newest aggregate compute time in the window; absent when the window holds no rows. */ + computedAt?: string; + /** Distinct aggregation algorithm versions contributing to the window, ascending. */ + versions: number[]; + totals: VerdictCalibrationTotals; + trend: VerdictCalibrationTrendMeta; + /** Rows ordered by count descending, then path/status ordinal. */ + paths: VerdictCalibrationPathRow[]; + /** Active drift episodes, newest first. */ + alerts: VerdictCalibrationAlert[]; +} + +/** Window totals of the calibration matrix. */ +export interface VerdictCalibrationTotals { + sessions: number; + terminal: number; + derived: number; + /** Aggregate days that contributed to the window. */ + days: number; +} + +/** Trend denominators shared by every row (single source, never per-row copies). */ +export interface VerdictCalibrationTrendMeta { + windowDays: number; + baselineDays: number; + windowSessions: number; + baselineSessions: number; +} + +/** One trend window of a path row. */ +export interface VerdictCalibrationTrendWindow { + count: number; + sessions: number; + sharePct: number; +} + export interface VerificationIssue { /** Info / Warning / Error. */ severity: string;