mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
feat(backend,mcp): typed API contract COMPLETED — every remaining top-level success body is a manifest-exported wire DTO, and the guard now catches the bypass shapes that let them escape. The 08-31 migration's ratchet only saw inline WriteAsJsonAsync(new {...}); four bypass classes had kept ~20 endpoint families untyped: (A) Shared classes without IApiResponse — GeographicMetricsResponse, FleetHealthMetrics, ImePatternHealthResponse, SlaMetricsResponse, PlatformUsageMetrics now implement it (wire byte-identical, interface is empty); (B) Functions-local named response families moved verbatim to Shared — PlatformAgentMetricsResponse/SessionAgentMetric/DeliveryLatencyMetrics/CrashRateMetrics/CrashExceptionSummary, AgentEfficiencyMetricsResponse family (CloneAsCacheHit relocated into the service; init→set for netstandard2.0), OffboardResponse; (C) anonymous/object builders converted to DTOs with old-vs-new wire-parity facts (TypedContractCompletionWireParityTests, 14 facts incl. every vanishing-key case): rule-stats x2 (RuleStatsResponse; uniqueRules is global-only via an optional slot at its exact wire position), verdict-calibration (builder renamed VerdictCalibrationResponseBuilder, PathRow/TrendWindow to Shared), metrics/app (MetricsMath), apps list/analytics/sessions x6 routes (AppsAnalyticsHelper; legacy + paged list share ONE DTO via optional paging keys), ime-versions lean projection (ImeVersionHistoryLeanEntry [WireContract]; bare-array envelope stays, typed as IReadOnlyList<object>), device-journeys + time-attribution fleet envelopes, feature-flags, diagnostics/paths, session restore, cascade-delete Enqueued arm (error arms stay anonymous by design), feedback eligibility/list; (D) Serialize+WriteString path — ListBackupsResponse under the backup surface's own serializer options; AppInsightsQuery's Serialize is an outgoing REQUEST body (not response debt, baselined as such). TypedResponseGuardTests gains three ratchets with mutation-proven red/green: WriteAsJsonAsync(<variable assigned an anonymous object>) (empty baseline), JsonSerializer.Serialize(new{ (error-tolerant; 1 documented request-body entry), and object/Task<object>-returning Build*/Compute*/*Payload/*Response methods (1 documented union entry); the local-WriteJson-wrapper class is closed structurally (generic T : IApiResponse — an interface-typed parameter would serialize zero properties, the generic keeps the concrete type). Manifest regenerated 208→298 exported types; MCP adopts the new types (get_rule_stats typed with the trend-drop as explicit output shaping, get_platform_metrics drops its hand-declared mirror for PlatformAgentMetricsResponse, verdict-calibration-shape derives its rows via extends Omit<generated wire row> so backend renames go tsc-red). Six backend shape-test files switched from default-options serialization to the production wire options via TestWire (ApiJsonOptions; the old default-options runs only matched while payloads were anonymous with lowercase members), and three assertions that pinned null-VALUED keys now pin key ABSENCE — production always omitted them (WhenWritingNull); the parity facts prove the wire itself is unchanged. Deliberately still untyped: auth/me, CheckMcpAccess dictionary, TenantAdmins/GlobalAdmins entity-row arrays and the object-slot payloads (RuleDryRun, HealthCheck, TenantConfigFieldSchema, ...) — the documented read-model follow-up. Suites: Backend 5323, DecisionCore 577, Agent 2805, SummaryDialog 44, MCP 617, Web tsc 0 + vitest 957, generator idempotent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+4
-4
@@ -265,7 +265,7 @@ public class AppInstallIdentityAndEspBlockingTests
|
|||||||
Summary("Company Portal", status: "Failed", collision: true),
|
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(1, root.GetProperty("totalApps").GetInt32()); // collided name forms no group
|
||||||
Assert.Equal(4, root.GetProperty("totalInstalls").GetInt32()); // raw row count unchanged
|
Assert.Equal(4, root.GetProperty("totalInstalls").GetInt32()); // raw row count unchanged
|
||||||
@@ -282,7 +282,7 @@ public class AppInstallIdentityAndEspBlockingTests
|
|||||||
Summary("Company Portal", collision: true),
|
Summary("Company Portal", collision: true),
|
||||||
};
|
};
|
||||||
|
|
||||||
var root = JsonSerializer.SerializeToElement(
|
var root = TestWire.SerializeToElement(
|
||||||
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
|
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
|
||||||
|
|
||||||
var apps = root.GetProperty("apps").EnumerateArray().ToList();
|
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
|
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));
|
summaries, repo.Object, "Company Portal", days: 30));
|
||||||
|
|
||||||
Assert.Equal(1, root.GetProperty("collisionExcluded").GetInt32());
|
Assert.Equal(1, root.GetProperty("collisionExcluded").GetInt32());
|
||||||
@@ -324,7 +324,7 @@ public class AppInstallIdentityAndEspBlockingTests
|
|||||||
|
|
||||||
var summaries = new List<AppInstallSummary> { Summary("Company Portal", collision: true) };
|
var summaries = new List<AppInstallSummary> { 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));
|
summaries, repo.Object, "Company Portal", days: 30));
|
||||||
|
|
||||||
Assert.Equal(1, root.GetProperty("collisionExcluded").GetInt32());
|
Assert.Equal(1, root.GetProperty("collisionExcluded").GetInt32());
|
||||||
|
|||||||
@@ -363,7 +363,7 @@ public class AppVersionRegressionRadarTests
|
|||||||
Install("1.0", 50, status: "Failed"), // failed — excluded from durations
|
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));
|
rows, sessionRepo.Object, App, days: 30));
|
||||||
|
|
||||||
var version = root.GetProperty("versionBreakdown")[0];
|
var version = root.GetProperty("versionBreakdown")[0];
|
||||||
@@ -383,19 +383,18 @@ public class AppVersionRegressionRadarTests
|
|||||||
.ReturnsAsync((SessionSummary?)null);
|
.ReturnsAsync((SessionSummary?)null);
|
||||||
|
|
||||||
var alert = Alert();
|
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.
|
// 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).
|
// 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<AppInstallSummary>(), sessionRepo.Object, App, days: 30,
|
new List<AppInstallSummary>(), sessionRepo.Object, App, days: 30,
|
||||||
new List<AppVersionRegressionAlert> { alert }), wireOptions);
|
new List<AppVersionRegressionAlert> { alert }));
|
||||||
Assert.Equal(1, emptyRoot.GetProperty("versionRegressions").GetArrayLength());
|
Assert.Equal(1, emptyRoot.GetProperty("versionRegressions").GetArrayLength());
|
||||||
|
|
||||||
var root = JsonSerializer.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
var root = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||||
new List<AppInstallSummary> { Install("1.0", 100) }, sessionRepo.Object, App, days: 30,
|
new List<AppInstallSummary> { Install("1.0", 100) }, sessionRepo.Object, App, days: 30,
|
||||||
new List<AppVersionRegressionAlert> { alert }), wireOptions);
|
new List<AppVersionRegressionAlert> { alert }));
|
||||||
|
|
||||||
var regression = root.GetProperty("versionRegressions")[0];
|
var regression = root.GetProperty("versionRegressions")[0];
|
||||||
Assert.Equal("2.0", regression.GetProperty("currentVersion").GetString());
|
Assert.Equal("2.0", regression.GetProperty("currentVersion").GetString());
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ public class AppsAnalyticsFailureRateTests
|
|||||||
A("InProgress"),
|
A("InProgress"),
|
||||||
};
|
};
|
||||||
|
|
||||||
var root = JsonSerializer.SerializeToElement(
|
var root = TestWire.SerializeToElement(
|
||||||
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
|
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
|
||||||
|
|
||||||
var app = root.GetProperty("apps")[0];
|
var app = root.GetProperty("apps")[0];
|
||||||
@@ -67,7 +67,7 @@ public class AppsAnalyticsFailureRateTests
|
|||||||
{
|
{
|
||||||
var summaries = new List<AppInstallSummary> { A("InProgress"), A("InProgress") };
|
var summaries = new List<AppInstallSummary> { A("InProgress"), A("InProgress") };
|
||||||
|
|
||||||
var root = JsonSerializer.SerializeToElement(
|
var root = TestWire.SerializeToElement(
|
||||||
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
|
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
|
||||||
|
|
||||||
Assert.Equal(0d, root.GetProperty("apps")[0].GetProperty("failureRate").GetDouble());
|
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 < 2; i++) summaries.Add(A("Succeeded", startedAt: secondHalf));
|
||||||
for (var i = 0; i < 10; i++) summaries.Add(A("InProgress", 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));
|
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
|
||||||
|
|
||||||
var app = root.GetProperty("apps")[0];
|
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 < 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)));
|
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));
|
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
|
||||||
|
|
||||||
var app = root.GetProperty("apps")[0];
|
var app = root.GetProperty("apps")[0];
|
||||||
Assert.Equal("stable", app.GetProperty("trend").GetString());
|
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 ───────────────────────────────────────────
|
// ── /apps/{appName}/analytics ───────────────────────────────────────────
|
||||||
@@ -131,7 +132,7 @@ public class AppsAnalyticsFailureRateTests
|
|||||||
}
|
}
|
||||||
var result = await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
var result = await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||||
summaries, sessionRepo, "Contoso App", days: 30);
|
summaries, sessionRepo, "Contoso App", days: 30);
|
||||||
return JsonSerializer.SerializeToElement(result);
|
return TestWire.SerializeToElement(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -176,7 +177,7 @@ public class AppsAnalyticsFailureRateTests
|
|||||||
|
|
||||||
var summaries = new List<AppInstallSummary> { A("Failed"), A("Succeeded"), skip, unmeasured };
|
var summaries = new List<AppInstallSummary> { A("Failed"), A("Succeeded"), skip, unmeasured };
|
||||||
|
|
||||||
var root = JsonSerializer.SerializeToElement(
|
var root = TestWire.SerializeToElement(
|
||||||
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
|
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
|
||||||
var app = root.GetProperty("apps")[0];
|
var app = root.GetProperty("apps")[0];
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ public class AppsAnalyticsHelperPagingTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static JsonElement Build(int? pageSize, int skip = 0) =>
|
private static JsonElement Build(int? pageSize, int skip = 0) =>
|
||||||
JsonSerializer.SerializeToElement(AppsAnalyticsHelper.BuildAppsListResponse(
|
TestWire.SerializeToElement(AppsAnalyticsHelper.BuildAppsListResponse(
|
||||||
FiveApps(), days: 30, pageSize: pageSize, skip: skip,
|
FiveApps(), days: 30, pageSize: pageSize, skip: skip,
|
||||||
nextLinkForOffset: o => $"/api/apps/list?pageSize={pageSize}&skip={o}"));
|
nextLinkForOffset: o => $"/api/apps/list?pageSize={pageSize}&skip={o}"));
|
||||||
|
|
||||||
@@ -55,12 +55,13 @@ public class AppsAnalyticsHelperPagingTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void PageSize_LastPage_HasNullNextLink()
|
public void PageSize_LastPage_OmitsNextLink()
|
||||||
{
|
{
|
||||||
var root = Build(pageSize: 2, skip: 4);
|
var root = Build(pageSize: 2, skip: 4);
|
||||||
|
|
||||||
Assert.Equal(1, root.GetProperty("count").GetInt32());
|
Assert.Equal(1, root.GetProperty("count").GetInt32());
|
||||||
Assert.Equal("Echo", root.GetProperty("apps")[0].GetProperty("appName").GetString());
|
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 _));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,16 +159,18 @@ public class DeleteSessionFunctionTests
|
|||||||
|
|
||||||
// ── Anonymous-object reflection helpers ───────────────────────────────────────────────
|
// ── 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)
|
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.NotNull(prop);
|
||||||
Assert.Equal(expected, prop!.GetValue(body));
|
Assert.Equal(expected, prop!.GetValue(body));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static T GetProperty<T>(object body, string propertyName)
|
private static T GetProperty<T>(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);
|
Assert.NotNull(prop);
|
||||||
var value = prop!.GetValue(body);
|
var value = prop!.GetValue(body);
|
||||||
return (T)value!;
|
return (T)value!;
|
||||||
|
|||||||
@@ -702,7 +702,7 @@ public class DeviceJourneyAndFtrTests
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
var totals = DeviceJourneyMetricsResponse.SumAggregates(daily);
|
var totals = DeviceJourneyMetricsResponseBuilder.SumAggregates(daily);
|
||||||
|
|
||||||
Assert.Equal(3, totals.CompletedJourneys);
|
Assert.Equal(3, totals.CompletedJourneys);
|
||||||
Assert.Equal(2, totals.FirstTimeRight);
|
Assert.Equal(2, totals.FirstTimeRight);
|
||||||
@@ -716,7 +716,7 @@ public class DeviceJourneyAndFtrTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void SumAggregates_NoCompletedJourneys_RateIsNull_NeverZero()
|
public void SumAggregates_NoCompletedJourneys_RateIsNull_NeverZero()
|
||||||
{
|
{
|
||||||
var totals = DeviceJourneyMetricsResponse.SumAggregates(new List<DeviceJourneyDailyAggregate>
|
var totals = DeviceJourneyMetricsResponseBuilder.SumAggregates(new List<DeviceJourneyDailyAggregate>
|
||||||
{
|
{
|
||||||
new() { CompletedJourneyCount = 0, FirstTimeRightCount = 0, ExcludedSessionCount = 3 },
|
new() { CompletedJourneyCount = 0, FirstTimeRightCount = 0, ExcludedSessionCount = 3 },
|
||||||
});
|
});
|
||||||
@@ -734,7 +734,7 @@ public class DeviceJourneyAndFtrTests
|
|||||||
[InlineData("999", 180)] // clamped to aggregate retention
|
[InlineData("999", 180)] // clamped to aggregate retention
|
||||||
public void ClampDays_DefaultsAndClamps(string? raw, int expected)
|
public void ClampDays_DefaultsAndClamps(string? raw, int expected)
|
||||||
{
|
{
|
||||||
Assert.Equal(expected, DeviceJourneyMetricsResponse.ClampDays(raw));
|
Assert.Equal(expected, DeviceJourneyMetricsResponseBuilder.ClampDays(raw));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -743,8 +743,8 @@ public class DeviceJourneyAndFtrTests
|
|||||||
// Codex review: both range ends are inclusive, so "today - days" returned N+1 keys —
|
// Codex review: both range ends are inclusive, so "today - days" returned N+1 keys —
|
||||||
// days=1 summed yesterday AND today.
|
// days=1 summed yesterday AND today.
|
||||||
var today = new DateTime(2026, 7, 27);
|
var today = new DateTime(2026, 7, 27);
|
||||||
Assert.Equal(today, DeviceJourneyMetricsResponse.InclusiveWindowStart(today, 1));
|
Assert.Equal(today, DeviceJourneyMetricsResponseBuilder.InclusiveWindowStart(today, 1));
|
||||||
Assert.Equal(today.AddDays(-29), DeviceJourneyMetricsResponse.InclusiveWindowStart(today, 30));
|
Assert.Equal(today.AddDays(-29), DeviceJourneyMetricsResponseBuilder.InclusiveWindowStart(today, 30));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static DeviceHistory RepeaterHistory(
|
private static DeviceHistory RepeaterHistory(
|
||||||
@@ -780,7 +780,7 @@ public class DeviceJourneyAndFtrTests
|
|||||||
Ref("w2", SessionStatus.Succeeded, T0.AddDays(-2))),
|
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(2, selected.Count);
|
||||||
Assert.Equal("hot-1", selected[0].History.SerialKey); // attempts desc
|
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))));
|
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(10, selected.Count);
|
||||||
Assert.Equal("dev-11", selected[0].History.SerialKey); // highest attempts first
|
Assert.Equal("dev-11", selected[0].History.SerialKey); // highest attempts first
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ public class GetTenantFeatureFlagsPayloadTests
|
|||||||
private static JsonElement Serialize(TenantConfiguration config)
|
private static JsonElement Serialize(TenantConfiguration config)
|
||||||
{
|
{
|
||||||
var payload = GetTenantFeatureFlagsFunction.BuildPayload(config, Now);
|
var payload = GetTenantFeatureFlagsFunction.BuildPayload(config, Now);
|
||||||
var json = JsonSerializer.Serialize(payload);
|
var json = TestWire.Serialize(payload);
|
||||||
return JsonDocument.Parse(json).RootElement;
|
return JsonDocument.Parse(json).RootElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +49,9 @@ public class GetTenantFeatureFlagsPayloadTests
|
|||||||
"isTrial",
|
"isTrial",
|
||||||
"showScriptOutput",
|
"showScriptOutput",
|
||||||
"trialAvailable",
|
"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",
|
"unrestrictedMode",
|
||||||
"validateAutopilotDevice",
|
"validateAutopilotDevice",
|
||||||
}, fieldNames);
|
}, fieldNames);
|
||||||
@@ -162,7 +164,7 @@ public class GetTenantFeatureFlagsPayloadTests
|
|||||||
WebhookUrl = "https://hooks.example.com/services/secret-generic-hook",
|
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-sas-token", json);
|
||||||
Assert.DoesNotContain("secret-team-hook", json);
|
Assert.DoesNotContain("secret-team-hook", json);
|
||||||
@@ -270,7 +272,7 @@ public class GetTenantFeatureFlagsPayloadTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Payload_ContactEmailSet_NeverLeaksTheAddress()
|
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));
|
new TenantConfiguration { ContactEmail = "secret-contact@contoso.com" }, Now));
|
||||||
|
|
||||||
Assert.DoesNotContain("secret-contact", json);
|
Assert.DoesNotContain("secret-contact", json);
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ public class MetricsMathAppPayloadTests
|
|||||||
private static JsonElement Build(IEnumerable<AppInstallSummary> summaries)
|
private static JsonElement Build(IEnumerable<AppInstallSummary> summaries)
|
||||||
{
|
{
|
||||||
var payload = MetricsMath.BuildAppMetricsPayload(summaries);
|
var payload = MetricsMath.BuildAppMetricsPayload(summaries);
|
||||||
return JsonSerializer.SerializeToElement(payload);
|
return TestWire.SerializeToElement(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using AutopilotMonitor.Functions.Helpers;
|
||||||
|
|
||||||
|
namespace AutopilotMonitor.Functions.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serializes test payloads with the PRODUCTION wire options (<see cref="ApiJsonOptions"/> —
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
internal static class TestWire
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions Options = ApiJsonOptions.Create();
|
||||||
|
|
||||||
|
public static string Serialize<T>(T value) => JsonSerializer.Serialize(value, Options);
|
||||||
|
|
||||||
|
public static JsonElement SerializeToElement<T>(T value) => JsonSerializer.SerializeToElement(value, Options);
|
||||||
|
}
|
||||||
+1
-1
@@ -465,6 +465,6 @@ public class TimeAttributionPersistenceAndAggregationTests
|
|||||||
// returned 31 day keys under a "windowDays: 30" label.
|
// returned 31 day keys under a "windowDays: 30" label.
|
||||||
var today = new System.DateTime(2026, 7, 27);
|
var today = new System.DateTime(2026, 7, 27);
|
||||||
Assert.Equal(today.AddDays(-29),
|
Assert.Equal(today.AddDays(-29),
|
||||||
Functions.Metrics.TimeAttributionResponse.InclusiveWindowStart(today, Functions.Metrics.TimeAttributionResponse.WindowDays));
|
Functions.Metrics.TimeAttributionResponseBuilder.InclusiveWindowStart(today, Functions.Metrics.TimeAttributionResponseBuilder.WindowDays));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+879
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <see cref="ApiResponseWireParityTests"/>: 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.
|
||||||
|
/// </summary>
|
||||||
|
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<RuleRegressionAlert>();
|
||||||
|
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<RuleRegressionAlert>();
|
||||||
|
AssertWireIdentical(
|
||||||
|
new
|
||||||
|
{
|
||||||
|
rules = Array.Empty<object>(),
|
||||||
|
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<RuleStatsRuleAggregate>(),
|
||||||
|
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<VerdictCalibrationPathRow>();
|
||||||
|
var alerts = new List<VerdictCalibrationAlert>();
|
||||||
|
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<object>(),
|
||||||
|
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<AppMetricsAppGroup>(),
|
||||||
|
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<TimeAttributionDailyAggregate>();
|
||||||
|
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<DeviceJourneyDailyAggregate>();
|
||||||
|
// Global aggregate: repeatDevices=null → the key vanishes (the disclosed gap).
|
||||||
|
AssertWireIdentical(
|
||||||
|
new { success = true, windowDays = 30, totals, daily, repeatDevices = (List<object>?)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<DeviceJourneyDailyAggregate>(),
|
||||||
|
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<DeviceJourneyDailyAggregate>(),
|
||||||
|
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<DiagnosticsLogPath>();
|
||||||
|
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<string, int>(StringComparer.Ordinal) { ["Sessions"] = 1 };
|
||||||
|
var empty = new Dictionary<string, int>(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<string> { "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<AppVersionRegressionAlert>();
|
||||||
|
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<object>(),
|
||||||
|
versionBreakdown = Array.Empty<object>(),
|
||||||
|
installerPhaseBreakdown = Array.Empty<object>(),
|
||||||
|
topFailureCodes = Array.Empty<object>(),
|
||||||
|
detectionLiesCount = 0,
|
||||||
|
deviceModelBreakdown = Array.Empty<object>(),
|
||||||
|
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<AppAnalyticsTimeBucket>(),
|
||||||
|
VersionBreakdown = Array.Empty<AppVersionBreakdownItem>(),
|
||||||
|
InstallerPhaseBreakdown = Array.Empty<AppInstallerPhaseCount>(),
|
||||||
|
TopFailureCodes = Array.Empty<AppAnalyticsFailureCode>(),
|
||||||
|
DetectionLiesCount = 0,
|
||||||
|
DeviceModelBreakdown = Array.Empty<AppDeviceModelBreakdownItem>(),
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,6 +47,50 @@ public class TypedResponseGuardTests
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private static readonly Dictionary<string, int> WriteBaseline = new(StringComparer.OrdinalIgnoreCase);
|
private static readonly Dictionary<string, int> 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<object>` returning `new { ... }`
|
||||||
|
// (verdict-calibration's Build). A fourth shape — local `WriteJson(req, object)`
|
||||||
|
// wrappers — is closed structurally: wrappers take a `T : IApiResponse` generic.
|
||||||
|
|
||||||
|
/// <summary>Identifier passed to WriteAsJsonAsync — flagged when the SAME file assigns that identifier an anonymous object.</summary>
|
||||||
|
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<object>)\s+\w*(?:Build|Compute|Payload|Response)\w*\s*\(", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
/// <summary>EMPTY — an anonymous object smuggled through a local variable is the same regression as an inline one.</summary>
|
||||||
|
private static readonly Dictionary<string, int> IdentifierBaseline = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
private static readonly Dictionary<string, int> SerializeBaseline = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["Functions/Raw/AppInsightsQueryFunction.cs"] = 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <c>SessionDeletionQueuedResponse</c>, but the method returns a union with the
|
||||||
|
/// anonymous ERROR arms (tolerated by design), so its declared type must stay object.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly Dictionary<string, int> ObjectBuilderBaseline = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["Functions/Sessions/DeleteSessionFunction.cs"] = 1,
|
||||||
|
};
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Anonymous_helper_success_bodies_never_exceed_the_frozen_baseline()
|
public void Anonymous_helper_success_bodies_never_exceed_the_frozen_baseline()
|
||||||
{
|
{
|
||||||
@@ -66,6 +110,42 @@ public class TypedResponseGuardTests
|
|||||||
"anonymous success WriteAsJsonAsync(new { ... })");
|
"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(<variable holding an anonymous object>)");
|
||||||
|
}
|
||||||
|
|
||||||
|
[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<object>-returning Build*/Compute*/*Payload/*Response method");
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Wire DTOs must be flat: System.Text.Json serializes derived properties BEFORE base
|
/// 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
|
/// properties, so any base class would silently reorder JSON keys — and key order is part
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ public class VerdictCalibrationTests
|
|||||||
private static readonly JsonSerializerOptions Wire = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
private static readonly JsonSerializerOptions Wire = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
||||||
|
|
||||||
private static JsonElement BuildJson(IReadOnlyList<VerdictCalibrationDailyAggregate> daily, DateTime today, int days)
|
private static JsonElement BuildJson(IReadOnlyList<VerdictCalibrationDailyAggregate> daily, DateTime today, int days)
|
||||||
=> JsonSerializer.SerializeToElement(VerdictCalibrationResponse.Build(daily, "global", today, days), Wire);
|
=> JsonSerializer.SerializeToElement(VerdictCalibrationResponseBuilder.Build(daily, "global", today, days), Wire);
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Response_sums_the_window_and_computes_share_rate_and_trend()
|
public void Response_sums_the_window_and_computes_share_rate_and_trend()
|
||||||
@@ -297,5 +297,5 @@ public class VerdictCalibrationTests
|
|||||||
[InlineData("500", 180)]
|
[InlineData("500", 180)]
|
||||||
[InlineData("14", 14)]
|
[InlineData("14", 14)]
|
||||||
public void ClampDays_bounds_the_window(string? raw, int expected)
|
public void ClampDays_bounds_the_window(string? raw, int expected)
|
||||||
=> Assert.Equal(expected, VerdictCalibrationResponse.ClampDays(raw));
|
=> Assert.Equal(expected, VerdictCalibrationResponseBuilder.ClampDays(raw));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -750,24 +750,8 @@ public class TenantOffboardFunction
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
// OffboardResponse (the 202/200 offboarding body) moved to AutopilotMonitor.Shared.Models
|
||||||
/// 202/200 response body for the offboarding endpoint. Fields point the caller at the
|
// (Models/OffboardApiModels.cs) so the shared manifest exports it as a wire type.
|
||||||
/// History row so subsequent reporting / status polling can resolve back to the audit
|
|
||||||
/// trail. <see cref="EarliestProcessingAt"/> drives the "data deletion starts in mm ss"
|
|
||||||
/// countdown in the Web UI's drain-barrier state.
|
|
||||||
/// </summary>
|
|
||||||
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;
|
|
||||||
|
|
||||||
/// <summary>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.</summary>
|
|
||||||
public DateTime? EarliestProcessingAt { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Decision the resume-path produced; consumed by <see cref="TenantOffboardFunction.HandleExistingMarkerAsync"/>
|
/// Decision the resume-path produced; consumed by <see cref="TenantOffboardFunction.HandleExistingMarkerAsync"/>
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
|||||||
/// (so an MCP caller can page a large fleet's app list). <paramref name="nextLinkForOffset"/> builds the
|
/// (so an MCP caller can page a large fleet's app list). <paramref name="nextLinkForOffset"/> builds the
|
||||||
/// route-specific nextLink for the next offset; it is only invoked when more pages remain.
|
/// route-specific nextLink for the next offset; it is only invoked when more pages remain.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static object BuildAppsListResponse(
|
public static AppsListResponse BuildAppsListResponse(
|
||||||
List<AppInstallSummary> allSummaries,
|
List<AppInstallSummary> allSummaries,
|
||||||
int days,
|
int days,
|
||||||
int? pageSize = null,
|
int? pageSize = null,
|
||||||
@@ -161,40 +161,40 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
|||||||
var secondHalf = g.Where(s => s.StartedAt >= midpoint).ToList();
|
var secondHalf = g.Where(s => s.StartedAt >= midpoint).ToList();
|
||||||
var (trend, trendDelta) = ComputeFailureTrend(firstHalf, secondHalf);
|
var (trend, trendDelta) = ComputeFailureTrend(firstHalf, secondHalf);
|
||||||
|
|
||||||
return new
|
return new AppsListItem
|
||||||
{
|
{
|
||||||
appName = g.Key,
|
AppName = g.Key,
|
||||||
appType = g.Select(s => s.AppType).FirstOrDefault(t => !string.IsNullOrEmpty(t)) ?? string.Empty,
|
AppType = g.Select(s => s.AppType).FirstOrDefault(t => !string.IsNullOrEmpty(t)) ?? string.Empty,
|
||||||
totalInstalls = total,
|
TotalInstalls = total,
|
||||||
succeeded = installed.Count,
|
Succeeded = installed.Count,
|
||||||
skipped,
|
Skipped = skipped,
|
||||||
unmeasured = installed.Count - measured.Count,
|
Unmeasured = installed.Count - measured.Count,
|
||||||
failed,
|
Failed = failed,
|
||||||
failureRate,
|
FailureRate = failureRate,
|
||||||
avgDurationSeconds = measured.Count > 0 ? Math.Round(measured.Average(s => s.DurationSeconds), 0) : 0,
|
AvgDurationSeconds = measured.Count > 0 ? Math.Round(measured.Average(s => s.DurationSeconds), 0) : 0,
|
||||||
maxDurationSeconds = measured.Count > 0 ? measured.Max(s => s.DurationSeconds) : 0,
|
MaxDurationSeconds = measured.Count > 0 ? measured.Max(s => s.DurationSeconds) : 0,
|
||||||
avgDownloadBytes = measured.Count > 0 ? (long)measured.Average(s => s.DownloadBytes) : 0,
|
AvgDownloadBytes = measured.Count > 0 ? (long)measured.Average(s => s.DownloadBytes) : 0,
|
||||||
trend,
|
Trend = trend,
|
||||||
trendDelta,
|
TrendDelta = trendDelta,
|
||||||
lastSeenAt = g.Max(s => s.CompletedAt ?? s.StartedAt)
|
LastSeenAt = g.Max(s => s.CompletedAt ?? s.StartedAt)
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.OrderByDescending(a => a.failed)
|
.OrderByDescending(a => a.Failed)
|
||||||
.ThenByDescending(a => a.failureRate)
|
.ThenByDescending(a => a.FailureRate)
|
||||||
.ThenBy(a => a.appName, StringComparer.OrdinalIgnoreCase) // deterministic tiebreaker for stable paging cursors
|
.ThenBy(a => a.AppName, StringComparer.OrdinalIgnoreCase) // deterministic tiebreaker for stable paging cursors
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
// Legacy mode: caller did not opt into pagination → full array (web UI pages client-side).
|
// Legacy mode: caller did not opt into pagination → full array (web UI pages client-side).
|
||||||
if (pageSize == null)
|
if (pageSize == null)
|
||||||
{
|
{
|
||||||
return new
|
return new AppsListResponse
|
||||||
{
|
{
|
||||||
success = true,
|
Success = true,
|
||||||
totalApps = apps.Count,
|
TotalApps = apps.Count,
|
||||||
totalInstalls = summaries.Count,
|
TotalInstalls = summaries.Count,
|
||||||
collisionExcluded,
|
CollisionExcluded = collisionExcluded,
|
||||||
windowDays = days,
|
WindowDays = days,
|
||||||
apps
|
Apps = apps
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,18 +204,18 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
|||||||
var nextOffset = offset + page.Count;
|
var nextOffset = offset + page.Count;
|
||||||
var hasMore = nextOffset < apps.Count;
|
var hasMore = nextOffset < apps.Count;
|
||||||
|
|
||||||
return new
|
return new AppsListResponse
|
||||||
{
|
{
|
||||||
success = true,
|
Success = true,
|
||||||
totalApps = apps.Count,
|
TotalApps = apps.Count,
|
||||||
totalInstalls = summaries.Count,
|
TotalInstalls = summaries.Count,
|
||||||
collisionExcluded,
|
CollisionExcluded = collisionExcluded,
|
||||||
windowDays = days,
|
WindowDays = days,
|
||||||
count = page.Count,
|
Count = page.Count,
|
||||||
offset,
|
Offset = offset,
|
||||||
pageSize = pageSize.Value,
|
PageSize = pageSize.Value,
|
||||||
apps = page,
|
Apps = page,
|
||||||
nextLink = hasMore ? nextLinkForOffset?.Invoke(nextOffset) : null
|
NextLink = hasMore ? nextLinkForOffset?.Invoke(nextOffset) : null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,7 +225,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
|||||||
/// Builds the analytics response body for a single app.
|
/// Builds the analytics response body for a single app.
|
||||||
/// Loads sessions individually via the session repository for the device-model join.
|
/// Loads sessions individually via the session repository for the device-model join.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static async Task<object> BuildAnalyticsResponseAsync(
|
public static async Task<AppAnalyticsResponse> BuildAnalyticsResponseAsync(
|
||||||
List<AppInstallSummary> allSummaries,
|
List<AppInstallSummary> allSummaries,
|
||||||
ISessionRepository sessionRepo,
|
ISessionRepository sessionRepo,
|
||||||
string appName,
|
string appName,
|
||||||
@@ -252,38 +252,38 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
|||||||
|
|
||||||
if (summaries.Count == 0)
|
if (summaries.Count == 0)
|
||||||
{
|
{
|
||||||
return new
|
return new AppAnalyticsResponse
|
||||||
{
|
{
|
||||||
success = true,
|
Success = true,
|
||||||
appName,
|
AppName = appName,
|
||||||
appType = string.Empty,
|
AppType = string.Empty,
|
||||||
windowDays = days,
|
WindowDays = days,
|
||||||
collisionExcluded,
|
CollisionExcluded = collisionExcluded,
|
||||||
bucket = "day",
|
Bucket = "day",
|
||||||
summary = new
|
Summary = new AppAnalyticsSummary
|
||||||
{
|
{
|
||||||
totalInstalls = 0,
|
TotalInstalls = 0,
|
||||||
succeeded = 0,
|
Succeeded = 0,
|
||||||
skipped = 0,
|
Skipped = 0,
|
||||||
unmeasured = 0,
|
Unmeasured = 0,
|
||||||
failed = 0,
|
Failed = 0,
|
||||||
failureRate = 0,
|
FailureRate = 0,
|
||||||
avgDurationSeconds = 0,
|
AvgDurationSeconds = 0,
|
||||||
p95DurationSeconds = 0,
|
P95DurationSeconds = 0,
|
||||||
avgDownloadBytes = 0,
|
AvgDownloadBytes = 0,
|
||||||
trend = "stable",
|
Trend = "stable",
|
||||||
trendDelta = (double?)null,
|
TrendDelta = null,
|
||||||
flakinessScore = 0.0
|
FlakinessScore = 0.0
|
||||||
},
|
},
|
||||||
timeSeries = Array.Empty<object>(),
|
TimeSeries = Array.Empty<AppAnalyticsTimeBucket>(),
|
||||||
versionBreakdown = Array.Empty<object>(),
|
VersionBreakdown = Array.Empty<AppVersionBreakdownItem>(),
|
||||||
installerPhaseBreakdown = Array.Empty<object>(),
|
InstallerPhaseBreakdown = Array.Empty<AppInstallerPhaseCount>(),
|
||||||
topFailureCodes = Array.Empty<object>(),
|
TopFailureCodes = Array.Empty<AppAnalyticsFailureCode>(),
|
||||||
detectionLiesCount = 0,
|
DetectionLiesCount = 0,
|
||||||
deviceModelBreakdown = Array.Empty<object>(),
|
DeviceModelBreakdown = Array.Empty<AppDeviceModelBreakdownItem>(),
|
||||||
// Lockstep with the full response: episodes can outlive the window's data
|
// 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.
|
// (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)
|
.Where(MetricsMath.HasMeasuredDuration)
|
||||||
.Select(s => s.DurationSeconds)
|
.Select(s => s.DurationSeconds)
|
||||||
.ToList();
|
.ToList();
|
||||||
return new
|
return new AppVersionBreakdownItem
|
||||||
{
|
{
|
||||||
appVersion = g.Key,
|
AppVersion = g.Key,
|
||||||
installs = vTotal,
|
Installs = vTotal,
|
||||||
failed = vFailed,
|
Failed = vFailed,
|
||||||
failureRate = MetricsMath.TerminalFailureRatePct(vFailed, vSucceeded),
|
FailureRate = MetricsMath.TerminalFailureRatePct(vFailed, vSucceeded),
|
||||||
measuredInstalls = vDurations.Count,
|
MeasuredInstalls = vDurations.Count,
|
||||||
medianDurationSeconds = Percentile(vDurations, 0.50),
|
MedianDurationSeconds = Percentile(vDurations, 0.50),
|
||||||
p95DurationSeconds = Percentile(vDurations, 0.95)
|
P95DurationSeconds = Percentile(vDurations, 0.95)
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.OrderByDescending(v => v.installs)
|
.OrderByDescending(v => v.Installs)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var installerPhaseBreakdown = summaries
|
var installerPhaseBreakdown = summaries
|
||||||
.Where(s => s.Status == "Failed" && !string.IsNullOrEmpty(s.InstallerPhase))
|
.Where(s => s.Status == "Failed" && !string.IsNullOrEmpty(s.InstallerPhase))
|
||||||
.GroupBy(s => s.InstallerPhase)
|
.GroupBy(s => s.InstallerPhase)
|
||||||
.Select(g => new { phase = g.Key, failed = g.Count() })
|
.Select(g => new AppInstallerPhaseCount { Phase = g.Key, Failed = g.Count() })
|
||||||
.OrderByDescending(p => p.failed)
|
.OrderByDescending(p => p.Failed)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var topFailureCodes = summaries
|
var topFailureCodes = summaries
|
||||||
.Where(s => s.Status == "Failed" && !string.IsNullOrEmpty(s.FailureCode))
|
.Where(s => s.Status == "Failed" && !string.IsNullOrEmpty(s.FailureCode))
|
||||||
.GroupBy(s => s.FailureCode)
|
.GroupBy(s => s.FailureCode)
|
||||||
.Select(g => new
|
.Select(g => new AppAnalyticsFailureCode
|
||||||
{
|
{
|
||||||
code = g.Key,
|
Code = g.Key,
|
||||||
exitCode = g.Select(s => s.ExitCode).FirstOrDefault(e => e.HasValue),
|
ExitCode = g.Select(s => s.ExitCode).FirstOrDefault(e => e.HasValue),
|
||||||
count = g.Count(),
|
Count = g.Count(),
|
||||||
sampleMessage = g.Select(s => s.FailureMessage).FirstOrDefault(m => !string.IsNullOrEmpty(m)) ?? string.Empty
|
SampleMessage = g.Select(s => s.FailureMessage).FirstOrDefault(m => !string.IsNullOrEmpty(m)) ?? string.Empty
|
||||||
})
|
})
|
||||||
.OrderByDescending(f => f.count)
|
.OrderByDescending(f => f.Count)
|
||||||
.Take(5)
|
.Take(5)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
@@ -397,57 +397,57 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
|||||||
var lift = failureRate > 0
|
var lift = failureRate > 0
|
||||||
? Math.Round(modelFailureRate / failureRate, 2)
|
? Math.Round(modelFailureRate / failureRate, 2)
|
||||||
: 0;
|
: 0;
|
||||||
return new
|
return new AppDeviceModelBreakdownItem
|
||||||
{
|
{
|
||||||
manufacturer = g.Key.Manufacturer,
|
Manufacturer = g.Key.Manufacturer,
|
||||||
model = g.Key.Model,
|
Model = g.Key.Model,
|
||||||
installs = modelTotal,
|
Installs = modelTotal,
|
||||||
failed = modelFailed,
|
Failed = modelFailed,
|
||||||
failureRate = modelFailureRate,
|
FailureRate = modelFailureRate,
|
||||||
liftVsBaseline = lift
|
LiftVsBaseline = lift
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.OrderByDescending(m => m.liftVsBaseline)
|
.OrderByDescending(m => m.LiftVsBaseline)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var appType = summaries.Select(s => s.AppType).FirstOrDefault(t => !string.IsNullOrEmpty(t)) ?? string.Empty;
|
var appType = summaries.Select(s => s.AppType).FirstOrDefault(t => !string.IsNullOrEmpty(t)) ?? string.Empty;
|
||||||
|
|
||||||
return new
|
return new AppAnalyticsResponse
|
||||||
{
|
{
|
||||||
success = true,
|
Success = true,
|
||||||
appName,
|
AppName = appName,
|
||||||
appType,
|
AppType = appType,
|
||||||
windowDays = days,
|
WindowDays = days,
|
||||||
collisionExcluded,
|
CollisionExcluded = collisionExcluded,
|
||||||
bucket,
|
Bucket = bucket,
|
||||||
summary = new
|
Summary = new AppAnalyticsSummary
|
||||||
{
|
{
|
||||||
totalInstalls = total,
|
TotalInstalls = total,
|
||||||
succeeded,
|
Succeeded = succeeded,
|
||||||
skipped,
|
Skipped = skipped,
|
||||||
unmeasured = installed.Count - measured.Count,
|
Unmeasured = installed.Count - measured.Count,
|
||||||
failed,
|
Failed = failed,
|
||||||
failureRate,
|
FailureRate = failureRate,
|
||||||
avgDurationSeconds,
|
AvgDurationSeconds = avgDurationSeconds,
|
||||||
p95DurationSeconds,
|
P95DurationSeconds = p95DurationSeconds,
|
||||||
avgDownloadBytes,
|
AvgDownloadBytes = avgDownloadBytes,
|
||||||
trend,
|
Trend = trend,
|
||||||
trendDelta,
|
TrendDelta = trendDelta,
|
||||||
flakinessScore
|
FlakinessScore = flakinessScore
|
||||||
},
|
},
|
||||||
timeSeries,
|
TimeSeries = timeSeries,
|
||||||
versionBreakdown,
|
VersionBreakdown = versionBreakdown,
|
||||||
installerPhaseBreakdown,
|
InstallerPhaseBreakdown = installerPhaseBreakdown,
|
||||||
topFailureCodes,
|
TopFailureCodes = topFailureCodes,
|
||||||
detectionLiesCount,
|
DetectionLiesCount = detectionLiesCount,
|
||||||
deviceModelBreakdown,
|
DeviceModelBreakdown = deviceModelBreakdown,
|
||||||
versionRegressions = appVersionRegressions
|
VersionRegressions = appVersionRegressions
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── /apps/{appName}/sessions ────────────────────────────────────────
|
// ── /apps/{appName}/sessions ────────────────────────────────────────
|
||||||
|
|
||||||
public static async Task<object> BuildSessionsResponseAsync(
|
public static async Task<AppSessionsResponse> BuildSessionsResponseAsync(
|
||||||
List<AppInstallSummary> allSummaries,
|
List<AppInstallSummary> allSummaries,
|
||||||
ISessionRepository sessionRepo,
|
ISessionRepository sessionRepo,
|
||||||
string appName,
|
string appName,
|
||||||
@@ -499,34 +499,34 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
|||||||
var items = page.Select(s =>
|
var items = page.Select(s =>
|
||||||
{
|
{
|
||||||
sessionLookup.TryGetValue($"{s.TenantId}|{s.SessionId}", out var sess);
|
sessionLookup.TryGetValue($"{s.TenantId}|{s.SessionId}", out var sess);
|
||||||
return new
|
return new AppSessionItem
|
||||||
{
|
{
|
||||||
sessionId = s.SessionId,
|
SessionId = s.SessionId,
|
||||||
tenantId = s.TenantId,
|
TenantId = s.TenantId,
|
||||||
deviceName = sess?.DeviceName ?? string.Empty,
|
DeviceName = sess?.DeviceName ?? string.Empty,
|
||||||
manufacturer = sess?.Manufacturer ?? string.Empty,
|
Manufacturer = sess?.Manufacturer ?? string.Empty,
|
||||||
model = sess?.Model ?? string.Empty,
|
Model = sess?.Model ?? string.Empty,
|
||||||
appVersion = s.AppVersion,
|
AppVersion = s.AppVersion,
|
||||||
status = s.Status,
|
Status = s.Status,
|
||||||
installerPhase = s.InstallerPhase,
|
InstallerPhase = s.InstallerPhase,
|
||||||
failureCode = s.FailureCode,
|
FailureCode = s.FailureCode,
|
||||||
exitCode = s.ExitCode,
|
ExitCode = s.ExitCode,
|
||||||
attemptNumber = s.AttemptNumber,
|
AttemptNumber = s.AttemptNumber,
|
||||||
startedAt = s.StartedAt,
|
StartedAt = s.StartedAt,
|
||||||
durationSeconds = s.DurationSeconds,
|
DurationSeconds = s.DurationSeconds,
|
||||||
// 2+ = the IME processed this app in multiple passes (device-ESP
|
// 2+ = the IME processed this app in multiple passes (device-ESP
|
||||||
// evaluation + real install) — explains a completion far after startedAt.
|
// evaluation + real install) — explains a completion far after startedAt.
|
||||||
installPassCount = s.InstallPassCount
|
InstallPassCount = s.InstallPassCount
|
||||||
};
|
};
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
return new
|
return new AppSessionsResponse
|
||||||
{
|
{
|
||||||
success = true,
|
Success = true,
|
||||||
total,
|
Total = total,
|
||||||
offset,
|
Offset = offset,
|
||||||
limit,
|
Limit = limit,
|
||||||
items
|
Items = items
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -559,7 +559,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
|||||||
return (trend, delta);
|
return (trend, delta);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<object> BuildTimeSeries(List<AppInstallSummary> summaries, DateTime cutoff, DateTime now, string bucket)
|
private static List<AppAnalyticsTimeBucket> BuildTimeSeries(List<AppInstallSummary> summaries, DateTime cutoff, DateTime now, string bucket)
|
||||||
{
|
{
|
||||||
var start = bucket == "week" ? StartOfWeek(cutoff) : cutoff.Date;
|
var start = bucket == "week" ? StartOfWeek(cutoff) : cutoff.Date;
|
||||||
var end = now.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.
|
// PR0 convention: skips are not attempts; durations read measured rows only.
|
||||||
var bInstalled = items.Where(s => s.Status == "Succeeded" && !MetricsMath.IsSkipTerminalState(s)).ToList();
|
var bInstalled = items.Where(s => s.Status == "Succeeded" && !MetricsMath.IsSkipTerminalState(s)).ToList();
|
||||||
var bMeasured = bInstalled.Where(MetricsMath.HasMeasuredDuration).ToList();
|
var bMeasured = bInstalled.Where(MetricsMath.HasMeasuredDuration).ToList();
|
||||||
return (object)new
|
return new AppAnalyticsTimeBucket
|
||||||
{
|
{
|
||||||
bucketStart = DateTime.SpecifyKind(kv.Key, DateTimeKind.Utc),
|
BucketStart = DateTime.SpecifyKind(kv.Key, DateTimeKind.Utc),
|
||||||
installs = bTotal,
|
Installs = bTotal,
|
||||||
succeeded = bInstalled.Count,
|
Succeeded = bInstalled.Count,
|
||||||
failed = bFailed,
|
Failed = bFailed,
|
||||||
failureRate = MetricsMath.TerminalFailureRatePct(bFailed, bInstalled.Count),
|
FailureRate = MetricsMath.TerminalFailureRatePct(bFailed, bInstalled.Count),
|
||||||
avgDurationSeconds = bMeasured.Count > 0 ? Math.Round(bMeasured.Average(s => s.DurationSeconds), 0) : 0
|
AvgDurationSeconds = bMeasured.Count > 0 ? Math.Round(bMeasured.Average(s => s.DurationSeconds), 0) : 0
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ namespace AutopilotMonitor.Functions.Functions.Backup
|
|||||||
|
|
||||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||||
response.Headers.Add("Content-Type", "application/json; charset=utf-8");
|
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);
|
await response.WriteStringAsync(json);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-20
@@ -70,56 +70,56 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
|||||||
/// without standing up an HttpRequestData mock. <paramref name="nowUtc"/> feeds the
|
/// without standing up an HttpRequestData mock. <paramref name="nowUtc"/> feeds the
|
||||||
/// read-time edition resolution (trial expiry degrades automatically).
|
/// read-time edition resolution (trial expiry degrades automatically).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
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 edition = FeatureEntitlementCatalog.ResolveEdition(config.PlanTier, config.TrialExpiresUtc, nowUtc);
|
||||||
var entitlements = FeatureEntitlementCatalog.Get(edition);
|
var entitlements = FeatureEntitlementCatalog.Get(edition);
|
||||||
var isTrial = edition == TenantEdition.Pro &&
|
var isTrial = edition == TenantEdition.Pro &&
|
||||||
!FeatureEntitlementCatalog.IsPermanentProTier(config.PlanTier);
|
!FeatureEntitlementCatalog.IsPermanentProTier(config.PlanTier);
|
||||||
|
|
||||||
return new
|
return new TenantFeatureFlagsResponse
|
||||||
{
|
{
|
||||||
// EFFECTIVE bootstrap availability (Pro includes it; the GA flag is the additive
|
// EFFECTIVE bootstrap availability (Pro includes it; the GA flag is the additive
|
||||||
// Community enable) — field name kept for web compatibility.
|
// 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
|
// 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
|
// 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
|
// read the full config, so this boolean is their only signal. Deliberately exposes no
|
||||||
// destination detail — just "would an upload work".
|
// destination detail — just "would an upload work".
|
||||||
diagnosticsUploadConfigured = DiagnosticsUploadConfigChange.IsConfigured(config),
|
DiagnosticsUploadConfigured = DiagnosticsUploadConfigChange.IsConfigured(config),
|
||||||
// Drives the "Autopilot Device Validation disabled" dashboard banner
|
// Drives the "Autopilot Device Validation disabled" dashboard banner
|
||||||
// (useTenantSecurityConfig).
|
// (useTenantSecurityConfig).
|
||||||
validateAutopilotDevice = config.ValidateAutopilotDevice,
|
ValidateAutopilotDevice = config.ValidateAutopilotDevice,
|
||||||
// Dual app-reg self-service migration: when true, running the consent flow (or
|
// 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
|
// "Detect existing access") targets the NEW app registration and auto-flips this
|
||||||
// tenant's homing after verification — drives the explanatory banner in the
|
// tenant's homing after verification — drives the explanatory banner in the
|
||||||
// Autopilot Validation settings section. Non-sensitive: exposes no client ids.
|
// Autopilot Validation settings section. Non-sensitive: exposes no client ids.
|
||||||
appHomingFunnelActive,
|
AppHomingFunnelActive = appHomingFunnelActive,
|
||||||
// Session-detail UI flags (useSessionTenantConfig). Nullable in the model;
|
// Session-detail UI flags (useSessionTenantConfig). Nullable in the model;
|
||||||
// surface the agent-side defaults so the UI does not need a second nullable layer.
|
// surface the agent-side defaults so the UI does not need a second nullable layer.
|
||||||
showScriptOutput = config.ShowScriptOutput ?? true,
|
ShowScriptOutput = config.ShowScriptOutput ?? true,
|
||||||
enableSoftwareInventoryAnalyzer = config.EnableSoftwareInventoryAnalyzer ?? false,
|
EnableSoftwareInventoryAnalyzer = config.EnableSoftwareInventoryAnalyzer ?? false,
|
||||||
enableIntegrityBypassAnalyzer = config.EnableIntegrityBypassAnalyzer ?? true,
|
EnableIntegrityBypassAnalyzer = config.EnableIntegrityBypassAnalyzer ?? true,
|
||||||
// Gather-rules page validation indicator. EFFECTIVE value (requires Pro edition +
|
// Gather-rules page validation indicator. EFFECTIVE value (requires Pro edition +
|
||||||
// GA gate + tenant toggle) — the privileged toggle is UnrestrictedModeEnabled
|
// GA gate + tenant toggle) — the privileged toggle is UnrestrictedModeEnabled
|
||||||
// (admin-only, stays in the full config response).
|
// (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):
|
// Edition/entitlement surface (read-time resolution — non-sensitive by design):
|
||||||
// drives the EditionBadge, trial CTA and retention hint in the web UI.
|
// drives the EditionBadge, trial CTA and retention hint in the web UI.
|
||||||
edition = edition.ToString().ToLowerInvariant(),
|
Edition = edition.ToString().ToLowerInvariant(),
|
||||||
isTrial,
|
IsTrial = isTrial,
|
||||||
trialExpiresUtc = isTrial ? config.TrialExpiresUtc : null,
|
TrialExpiresUtc = isTrial ? config.TrialExpiresUtc : null,
|
||||||
trialAvailable = !config.TrialConsumed && edition == TenantEdition.Community,
|
TrialAvailable = !config.TrialConsumed && edition == TenantEdition.Community,
|
||||||
// Pro-requires-contact surface: drives the trial CTA gate and the dashboard
|
// Pro-requires-contact surface: drives the trial CTA gate and the dashboard
|
||||||
// "set a contact address" banner for Pro tenants. Boolean only — the address
|
// "set a contact address" banner for Pro tenants. Boolean only — the address
|
||||||
// itself stays in the admin-gated full config response.
|
// itself stays in the admin-gated full config response.
|
||||||
contactEmailSet = !string.IsNullOrWhiteSpace(config.ContactEmail),
|
ContactEmailSet = !string.IsNullOrWhiteSpace(config.ContactEmail),
|
||||||
entitlements = new
|
Entitlements = new TenantFeatureEntitlements
|
||||||
{
|
{
|
||||||
retentionCapDays = entitlements.RetentionCapDays,
|
RetentionCapDays = entitlements.RetentionCapDays,
|
||||||
userRateLimitPerMinute = entitlements.UserRateLimitPerMinute,
|
UserRateLimitPerMinute = entitlements.UserRateLimitPerMinute,
|
||||||
delegatedAdminAllowed = entitlements.DelegatedAdminAllowed,
|
DelegatedAdminAllowed = entitlements.DelegatedAdminAllowed,
|
||||||
mcpUsagePlan = entitlements.McpUsagePlanName
|
McpUsagePlan = entitlements.McpUsagePlanName
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-10
@@ -59,19 +59,19 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
|
|||||||
/// <c>condition</c> travels as the enum NAME ("Always" | "RealmJoinWatcher" |
|
/// <c>condition</c> travels as the enum NAME ("Always" | "RealmJoinWatcher" |
|
||||||
/// "DevicePreparation"), never the integer — the web switches on the string.
|
/// "DevicePreparation"), never the integer — the web switches on the string.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
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,
|
Id = s.Id,
|
||||||
zipFolder = s.ZipFolder,
|
ZipFolder = s.ZipFolder,
|
||||||
sourceFolder = s.SourceFolder,
|
SourceFolder = s.SourceFolder,
|
||||||
patterns = s.Patterns,
|
Patterns = s.Patterns,
|
||||||
includeSubfolders = s.IncludeSubfolders,
|
IncludeSubfolders = s.IncludeSubfolders,
|
||||||
description = s.Description,
|
Description = s.Description,
|
||||||
condition = s.Condition.ToString(),
|
Condition = s.Condition.ToString(),
|
||||||
}).ToArray(),
|
}).ToArray(),
|
||||||
globalPaths = adminConfig.GetDiagnosticsGlobalLogPaths(),
|
GlobalPaths = adminConfig.GetDiagnosticsGlobalLogPaths(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ namespace AutopilotMonitor.Functions.Functions.Feedback
|
|||||||
// four extra Table reads on every poll from every active user.
|
// four extra Table reads on every poll from every active user.
|
||||||
var adminConfig = await _adminConfigService.GetConfigurationAsync();
|
var adminConfig = await _adminConfigService.GetConfigurationAsync();
|
||||||
if (!adminConfig.FeedbackEnabled)
|
if (!adminConfig.FeedbackEnabled)
|
||||||
return await WriteJson(req, new { eligible = false });
|
return await WriteJson(req, new FeedbackEligibilityResponse { Eligible = false });
|
||||||
|
|
||||||
// 2. User identity
|
// 2. User identity
|
||||||
string tenantId = TenantHelper.GetTenantId(req);
|
string tenantId = TenantHelper.GetTenantId(req);
|
||||||
@@ -86,17 +86,17 @@ namespace AutopilotMonitor.Functions.Functions.Feedback
|
|||||||
var roleInfo = EntraAppRoleResolver.Resolve(
|
var roleInfo = EntraAppRoleResolver.Resolve(
|
||||||
tableState, tableRole, context.GetUser()?.GetAppRoles(), tenantConfig.EntraAppRolesEnabled);
|
tableState, tableRole, context.GetUser()?.GetAppRoles(), tenantConfig.EntraAppRolesEnabled);
|
||||||
if (roleInfo == null || roleInfo.Role == Constants.TenantRoles.Viewer)
|
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
|
// 4. Tenant age check
|
||||||
if (tenantConfig.OnboardedAt == null ||
|
if (tenantConfig.OnboardedAt == null ||
|
||||||
(DateTime.UtcNow - tenantConfig.OnboardedAt.Value).TotalDays < adminConfig.FeedbackMinTenantAgeDays)
|
(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.
|
// 5. Sessions check — at least 1 session exists.
|
||||||
var sessionPage = sessionsTask.Result;
|
var sessionPage = sessionsTask.Result;
|
||||||
if (sessionPage.Items.Count == 0)
|
if (sessionPage.Items.Count == 0)
|
||||||
return await WriteJson(req, new { eligible = false });
|
return await WriteJson(req, new FeedbackEligibilityResponse { Eligible = false });
|
||||||
|
|
||||||
// 6. Cooldown check
|
// 6. Cooldown check
|
||||||
var feedbackEntry = feedbackTask.Result;
|
var feedbackEntry = feedbackTask.Result;
|
||||||
@@ -104,14 +104,14 @@ namespace AutopilotMonitor.Functions.Functions.Feedback
|
|||||||
{
|
{
|
||||||
// Cooldown = 0 means single wave only
|
// Cooldown = 0 means single wave only
|
||||||
if (adminConfig.FeedbackCooldownDays == 0)
|
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;
|
var daysSinceInteraction = (DateTime.UtcNow - feedbackEntry.InteractedAt.Value).TotalDays;
|
||||||
if (daysSinceInteraction < adminConfig.FeedbackCooldownDays)
|
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)
|
catch (UnauthorizedAccessException)
|
||||||
{
|
{
|
||||||
@@ -122,7 +122,7 @@ namespace AutopilotMonitor.Functions.Functions.Feedback
|
|||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error checking feedback status");
|
_logger.LogError(ex, "Error checking feedback status");
|
||||||
// Fail closed — don't show bubble on errors
|
// 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})",
|
_logger.LogInformation("Feedback {Action} by {Upn} (tenant {TenantId})",
|
||||||
body.Dismissed ? "dismissed" : "submitted", upn, tenantId);
|
body.Dismissed ? "dismissed" : "submitted", upn, tenantId);
|
||||||
|
|
||||||
return await WriteJson(req, new { success = true });
|
return await WriteJson(req, new SuccessOnlyResponse { Success = true });
|
||||||
}
|
}
|
||||||
catch (UnauthorizedAccessException)
|
catch (UnauthorizedAccessException)
|
||||||
{
|
{
|
||||||
@@ -222,22 +222,22 @@ namespace AutopilotMonitor.Functions.Functions.Feedback
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var allEntries = await _feedbackRepo.GetAllAsync();
|
var allEntries = await _feedbackRepo.GetAllAsync();
|
||||||
var entries = allEntries.Select(e => new
|
var entries = allEntries.Select(e => new FeedbackEntryWire
|
||||||
{
|
{
|
||||||
type = e.Type,
|
Type = e.Type,
|
||||||
upn = e.Upn,
|
Upn = e.Upn,
|
||||||
tenantId = e.TenantId,
|
TenantId = e.TenantId,
|
||||||
displayName = e.DisplayName,
|
DisplayName = e.DisplayName,
|
||||||
rating = e.Rating,
|
Rating = e.Rating,
|
||||||
comment = e.Comment,
|
Comment = e.Comment,
|
||||||
dismissed = e.Dismissed,
|
Dismissed = e.Dismissed,
|
||||||
submitted = e.Submitted,
|
Submitted = e.Submitted,
|
||||||
interactedAt = e.InteractedAt?.ToString("o"),
|
InteractedAt = e.InteractedAt?.ToString("o"),
|
||||||
historyRowKey = e.HistoryRowKey,
|
HistoryRowKey = e.HistoryRowKey,
|
||||||
domainName = e.DomainName,
|
DomainName = e.DomainName,
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
return await WriteJson(req, new { feedback = entries });
|
return await WriteJson(req, new FeedbackListResponse { Feedback = entries });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -248,7 +248,11 @@ namespace AutopilotMonitor.Functions.Functions.Feedback
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<HttpResponseData> 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<HttpResponseData> WriteJson<T>(HttpRequestData req, T data) where T : IApiResponse
|
||||||
{
|
{
|
||||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||||
await response.WriteAsJsonAsync(data);
|
await response.WriteAsJsonAsync(data);
|
||||||
|
|||||||
+30
-38
@@ -129,10 +129,10 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
|||||||
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware
|
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware
|
||||||
var tenantId = TenantHelper.GetTenantId(req);
|
var tenantId = TenantHelper.GetTenantId(req);
|
||||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
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);
|
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||||
await response.WriteAsJsonAsync(await DeviceJourneyMetricsResponse.BuildAsync(
|
await response.WriteAsJsonAsync(await DeviceJourneyMetricsResponseBuilder.BuildAsync(
|
||||||
_metricsRepo, _sessionRepo, tenantId, days, includeRepeatDevices: true));
|
_metricsRepo, _sessionRepo, tenantId, days, includeRepeatDevices: true));
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
@@ -177,11 +177,11 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
|||||||
// Authentication + GlobalReadOrAdmin authorization enforced by PolicyEnforcementMiddleware
|
// Authentication + GlobalReadOrAdmin authorization enforced by PolicyEnforcementMiddleware
|
||||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||||
var tenantIdFilter = query["tenantId"];
|
var tenantIdFilter = query["tenantId"];
|
||||||
var days = DeviceJourneyMetricsResponse.ClampDays(query["days"]);
|
var days = DeviceJourneyMetricsResponseBuilder.ClampDays(query["days"]);
|
||||||
var partition = string.IsNullOrWhiteSpace(tenantIdFilter) ? "global" : tenantIdFilter!;
|
var partition = string.IsNullOrWhiteSpace(tenantIdFilter) ? "global" : tenantIdFilter!;
|
||||||
|
|
||||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
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"));
|
_metricsRepo, _sessionRepo, partition, days, includeRepeatDevices: partition != "global"));
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
@@ -195,8 +195,13 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Shared response shape for the tenant and global variants (mirrors TimeAttributionResponse).</summary>
|
/// <summary>
|
||||||
internal static class DeviceJourneyMetricsResponse
|
/// Shared response builder for the tenant and global variants (mirrors
|
||||||
|
/// TimeAttributionResponseBuilder). The envelope/row DTOs
|
||||||
|
/// (<see cref="DeviceJourneyMetricsResponse"/>, <see cref="DeviceJourneyWindowTotals"/>,
|
||||||
|
/// <see cref="DeviceJourneyRepeatDevice"/>) live in Shared so the manifest exports them.
|
||||||
|
/// </summary>
|
||||||
|
internal static class DeviceJourneyMetricsResponseBuilder
|
||||||
{
|
{
|
||||||
internal const int DefaultWindowDays = 30;
|
internal const int DefaultWindowDays = 30;
|
||||||
internal const int MaxWindowDays = 180; // aggregate retention — older rows no longer exist
|
internal const int MaxWindowDays = 180; // aggregate retention — older rows no longer exist
|
||||||
@@ -215,7 +220,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
internal static DateTime InclusiveWindowStart(DateTime today, int days) => today.AddDays(-(days - 1));
|
internal static DateTime InclusiveWindowStart(DateTime today, int days) => today.AddDays(-(days - 1));
|
||||||
|
|
||||||
internal static async Task<object> BuildAsync(
|
internal static async Task<DeviceJourneyMetricsResponse> BuildAsync(
|
||||||
IMetricsRepository metricsRepo, ISessionRepository sessionRepo,
|
IMetricsRepository metricsRepo, ISessionRepository sessionRepo,
|
||||||
string partition, int days, bool includeRepeatDevices)
|
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 daily = await metricsRepo.GetDeviceJourneyAggregatesAsync(partition, InclusiveWindowStart(today, days), today);
|
||||||
var totals = SumAggregates(daily);
|
var totals = SumAggregates(daily);
|
||||||
|
|
||||||
List<object>? repeatDevices = null;
|
List<DeviceJourneyRepeatDevice>? repeatDevices = null;
|
||||||
if (includeRepeatDevices)
|
if (includeRepeatDevices)
|
||||||
{
|
{
|
||||||
var histories = await metricsRepo.GetDeviceHistoriesByTenantAsync(partition);
|
var histories = await metricsRepo.GetDeviceHistoriesByTenantAsync(partition);
|
||||||
var candidates = SelectRepeatDevices(histories, today.AddDays(-days));
|
var candidates = SelectRepeatDevices(histories, today.AddDays(-days));
|
||||||
repeatDevices = new List<object>(candidates.Count);
|
repeatDevices = new List<DeviceJourneyRepeatDevice>(candidates.Count);
|
||||||
foreach (var candidate in candidates)
|
foreach (var candidate in candidates)
|
||||||
{
|
{
|
||||||
// Failure reason of the newest failed attempt — a bounded point-read per top
|
// 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
|
// fail-soft — the row renders without a reason
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
repeatDevices.Add(new
|
repeatDevices.Add(new DeviceJourneyRepeatDevice
|
||||||
{
|
{
|
||||||
serialNumber = candidate.History.SerialNumber,
|
SerialNumber = candidate.History.SerialNumber,
|
||||||
manufacturer = candidate.History.Manufacturer,
|
Manufacturer = candidate.History.Manufacturer,
|
||||||
model = candidate.History.Model,
|
Model = candidate.History.Model,
|
||||||
attempts = candidate.History.CurrentJourneyAttempts,
|
Attempts = candidate.History.CurrentJourneyAttempts,
|
||||||
journeyCount = candidate.History.JourneyCount,
|
JourneyCount = candidate.History.JourneyCount,
|
||||||
lastStatus = candidate.Newest.Status,
|
LastStatus = candidate.Newest.Status,
|
||||||
lastSessionId = candidate.Newest.SessionId,
|
LastSessionId = candidate.Newest.SessionId,
|
||||||
lastStartedAt = candidate.Newest.StartedAt,
|
LastStartedAt = candidate.Newest.StartedAt,
|
||||||
lastFailureReason,
|
LastFailureReason = lastFailureReason,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return new
|
return new DeviceJourneyMetricsResponse
|
||||||
{
|
{
|
||||||
success = true,
|
Success = true,
|
||||||
windowDays = days,
|
WindowDays = days,
|
||||||
totals,
|
Totals = totals,
|
||||||
daily = daily.OrderBy(d => d.Date, StringComparer.Ordinal).ToList(),
|
Daily = daily.OrderBy(d => d.Date, StringComparer.Ordinal).ToList(),
|
||||||
repeatDevices,
|
RepeatDevices = repeatDevices,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Window totals (serialized camelCase by the worker's global serializer, like the model rows).</summary>
|
|
||||||
internal sealed class DeviceJourneyWindowTotals
|
|
||||||
{
|
|
||||||
public int CompletedJourneys { get; init; }
|
|
||||||
public int FirstTimeRight { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Null with zero completed journeys — no rate claim, never 0 (truthfulness rule 1).</summary>
|
|
||||||
public double? FtrRatePct { get; init; }
|
|
||||||
|
|
||||||
public int ExcludedSessions { get; init; }
|
|
||||||
public List<DeviceJourneyAttemptBucket> AttemptHistogram { get; init; } = new();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Window sums over the daily rows — honest because every field is an additive count
|
/// 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
|
/// (rule: the FTR window rate IS the ratio of summed counts). internal static: pinned
|
||||||
|
|||||||
+12
-5
@@ -66,11 +66,12 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
|||||||
/// Global scope sees every row, unconfirmed ones included — that is where a bogus claim
|
/// Global scope sees every row, unconfirmed ones included — that is where a bogus claim
|
||||||
/// is investigated.
|
/// is investigated.
|
||||||
///
|
///
|
||||||
/// Returned as <see cref="object"/> on purpose — System.Text.Json serializes an
|
/// Returned as <c>IReadOnlyList<object></c> — System.Text.Json serializes each
|
||||||
/// `object` declared type using the RUNTIME type, so both branches keep the exact
|
/// element using its RUNTIME type, so both branches keep the exact wire format they
|
||||||
/// wire format they had while the projection was inline.
|
/// had while the projection was inline. Both element types are manifest-exported
|
||||||
|
/// (<see cref="ImeVersionHistoryEntry"/> / <see cref="ImeVersionHistoryLeanEntry"/>).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static object BuildResponsePayload(
|
public static IReadOnlyList<object> BuildResponsePayload(
|
||||||
IEnumerable<ImeVersionHistoryEntry> versions, bool hasGlobalScope)
|
IEnumerable<ImeVersionHistoryEntry> versions, bool hasGlobalScope)
|
||||||
{
|
{
|
||||||
if (hasGlobalScope)
|
if (hasGlobalScope)
|
||||||
@@ -80,7 +81,13 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
|||||||
|
|
||||||
return versions
|
return versions
|
||||||
.Where(IsFleetConfirmed)
|
.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();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+14
-10
@@ -93,7 +93,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
|||||||
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware
|
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware
|
||||||
var tenantId = TenantHelper.GetTenantId(req);
|
var tenantId = TenantHelper.GetTenantId(req);
|
||||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||||
await response.WriteAsJsonAsync(await TimeAttributionResponse.BuildAsync(_metricsRepo, tenantId));
|
await response.WriteAsJsonAsync(await TimeAttributionResponseBuilder.BuildAsync(_metricsRepo, tenantId));
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -132,7 +132,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
|||||||
var partition = string.IsNullOrWhiteSpace(tenantIdFilter) ? "global" : tenantIdFilter!;
|
var partition = string.IsNullOrWhiteSpace(tenantIdFilter) ? "global" : tenantIdFilter!;
|
||||||
|
|
||||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||||
await response.WriteAsJsonAsync(await TimeAttributionResponse.BuildAsync(_metricsRepo, partition));
|
await response.WriteAsJsonAsync(await TimeAttributionResponseBuilder.BuildAsync(_metricsRepo, partition));
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -145,8 +145,12 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Shared response shape for the tenant and global variants (mirrors the MetricsMath single-source pattern).</summary>
|
/// <summary>
|
||||||
internal static class TimeAttributionResponse
|
/// Shared response builder for the tenant and global variants (mirrors the MetricsMath
|
||||||
|
/// single-source pattern). The envelope DTO (<see cref="TimeAttributionMetricsResponse"/>)
|
||||||
|
/// lives in Shared so the manifest exports it.
|
||||||
|
/// </summary>
|
||||||
|
internal static class TimeAttributionResponseBuilder
|
||||||
{
|
{
|
||||||
internal const int WindowDays = 30;
|
internal const int WindowDays = 30;
|
||||||
|
|
||||||
@@ -164,7 +168,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
internal static DateTime InclusiveWindowStart(DateTime today, int days) => today.AddDays(-(days - 1));
|
internal static DateTime InclusiveWindowStart(DateTime today, int days) => today.AddDays(-(days - 1));
|
||||||
|
|
||||||
internal static async Task<object> BuildAsync(IMetricsRepository metricsRepo, string partition)
|
internal static async Task<TimeAttributionMetricsResponse> BuildAsync(IMetricsRepository metricsRepo, string partition)
|
||||||
{
|
{
|
||||||
var freshCutoff = DateTime.UtcNow - RollingMaxAge;
|
var freshCutoff = DateTime.UtcNow - RollingMaxAge;
|
||||||
var rolling = (await metricsRepo.GetRollingTimeAttributionAggregatesAsync(partition))
|
var rolling = (await metricsRepo.GetRollingTimeAttributionAggregatesAsync(partition))
|
||||||
@@ -173,14 +177,14 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
|||||||
var today = DateTime.UtcNow.Date;
|
var today = DateTime.UtcNow.Date;
|
||||||
var daily = await metricsRepo.GetTimeAttributionAggregatesAsync(partition, InclusiveWindowStart(today, WindowDays), today);
|
var daily = await metricsRepo.GetTimeAttributionAggregatesAsync(partition, InclusiveWindowStart(today, WindowDays), today);
|
||||||
|
|
||||||
return new
|
return new TimeAttributionMetricsResponse
|
||||||
{
|
{
|
||||||
success = true,
|
Success = true,
|
||||||
windowDays = WindowDays,
|
WindowDays = WindowDays,
|
||||||
// Range statistics per enrollment class (never mixed) — the UI gates rendering
|
// Range statistics per enrollment class (never mixed) — the UI gates rendering
|
||||||
// at cleanSessionCount >= 20 and shows "insufficient data (n=…)" below it.
|
// at cleanSessionCount >= 20 and shows "insufficient data (n=…)" below it.
|
||||||
classes = rolling.OrderBy(r => r.EnrollmentClass, StringComparer.Ordinal).ToList(),
|
Classes = rolling.OrderBy(r => r.EnrollmentClass, StringComparer.Ordinal).ToList(),
|
||||||
daily = daily.OrderBy(d => d.Date, StringComparer.Ordinal).ToList(),
|
Daily = daily.OrderBy(d => d.Date, StringComparer.Ordinal).ToList(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-49
@@ -42,7 +42,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
|||||||
// Authentication + GlobalReadOrAdmin authorization enforced by PolicyEnforcementMiddleware
|
// Authentication + GlobalReadOrAdmin authorization enforced by PolicyEnforcementMiddleware
|
||||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||||
var tenantIdFilter = query["tenantId"];
|
var tenantIdFilter = query["tenantId"];
|
||||||
var days = VerdictCalibrationResponse.ClampDays(query["days"]);
|
var days = VerdictCalibrationResponseBuilder.ClampDays(query["days"]);
|
||||||
var partition = "global";
|
var partition = "global";
|
||||||
if (!string.IsNullOrWhiteSpace(tenantIdFilter))
|
if (!string.IsNullOrWhiteSpace(tenantIdFilter))
|
||||||
{
|
{
|
||||||
@@ -57,14 +57,14 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
|||||||
|
|
||||||
var today = DateTime.UtcNow.Date;
|
var today = DateTime.UtcNow.Date;
|
||||||
// One read covers both the requested window and the trend horizon (7d window + 28d baseline).
|
// 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(
|
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).
|
// Active drift episodes of this partition (tracker keyspace; fail-soft empty).
|
||||||
var alerts = await _tracker.GetVerdictCalibrationAlertsAsync(partition);
|
var alerts = await _tracker.GetVerdictCalibrationAlertsAsync(partition);
|
||||||
|
|
||||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
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;
|
return response;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -77,8 +77,12 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Pure response builder — internal static so the matrix arithmetic is pinned by tests.</summary>
|
/// <summary>
|
||||||
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.
|
||||||
|
/// </summary>
|
||||||
|
internal static class VerdictCalibrationResponseBuilder
|
||||||
{
|
{
|
||||||
internal const int DefaultWindowDays = 30;
|
internal const int DefaultWindowDays = 30;
|
||||||
internal const int MaxWindowDays = 180; // aggregate retention
|
internal const int MaxWindowDays = 180; // aggregate retention
|
||||||
@@ -95,37 +99,10 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
|||||||
/// <summary>"Last N days" = exactly N calendar day keys including today (both range ends inclusive).</summary>
|
/// <summary>"Last N days" = exactly N calendar day keys including today (both range ends inclusive).</summary>
|
||||||
internal static DateTime InclusiveWindowStart(DateTime today, int days) => today.AddDays(-(days - 1));
|
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; }
|
|
||||||
/// <summary>Null below the minimum eligible count — never a rate on a handful of sessions.</summary>
|
|
||||||
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();
|
|
||||||
/// <summary>Window share ÷ baseline share; null when the baseline share is 0 (a new path has no finite lift — never invented).</summary>
|
|
||||||
public double? Lift { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
internal sealed class TrendWindow
|
|
||||||
{
|
|
||||||
public int Count { get; set; }
|
|
||||||
public int Sessions { get; set; }
|
|
||||||
public double SharePct { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Minimum eligible sessions before a re-enrollment rate is stated (truthfulness: no rate on n<20).</summary>
|
/// <summary>Minimum eligible sessions before a re-enrollment rate is stated (truthfulness: no rate on n<20).</summary>
|
||||||
internal const int MinEligibleForRate = 20;
|
internal const int MinEligibleForRate = 20;
|
||||||
|
|
||||||
internal static object Build(
|
internal static VerdictCalibrationResponse Build(
|
||||||
IReadOnlyList<VerdictCalibrationDailyAggregate> daily, string partition, DateTime today, int days,
|
IReadOnlyList<VerdictCalibrationDailyAggregate> daily, string partition, DateTime today, int days,
|
||||||
IReadOnlyList<VerdictCalibrationAlert>? alerts = null)
|
IReadOnlyList<VerdictCalibrationAlert>? alerts = null)
|
||||||
{
|
{
|
||||||
@@ -133,18 +110,18 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
|||||||
var trendWindowStart = Key(InclusiveWindowStart(today, TrendWindowDays));
|
var trendWindowStart = Key(InclusiveWindowStart(today, TrendWindowDays));
|
||||||
var baselineStart = Key(InclusiveWindowStart(today, TrendHorizonDays));
|
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 sessions = 0, terminal = 0, derived = 0;
|
||||||
int trendSessions = 0, baselineSessions = 0;
|
int trendSessions = 0, baselineSessions = 0;
|
||||||
DateTime? computedAt = null;
|
DateTime? computedAt = null;
|
||||||
var versions = new HashSet<int>();
|
var versions = new HashSet<int>();
|
||||||
|
|
||||||
PathRow Row(VerdictCalibrationBucket b)
|
VerdictCalibrationPathRow Row(VerdictCalibrationBucket b)
|
||||||
{
|
{
|
||||||
var key = (b.VerdictPath, b.Status);
|
var key = (b.VerdictPath, b.Status);
|
||||||
if (!rows.TryGetValue(key, out var row))
|
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;
|
rows[key] = row;
|
||||||
}
|
}
|
||||||
return row;
|
return row;
|
||||||
@@ -206,20 +183,32 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
|||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new
|
return new VerdictCalibrationResponse
|
||||||
{
|
{
|
||||||
success = true,
|
Success = true,
|
||||||
tenantId = partition,
|
TenantId = partition,
|
||||||
windowDays = days,
|
WindowDays = days,
|
||||||
windowStart,
|
WindowStart = windowStart,
|
||||||
windowEnd = Key(today),
|
WindowEnd = Key(today),
|
||||||
computedAt,
|
ComputedAt = computedAt,
|
||||||
versions = versions.OrderBy(v => v).ToArray(),
|
Versions = versions.OrderBy(v => v).ToArray(),
|
||||||
totals = new { sessions, terminal, derived, days = daily.Count(d => string.CompareOrdinal(d.Date, windowStart) >= 0) },
|
Totals = new VerdictCalibrationTotals
|
||||||
trend = new { windowDays = TrendWindowDays, baselineDays = TrendBaselineDays, windowSessions = trendSessions, baselineSessions },
|
{
|
||||||
paths = list,
|
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.
|
// Wording contract shared with the rule radar: a dimension is correlated, not causal.
|
||||||
alerts = (alerts ?? Array.Empty<VerdictCalibrationAlert>())
|
Alerts = (alerts ?? Array.Empty<VerdictCalibrationAlert>())
|
||||||
.OrderByDescending(a => a.FirstNotifiedAt)
|
.OrderByDescending(a => a.FirstNotifiedAt)
|
||||||
.ToList(),
|
.ToList(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using AutopilotMonitor.Functions.Helpers;
|
using AutopilotMonitor.Functions.Helpers;
|
||||||
using AutopilotMonitor.Shared.DataAccess;
|
using AutopilotMonitor.Shared.DataAccess;
|
||||||
|
using AutopilotMonitor.Shared.Models;
|
||||||
using Microsoft.Azure.Functions.Worker;
|
using Microsoft.Azure.Functions.Worker;
|
||||||
using Microsoft.Azure.Functions.Worker.Http;
|
using Microsoft.Azure.Functions.Worker.Http;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@@ -29,7 +30,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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).
|
/// Returns rule stats for the caller's tenant (MemberRead).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Function("GetRuleStats")]
|
[Function("GetRuleStats")]
|
||||||
@@ -48,55 +49,16 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
|||||||
tenantId, startDate, endDate);
|
tenantId, startDate, endDate);
|
||||||
|
|
||||||
var entries = await _metricsRepo.GetRuleStatsAsync(tenantId, startDate, endDate, ruleType);
|
var entries = await _metricsRepo.GetRuleStatsAsync(tenantId, startDate, endDate, ruleType);
|
||||||
|
var aggregated = BuildRuleAggregates(entries);
|
||||||
|
|
||||||
// Aggregate across dates per ruleId
|
var result = new RuleStatsResponse
|
||||||
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
|
|
||||||
{
|
{
|
||||||
rules = aggregated,
|
Rules = aggregated,
|
||||||
// F3 PR6: active regression episodes (tracker rows) — the rules-page badge
|
// F3 PR6: active regression episodes (tracker rows) — the rules-page badge
|
||||||
// and MCP get_rule_stats read them from here. Empty list = nothing regressed.
|
// and MCP get_rule_stats read them from here. Empty list = nothing regressed.
|
||||||
regressions = await _notificationTracker.GetRuleRegressionsAsync(tenantId),
|
Regressions = await _notificationTracker.GetRuleRegressionsAsync(tenantId),
|
||||||
summary = new
|
// The tenant route historically carries no uniqueRules key — keep it absent.
|
||||||
{
|
Summary = BuildSummary(aggregated, startDate, endDate, uniqueRules: null),
|
||||||
totalEvaluations,
|
|
||||||
totalFires,
|
|
||||||
overallHitRate = totalEvaluations > 0
|
|
||||||
? Math.Round(100.0 * totalFires / totalEvaluations, 1)
|
|
||||||
: 0.0,
|
|
||||||
topRuleByFireCount = aggregated.FirstOrDefault()?.ruleId,
|
|
||||||
period = new { start = startDate, end = endDate }
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||||
@@ -113,7 +75,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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).
|
/// Returns cross-tenant global rule stats (Global Admin only).
|
||||||
/// When tenantId is provided, returns tenant-specific stats instead of global aggregates.
|
/// When tenantId is provided, returns tenant-specific stats instead of global aggregates.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -136,57 +98,17 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
|||||||
queryTenantId, startDate, endDate);
|
queryTenantId, startDate, endDate);
|
||||||
|
|
||||||
var entries = await _metricsRepo.GetRuleStatsAsync(queryTenantId, startDate, endDate, ruleType);
|
var entries = await _metricsRepo.GetRuleStatsAsync(queryTenantId, startDate, endDate, ruleType);
|
||||||
|
var aggregated = BuildRuleAggregates(entries);
|
||||||
|
|
||||||
var aggregated = entries
|
var result = new RuleStatsResponse
|
||||||
.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
|
|
||||||
{
|
{
|
||||||
rules = aggregated,
|
Rules = aggregated,
|
||||||
// Regression episodes are tenant-scoped: present when drilling into one
|
// Regression episodes are tenant-scoped: present when drilling into one
|
||||||
// tenant, empty for the cross-tenant "global" aggregate (no episode there).
|
// tenant, empty for the cross-tenant "global" aggregate (no episode there).
|
||||||
regressions = queryTenantId != "global"
|
Regressions = queryTenantId != "global"
|
||||||
? await _notificationTracker.GetRuleRegressionsAsync(queryTenantId)
|
? await _notificationTracker.GetRuleRegressionsAsync(queryTenantId)
|
||||||
: new List<AutopilotMonitor.Shared.Models.RuleRegressionAlert>(),
|
: new List<RuleRegressionAlert>(),
|
||||||
summary = new
|
Summary = BuildSummary(aggregated, startDate, endDate, uniqueRules: aggregated.Count),
|
||||||
{
|
|
||||||
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 }
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||||
@@ -201,5 +123,53 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
|||||||
return errorResponse;
|
return errorResponse;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Aggregate the per-date entries across dates per ruleId, fires descending.</summary>
|
||||||
|
internal static List<RuleStatsRuleAggregate> BuildRuleAggregates(IReadOnlyList<RuleStatsEntry> 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<RuleStatsRuleAggregate> 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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Net;
|
|||||||
using AutopilotMonitor.Functions.Helpers;
|
using AutopilotMonitor.Functions.Helpers;
|
||||||
using AutopilotMonitor.Functions.Services;
|
using AutopilotMonitor.Functions.Services;
|
||||||
using AutopilotMonitor.Functions.Services.Deletion;
|
using AutopilotMonitor.Functions.Services.Deletion;
|
||||||
|
using AutopilotMonitor.Shared.Models;
|
||||||
using AutopilotMonitor.Shared.Models.Deletion;
|
using AutopilotMonitor.Shared.Models.Deletion;
|
||||||
using Microsoft.Azure.Functions.Worker;
|
using Microsoft.Azure.Functions.Worker;
|
||||||
using Microsoft.Azure.Functions.Worker.Http;
|
using Microsoft.Azure.Functions.Worker.Http;
|
||||||
@@ -172,12 +173,12 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
internal static object BuildV2ResponseBody(SessionDeletionEnqueueResult result, string sessionId) => result.Outcome switch
|
internal static object BuildV2ResponseBody(SessionDeletionEnqueueResult result, string sessionId) => result.Outcome switch
|
||||||
{
|
{
|
||||||
SessionDeletionEnqueueOutcome.Enqueued => new
|
SessionDeletionEnqueueOutcome.Enqueued => new SessionDeletionQueuedResponse
|
||||||
{
|
{
|
||||||
success = true,
|
Success = true,
|
||||||
status = "queued",
|
Status = "queued",
|
||||||
manifestId = result.ManifestId,
|
ManifestId = result.ManifestId,
|
||||||
message = "Cascade deletion queued; worker will drain asynchronously.",
|
Message = "Cascade deletion queued; worker will drain asynchronously.",
|
||||||
},
|
},
|
||||||
SessionDeletionEnqueueOutcome.AlreadyInFlight => new
|
SessionDeletionEnqueueOutcome.AlreadyInFlight => new
|
||||||
{
|
{
|
||||||
|
|||||||
+13
-12
@@ -5,6 +5,7 @@ using System.Threading.Tasks;
|
|||||||
using AutopilotMonitor.Functions.Helpers;
|
using AutopilotMonitor.Functions.Helpers;
|
||||||
using AutopilotMonitor.Functions.Services.Deletion;
|
using AutopilotMonitor.Functions.Services.Deletion;
|
||||||
using AutopilotMonitor.Shared.DataAccess;
|
using AutopilotMonitor.Shared.DataAccess;
|
||||||
|
using AutopilotMonitor.Shared.Models;
|
||||||
using Microsoft.Azure.Functions.Worker;
|
using Microsoft.Azure.Functions.Worker;
|
||||||
using Microsoft.Azure.Functions.Worker.Http;
|
using Microsoft.Azure.Functions.Worker.Http;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@@ -170,19 +171,19 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
|||||||
{
|
{
|
||||||
var status = MapOutcomeToStatus(result.Outcome);
|
var status = MapOutcomeToStatus(result.Outcome);
|
||||||
|
|
||||||
var body = new
|
var body = new SessionRestoreResponse
|
||||||
{
|
{
|
||||||
success = status == HttpStatusCode.OK,
|
Success = status == HttpStatusCode.OK,
|
||||||
outcome = result.Outcome.ToString(),
|
Outcome = result.Outcome.ToString(),
|
||||||
mode = result.Mode,
|
Mode = result.Mode,
|
||||||
message = result.Message,
|
Message = result.Message,
|
||||||
currentState = result.CurrentState,
|
CurrentState = result.CurrentState,
|
||||||
pendingManifestId = result.PendingManifestId,
|
PendingManifestId = result.PendingManifestId,
|
||||||
rowsRestoredByTable = result.RowsRestoredByTable,
|
RowsRestoredByTable = result.RowsRestoredByTable,
|
||||||
rowsSkippedByTable = result.RowsSkippedByTable,
|
RowsSkippedByTable = result.RowsSkippedByTable,
|
||||||
wouldRestoreByTable = result.WouldRestoreByTable,
|
WouldRestoreByTable = result.WouldRestoreByTable,
|
||||||
inventoryReIncrements = result.InventoryReIncrements,
|
InventoryReIncrements = result.InventoryReIncrements,
|
||||||
durationMs = result.DurationMs,
|
DurationMs = result.DurationMs,
|
||||||
};
|
};
|
||||||
|
|
||||||
return await WriteJsonAsync(req, status, body);
|
return await WriteJsonAsync(req, status, body);
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ public static class MetricsMath
|
|||||||
/// final status. Peer bytes and Microsoft Connected Cache (MCC) bytes are reported separately —
|
/// 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".
|
/// MCC is counted apart from peers by DO — and offload% credits both as "not pulled from the CDN".
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static object BuildAppMetricsPayload(IEnumerable<AppInstallSummary> summaries)
|
public static AppMetricsResponse BuildAppMetricsPayload(IEnumerable<AppInstallSummary> summaries)
|
||||||
{
|
{
|
||||||
var summaryList = summaries as IList<AppInstallSummary> ?? summaries.ToList();
|
var summaryList = summaries as IList<AppInstallSummary> ?? summaries.ToList();
|
||||||
|
|
||||||
@@ -101,65 +101,66 @@ public static class MetricsMath
|
|||||||
// row reports source bytes but no DoTotalBytesDownloaded — so that telemetry is not lost.
|
// row reports source bytes but no DoTotalBytesDownloaded — so that telemetry is not lost.
|
||||||
var doG = DoAggregator.Compute(g);
|
var doG = DoAggregator.Compute(g);
|
||||||
|
|
||||||
return new
|
return new AppMetricsAppGroup
|
||||||
{
|
{
|
||||||
appName = g.Key,
|
AppName = g.Key,
|
||||||
totalInstalls = total,
|
TotalInstalls = total,
|
||||||
succeeded = installed.Count,
|
Succeeded = installed.Count,
|
||||||
skipped = skipped.Count,
|
Skipped = skipped.Count,
|
||||||
unmeasured = installed.Count - measured.Count,
|
Unmeasured = installed.Count - measured.Count,
|
||||||
failed = failed.Count,
|
Failed = failed.Count,
|
||||||
// Skips leave the rate (they are not attempts); legacy rows stay in it.
|
// Skips leave the rate (they are not attempts); legacy rows stay in it.
|
||||||
failureRate = TerminalFailureRatePct(failed.Count, installed.Count),
|
FailureRate = TerminalFailureRatePct(failed.Count, installed.Count),
|
||||||
avgDurationSeconds = measured.Count > 0 ? Math.Round(measured.Average(s => s.DurationSeconds), 0) : 0,
|
AvgDurationSeconds = measured.Count > 0 ? Math.Round(measured.Average(s => s.DurationSeconds), 0) : 0,
|
||||||
maxDurationSeconds = measured.Count > 0 ? measured.Max(s => s.DurationSeconds) : 0,
|
MaxDurationSeconds = measured.Count > 0 ? measured.Max(s => s.DurationSeconds) : 0,
|
||||||
measuredInstalls = measured.Count,
|
MeasuredInstalls = measured.Count,
|
||||||
avgDownloadBytes = measured.Count > 0 ? (long)measured.Average(s => s.DownloadBytes) : 0,
|
AvgDownloadBytes = measured.Count > 0 ? (long)measured.Average(s => s.DownloadBytes) : 0,
|
||||||
doTotalBytesDownloaded = doG.TotalBytesDownloaded,
|
DoTotalBytesDownloaded = doG.TotalBytesDownloaded,
|
||||||
doBytesFromPeers = doG.BytesFromPeers,
|
DoBytesFromPeers = doG.BytesFromPeers,
|
||||||
doBytesFromCacheServer = doG.BytesFromCacheServer,
|
DoBytesFromCacheServer = doG.BytesFromCacheServer,
|
||||||
doBytesFromHttp = doG.BytesFromHttp,
|
DoBytesFromHttp = doG.BytesFromHttp,
|
||||||
peerOffloadPercent = OffloadPercent(doG.BytesFromPeers + doG.BytesFromCacheServer, doG.TotalBytesDownloaded),
|
PeerOffloadPercent = OffloadPercent(doG.BytesFromPeers + doG.BytesFromCacheServer, doG.TotalBytesDownloaded),
|
||||||
topFailureCodes = failed
|
TopFailureCodes = failed
|
||||||
.Where(f => !string.IsNullOrEmpty(f.FailureCode))
|
.Where(f => !string.IsNullOrEmpty(f.FailureCode))
|
||||||
.GroupBy(f => f.FailureCode)
|
.GroupBy(f => f.FailureCode)
|
||||||
.OrderByDescending(fc => fc.Count())
|
.OrderByDescending(fc => fc.Count())
|
||||||
.Take(3)
|
.Take(3)
|
||||||
.Select(fc => new { code = fc.Key, count = fc.Count() })
|
.Select(fc => new AppFailureCodeCount { Code = fc.Key, Count = fc.Count() })
|
||||||
|
.ToList()
|
||||||
};
|
};
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
// Slowest ranking gates on MEASURED installs — an app whose durations were mostly
|
// 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).
|
// unobserved must not rank as "fast" on a handful of zeros (audit §0.5).
|
||||||
var slowestApps = SelectSlowestApps(
|
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
|
var topFailingApps = appGroups
|
||||||
.Where(a => a.failed > 0)
|
.Where(a => a.Failed > 0)
|
||||||
.OrderByDescending(a => a.failed)
|
.OrderByDescending(a => a.Failed)
|
||||||
.ThenByDescending(a => a.failureRate)
|
.ThenByDescending(a => a.FailureRate)
|
||||||
.Take(10)
|
.Take(10)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var doAll = DoAggregator.Compute(summaryList);
|
var doAll = DoAggregator.Compute(summaryList);
|
||||||
|
|
||||||
return new
|
return new AppMetricsResponse
|
||||||
{
|
{
|
||||||
success = true,
|
Success = true,
|
||||||
totalApps = appGroups.Count,
|
TotalApps = appGroups.Count,
|
||||||
totalInstalls = summaryList.Count,
|
TotalInstalls = summaryList.Count,
|
||||||
totalSkipped = appGroups.Sum(a => a.skipped),
|
TotalSkipped = appGroups.Sum(a => a.Skipped),
|
||||||
totalUnmeasured = appGroups.Sum(a => a.unmeasured),
|
TotalUnmeasured = appGroups.Sum(a => a.Unmeasured),
|
||||||
totalCollisionExcluded,
|
TotalCollisionExcluded = totalCollisionExcluded,
|
||||||
slowestApps,
|
SlowestApps = slowestApps,
|
||||||
topFailingApps,
|
TopFailingApps = topFailingApps,
|
||||||
deliveryOptimization = new
|
DeliveryOptimization = new AppMetricsDeliveryOptimization
|
||||||
{
|
{
|
||||||
totalBytesDownloaded = doAll.TotalBytesDownloaded,
|
TotalBytesDownloaded = doAll.TotalBytesDownloaded,
|
||||||
fromPeers = doAll.BytesFromPeers,
|
FromPeers = doAll.BytesFromPeers,
|
||||||
fromCacheServer = doAll.BytesFromCacheServer,
|
FromCacheServer = doAll.BytesFromCacheServer,
|
||||||
fromHttp = doAll.BytesFromHttp,
|
FromHttp = doAll.BytesFromHttp,
|
||||||
peerOffloadPercent = OffloadPercent(doAll.BytesFromPeers + doAll.BytesFromCacheServer, doAll.TotalBytesDownloaded),
|
PeerOffloadPercent = OffloadPercent(doAll.BytesFromPeers + doAll.BytesFromCacheServer, doAll.TotalBytesDownloaded),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ namespace AutopilotMonitor.Functions.Services
|
|||||||
// Shallow copy so flipping FromCache never mutates the instance an earlier
|
// Shallow copy so flipping FromCache never mutates the instance an earlier
|
||||||
// (fresh) caller may still be holding/serializing. The buckets are shared
|
// (fresh) caller may still be holding/serializing. The buckets are shared
|
||||||
// read-only — nothing downstream writes into a response.
|
// read-only — nothing downstream writes into a response.
|
||||||
return entry.metrics.CloneAsCacheHit();
|
return CloneAsCacheHit(entry.metrics);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,6 +287,21 @@ namespace AutopilotMonitor.Functions.Services
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Shallow copy flagged as a cache hit — see the cache-read path.</summary>
|
||||||
|
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<double> values)
|
private static PercentileStats? BuildStats(IEnumerable<double> values)
|
||||||
{
|
{
|
||||||
var sorted = values.OrderBy(v => v).ToList();
|
var sorted = values.OrderBy(v => v).ToList();
|
||||||
@@ -303,76 +318,7 @@ namespace AutopilotMonitor.Functions.Services
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Response DTOs ────────────────────────────────────────────────────────────
|
// Response DTOs (AgentEfficiencyMetricsResponse family) moved to
|
||||||
|
// AutopilotMonitor.Shared.Models (Models/Metrics/AgentPerformanceMetrics.cs) so the
|
||||||
public class AgentEfficiencyMetricsResponse
|
// shared manifest exports them as wire types.
|
||||||
{
|
|
||||||
public int WindowDays { get; set; }
|
|
||||||
public int SessionLimit { get; set; }
|
|
||||||
/// <summary>Echo of the requested tenant filter; null = cross-tenant aggregate.</summary>
|
|
||||||
public string? TenantId { get; set; }
|
|
||||||
/// <summary>Sessions the scan covered — compare against <see cref="SessionLimit"/> for truncation.</summary>
|
|
||||||
public int SessionsScanned { get; set; }
|
|
||||||
public int SessionsWithSnapshots { get; set; }
|
|
||||||
public List<AgentVersionEfficiency> ByVersion { get; set; } = new();
|
|
||||||
public AgentVersionEfficiency? Overall { get; set; }
|
|
||||||
public DateTime ComputedAt { get; set; }
|
|
||||||
public int ComputeDurationMs { get; set; }
|
|
||||||
public bool FromCache { get; set; }
|
|
||||||
|
|
||||||
/// <summary>Shallow copy flagged as a cache hit — see the cache-read path.</summary>
|
|
||||||
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
|
|
||||||
{
|
|
||||||
/// <summary>Null on the cross-version "overall" bucket (omitted on the wire).</summary>
|
|
||||||
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<EfficiencyOffender>? TopOffenders { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Distribution of a per-session statistic across a version bucket.</summary>
|
|
||||||
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; }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using AutopilotMonitor.Shared.Models;
|
||||||
|
|
||||||
namespace AutopilotMonitor.Functions.Services
|
namespace AutopilotMonitor.Functions.Services
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -328,81 +328,7 @@ namespace AutopilotMonitor.Functions.Services
|
|||||||
=> AgentMetricsAggregation.GetString(data, key);
|
=> AgentMetricsAggregation.GetString(data, key);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Response DTOs ────────────────────────────────────────────────────────────
|
// Response DTOs (PlatformAgentMetricsResponse family) moved to
|
||||||
|
// AutopilotMonitor.Shared.Models (Models/Metrics/AgentPerformanceMetrics.cs) so the
|
||||||
public class PlatformAgentMetricsResponse
|
// shared manifest exports them as wire types.
|
||||||
{
|
|
||||||
public List<SessionAgentMetric> 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; }
|
|
||||||
/// <summary>
|
|
||||||
/// Sessions the scan actually covered in the window (before the has-snapshots filter
|
|
||||||
/// that shapes <see cref="Sessions"/>). Callers must compare THIS against
|
|
||||||
/// <see cref="SessionLimit"/> to decide whether the window was truncated —
|
|
||||||
/// <c>Sessions.Count</c> understates truncation on fleets where many sessions
|
|
||||||
/// emit no agent_metrics_snapshot.
|
|
||||||
/// </summary>
|
|
||||||
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<CrashExceptionSummary> TopExceptions { get; set; } = new();
|
|
||||||
}
|
|
||||||
|
|
||||||
public class CrashExceptionSummary
|
|
||||||
{
|
|
||||||
public string ExceptionType { get; set; } = string.Empty;
|
|
||||||
public int Count { get; set; }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -14,8 +14,12 @@ import type {
|
|||||||
GetMcpUserUsageResponse,
|
GetMcpUserUsageResponse,
|
||||||
GetMyMcpUsageResponse,
|
GetMyMcpUsageResponse,
|
||||||
GetUnmatchedSoftwareResponse,
|
GetUnmatchedSoftwareResponse,
|
||||||
|
PlatformAgentMetricsResponse,
|
||||||
QueryRawTableResponse,
|
QueryRawTableResponse,
|
||||||
|
RuleStatsResponse,
|
||||||
|
RuleStatsRuleAggregate,
|
||||||
SoftwareInventoryResponse,
|
SoftwareInventoryResponse,
|
||||||
|
VerdictCalibrationResponse,
|
||||||
} from '../generated/wire-types.generated.js';
|
} 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 () => {
|
async (args) => withToolTelemetry('get_platform_metrics', args, async () => {
|
||||||
try {
|
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
|
// 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
|
// 2000-session windows take longer than the default 30s client timeout — same
|
||||||
// override precedent as get_session_summary. Warm (cached) calls return instantly.
|
// override precedent as get_session_summary. Warm (cached) calls return instantly.
|
||||||
const raw = await apiFetch(
|
const raw = await apiFetch(
|
||||||
`/api/global/metrics/platform${buildQuery({ days: args.days, limit: args.maxSessions })}`,
|
`/api/global/metrics/platform${buildQuery({ days: args.days, limit: args.maxSessions })}`,
|
||||||
{ signal: AbortSignal.timeout(90_000) }) as
|
{ signal: AbortSignal.timeout(90_000) }) as PlatformAgentMetricsResponse;
|
||||||
{
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
const sessions = raw?.sessions ?? [];
|
const sessions = raw?.sessions ?? [];
|
||||||
const requested = { days: args.days, limit: args.maxSessions };
|
const requested = { days: args.days, limit: args.maxSessions };
|
||||||
// Backend-computed blocks that exist independently of snapshot availability
|
// 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 () => {
|
async (args) => withToolTelemetry('get_verdict_calibration', args, async () => {
|
||||||
try {
|
try {
|
||||||
const { tenantId, days, minSharePct, top } = args;
|
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);
|
return toolResultText(shapeVerdictCalibration(data, { minSharePct, top }), MAX_RESULT_SIZE_CHARS.adminStream);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
return toolError('get_verdict_calibration', args, error);
|
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;
|
if (tenantId) params.tenantId = tenantId;
|
||||||
const prefix = pickGlobalOrTenantPath('/api/global/metrics', '/api/metrics', tenantId);
|
const prefix = pickGlobalOrTenantPath('/api/global/metrics', '/api/metrics', tenantId);
|
||||||
const data = await apiFetch(`${prefix}/rule-stats${buildQuery(params)}`) as {
|
const data = await apiFetch(`${prefix}/rule-stats${buildQuery(params)}`) as
|
||||||
rules?: Array<Record<string, unknown>>;
|
RuleStatsResponse & { trendsNote?: string };
|
||||||
trendsNote?: string;
|
|
||||||
};
|
|
||||||
// The per-rule daily trend arrays are the size driver (one row per rule
|
// 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
|
// per day; a 30-day platform-scope response reaches 150k+ chars and gets
|
||||||
// host-truncated). Aggregates stay intact — trends only on request.
|
// 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)) {
|
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<RuleStatsRuleAggregate>).trend;
|
||||||
data.trendsNote = 'Per-rule daily trend rows omitted. Re-call with includeTrends=true (plus a narrow startDate/endDate window) to include them.';
|
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);
|
return toolResultText(data, MAX_RESULT_SIZE_CHARS.small);
|
||||||
|
|||||||
@@ -12,21 +12,23 @@
|
|||||||
* in `omitted` so a filtered response never reads as complete coverage.
|
* in `omitted` so a filtered response never reads as complete coverage.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface TrendCell {
|
import type {
|
||||||
count: number;
|
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<WireTrendWindow, 'sessions'> {
|
||||||
|
/** Dropped in shaped output — the denominators live once in `trend`. */
|
||||||
sessions?: number;
|
sessions?: number;
|
||||||
sharePct: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VerdictCalibrationPathRow {
|
export interface VerdictCalibrationPathRow
|
||||||
verdictPath: string;
|
extends Omit<WirePathRow, 'reEnrollRatePct' | 'lift' | 'window7' | 'baseline28'> {
|
||||||
status: string;
|
/** Explicit null in shaped output (wire omits the key when withheld). */
|
||||||
count: number;
|
|
||||||
sharePct: number;
|
|
||||||
overriddenByAdmin: number;
|
|
||||||
overriddenByLateCompletion: number;
|
|
||||||
overriddenOther: number;
|
|
||||||
reEnrollRatePct?: number | null;
|
reEnrollRatePct?: number | null;
|
||||||
|
/** Explicit null in shaped output (wire omits the key when withheld). */
|
||||||
lift?: number | null;
|
lift?: number | null;
|
||||||
window7: TrendCell;
|
window7: TrendCell;
|
||||||
baseline28: TrendCell;
|
baseline28: TrendCell;
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace AutopilotMonitor.Shared.Models.Backup
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Response of <c>GET global/backups</c>: every backupId in the critical-table-backups
|
||||||
|
/// container, newest first. Serialized with <see cref="BackupManifestJson.SerializerOptions"/>
|
||||||
|
/// (the backup surface's own options), not the ApiJsonOptions pipeline.
|
||||||
|
/// </summary>
|
||||||
|
public class ListBackupsResponse : IApiResponse
|
||||||
|
{
|
||||||
|
public IReadOnlyList<string> BackupIds { get; set; } = default!;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace AutopilotMonitor.Shared.Models
|
||||||
|
{
|
||||||
|
// Declaration order == wire order.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Response of <c>GET config/{tenantId}/feature-flags</c>: 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).
|
||||||
|
/// </summary>
|
||||||
|
public class TenantFeatureFlagsResponse : IApiResponse
|
||||||
|
{
|
||||||
|
/// <summary>EFFECTIVE bootstrap availability (Pro includes it; the GA flag is the additive Community enable) — field name kept for web compatibility.</summary>
|
||||||
|
public bool BootstrapTokenEnabled { get; set; }
|
||||||
|
/// <summary>Whether an on-demand diagnostics upload can succeed right now (mode not Off + usable destination). Deliberately no destination detail.</summary>
|
||||||
|
public bool DiagnosticsUploadConfigured { get; set; }
|
||||||
|
/// <summary>Drives the "Autopilot Device Validation disabled" dashboard banner.</summary>
|
||||||
|
public bool ValidateAutopilotDevice { get; set; }
|
||||||
|
/// <summary>Dual app-reg self-service migration: consent flow targets the NEW app registration. Non-sensitive — exposes no client ids.</summary>
|
||||||
|
public bool AppHomingFunnelActive { get; set; }
|
||||||
|
public bool ShowScriptOutput { get; set; }
|
||||||
|
public bool EnableSoftwareInventoryAnalyzer { get; set; }
|
||||||
|
public bool EnableIntegrityBypassAnalyzer { get; set; }
|
||||||
|
/// <summary>EFFECTIVE gather-rule unrestricted mode (Pro edition + GA gate + tenant toggle).</summary>
|
||||||
|
public bool UnrestrictedMode { get; set; }
|
||||||
|
/// <summary>Resolved edition, lowercase ("community" / "pro").</summary>
|
||||||
|
public string Edition { get; set; } = string.Empty;
|
||||||
|
public bool IsTrial { get; set; }
|
||||||
|
/// <summary>Absent unless the tenant is on an active trial.</summary>
|
||||||
|
public DateTime? TrialExpiresUtc { get; set; }
|
||||||
|
public bool TrialAvailable { get; set; }
|
||||||
|
/// <summary>Whether a contact address is stored (boolean only — the address stays admin-gated).</summary>
|
||||||
|
public bool ContactEmailSet { get; set; }
|
||||||
|
public TenantFeatureEntitlements Entitlements { get; set; } = default!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Read-time entitlement surface of the resolved edition.</summary>
|
||||||
|
public class TenantFeatureEntitlements
|
||||||
|
{
|
||||||
|
public int RetentionCapDays { get; set; }
|
||||||
|
/// <summary>Absent when the platform default applies.</summary>
|
||||||
|
public int? UserRateLimitPerMinute { get; set; }
|
||||||
|
public bool DelegatedAdminAllowed { get; set; }
|
||||||
|
public string McpUsagePlan { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace AutopilotMonitor.Shared.Models
|
||||||
|
{
|
||||||
|
// Declaration order == wire order.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Response of <c>GET diagnostics/paths</c>: 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.
|
||||||
|
/// </summary>
|
||||||
|
public class DiagnosticsPathsResponse : IApiResponse
|
||||||
|
{
|
||||||
|
public IReadOnlyList<DiagnosticsBuiltInSectionWire> BuiltIn { get; set; } = default!;
|
||||||
|
public IReadOnlyList<DiagnosticsLogPath> GlobalPaths { get; set; } = default!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wire projection of one built-in diagnostics section. <c>Condition</c> travels as the
|
||||||
|
/// enum NAME ("Always" | "RealmJoinWatcher" | "DevicePreparation"), never the integer —
|
||||||
|
/// the web switches on the string.
|
||||||
|
/// </summary>
|
||||||
|
public class DiagnosticsBuiltInSectionWire
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
public string ZipFolder { get; set; } = string.Empty;
|
||||||
|
/// <summary>UNEXPANDED source folder (may contain %ProgramData% or the user-profile token).</summary>
|
||||||
|
public string SourceFolder { get; set; } = string.Empty;
|
||||||
|
public IReadOnlyList<string> Patterns { get; set; } = default!;
|
||||||
|
public bool IncludeSubfolders { get; set; }
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
public string Condition { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace AutopilotMonitor.Shared.Models
|
||||||
|
{
|
||||||
|
// Declaration order == wire order.
|
||||||
|
|
||||||
|
/// <summary>Response of GET feedback/eligibility: whether the caller should be shown the feedback prompt.</summary>
|
||||||
|
public class FeedbackEligibilityResponse : IApiResponse
|
||||||
|
{
|
||||||
|
public bool Eligible { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Response of GET feedback/all (Global Admin dashboard): every stored feedback entry.</summary>
|
||||||
|
public class FeedbackListResponse : IApiResponse
|
||||||
|
{
|
||||||
|
public IReadOnlyList<FeedbackEntryWire> Feedback { get; set; } = default!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One feedback interaction as rendered on the Global Admin dashboard.</summary>
|
||||||
|
public class FeedbackEntryWire
|
||||||
|
{
|
||||||
|
public string? Type { get; set; }
|
||||||
|
public string? Upn { get; set; }
|
||||||
|
public string? TenantId { get; set; }
|
||||||
|
public string? DisplayName { get; set; }
|
||||||
|
/// <summary>Absent on dismissals.</summary>
|
||||||
|
public int? Rating { get; set; }
|
||||||
|
/// <summary>Absent on dismissals.</summary>
|
||||||
|
public string? Comment { get; set; }
|
||||||
|
public bool Dismissed { get; set; }
|
||||||
|
public bool Submitted { get; set; }
|
||||||
|
/// <summary>ISO-8601 round-trip string; absent when never stamped.</summary>
|
||||||
|
public string? InteractedAt { get; set; }
|
||||||
|
public string? HistoryRowKey { get; set; }
|
||||||
|
public string? DomainName { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ namespace AutopilotMonitor.Shared.Models
|
|||||||
/// Presentation-only derivations (bar-chart maxima, weekday/month axis labels) stay on the
|
/// Presentation-only derivations (bar-chart maxima, weekday/month axis labels) stay on the
|
||||||
/// client; this payload carries the data, not the formatting.
|
/// client; this payload carries the data, not the formatting.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class FleetHealthMetrics
|
public sealed class FleetHealthMetrics : IApiResponse
|
||||||
{
|
{
|
||||||
public bool Success { get; set; } = true;
|
public bool Success { get; set; } = true;
|
||||||
public int Days { get; set; }
|
public int Days { get; set; }
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ namespace AutopilotMonitor.Shared.Models
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Response containing geographic performance metrics aggregated by location.
|
/// Response containing geographic performance metrics aggregated by location.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class GeographicMetricsResponse
|
public class GeographicMetricsResponse : IApiResponse
|
||||||
{
|
{
|
||||||
public bool Success { get; set; }
|
public bool Success { get; set; }
|
||||||
public List<LocationMetrics> Locations { get; set; } = new();
|
public List<LocationMetrics> Locations { get; set; } = new();
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ namespace AutopilotMonitor.Shared.Models
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Response of <c>GET metrics/ime-pattern-health</c>.</summary>
|
/// <summary>Response of <c>GET metrics/ime-pattern-health</c>.</summary>
|
||||||
public class ImePatternHealthResponse
|
public class ImePatternHealthResponse : IApiResponse
|
||||||
{
|
{
|
||||||
public string? BaselineVersion { get; set; }
|
public string? BaselineVersion { get; set; }
|
||||||
public int MinBaselineSessions { get; set; }
|
public int MinBaselineSessions { get; set; }
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
/// <summary>Response of <c>GET global/metrics/platform</c> (GetGlobalPlatformMetrics).</summary>
|
||||||
|
public class PlatformAgentMetricsResponse : IApiResponse
|
||||||
|
{
|
||||||
|
public List<SessionAgentMetric> 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; }
|
||||||
|
/// <summary>
|
||||||
|
/// Sessions the scan actually covered in the window (before the has-snapshots filter
|
||||||
|
/// that shapes <see cref="Sessions"/>). Callers must compare THIS against
|
||||||
|
/// <see cref="SessionLimit"/> to decide whether the window was truncated —
|
||||||
|
/// <c>Sessions.Count</c> understates truncation on fleets where many sessions
|
||||||
|
/// emit no agent_metrics_snapshot.
|
||||||
|
/// </summary>
|
||||||
|
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<CrashExceptionSummary> TopExceptions { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CrashExceptionSummary
|
||||||
|
{
|
||||||
|
public string ExceptionType { get; set; } = string.Empty;
|
||||||
|
public int Count { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Response of <c>GET global/metrics/agent-efficiency</c> (GetGlobalAgentEfficiency).</summary>
|
||||||
|
public class AgentEfficiencyMetricsResponse : IApiResponse
|
||||||
|
{
|
||||||
|
public int WindowDays { get; set; }
|
||||||
|
public int SessionLimit { get; set; }
|
||||||
|
/// <summary>Echo of the requested tenant filter; null = cross-tenant aggregate.</summary>
|
||||||
|
public string? TenantId { get; set; }
|
||||||
|
/// <summary>Sessions the scan covered — compare against <see cref="SessionLimit"/> for truncation.</summary>
|
||||||
|
public int SessionsScanned { get; set; }
|
||||||
|
public int SessionsWithSnapshots { get; set; }
|
||||||
|
public List<AgentVersionEfficiency> 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
|
||||||
|
{
|
||||||
|
/// <summary>Null on the cross-version "overall" bucket (omitted on the wire).</summary>
|
||||||
|
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<EfficiencyOffender>? TopOffenders { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Distribution of a per-session statistic across a version bucket.</summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace AutopilotMonitor.Shared.Models
|
||||||
|
{
|
||||||
|
// Declaration order == wire order.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Response of <c>GET metrics/app</c> and <c>GET global/metrics/app</c>: per-app install
|
||||||
|
/// health over the requested window (slowest apps by average FINAL-attempt duration, top
|
||||||
|
/// failing apps) plus the fleet Delivery Optimization rollup.
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
/// <summary>Rows excluded from per-app groups because their name-keyed row merged distinct appIds.</summary>
|
||||||
|
public int TotalCollisionExcluded { get; set; }
|
||||||
|
public IReadOnlyList<AppMetricsAppGroup> SlowestApps { get; set; } = default!;
|
||||||
|
public IReadOnlyList<AppMetricsAppGroup> TopFailingApps { get; set; } = default!;
|
||||||
|
public AppMetricsDeliveryOptimization DeliveryOptimization { get; set; } = default!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One app's aggregate across its install rows in the window.</summary>
|
||||||
|
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; }
|
||||||
|
/// <summary>Failed / (failed + succeeded) as a percentage; skips never count as attempts.</summary>
|
||||||
|
public double FailureRate { get; set; }
|
||||||
|
/// <summary>Average measured FINAL-attempt duration (whole seconds); 0 with no measured installs.</summary>
|
||||||
|
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; }
|
||||||
|
/// <summary>Top 3 failure codes by count, descending.</summary>
|
||||||
|
public IReadOnlyList<AppFailureCodeCount> TopFailureCodes { get; set; } = default!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One failure code with its occurrence count.</summary>
|
||||||
|
public class AppFailureCodeCount
|
||||||
|
{
|
||||||
|
public string Code { get; set; } = string.Empty;
|
||||||
|
public int Count { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Fleet-wide Delivery Optimization rollup across every install row in the window.</summary>
|
||||||
|
public class AppMetricsDeliveryOptimization
|
||||||
|
{
|
||||||
|
public long TotalBytesDownloaded { get; set; }
|
||||||
|
public long FromPeers { get; set; }
|
||||||
|
public long FromCacheServer { get; set; }
|
||||||
|
public long FromHttp { get; set; }
|
||||||
|
/// <summary>Share of bytes not pulled from the CDN (peers + Microsoft Connected Cache), 0-100 one decimal.</summary>
|
||||||
|
public double PeerOffloadPercent { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Response of <c>GET apps/list</c> and <c>GET global/apps/list</c>. 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.
|
||||||
|
/// </summary>
|
||||||
|
public class AppsListResponse : IApiResponse
|
||||||
|
{
|
||||||
|
public bool Success { get; set; }
|
||||||
|
public int TotalApps { get; set; }
|
||||||
|
public int TotalInstalls { get; set; }
|
||||||
|
/// <summary>Rows excluded because their name-keyed row merged distinct appIds.</summary>
|
||||||
|
public int CollisionExcluded { get; set; }
|
||||||
|
public int WindowDays { get; set; }
|
||||||
|
/// <summary>Rows on this page; absent in legacy full-array mode.</summary>
|
||||||
|
public int? Count { get; set; }
|
||||||
|
/// <summary>Absent in legacy full-array mode.</summary>
|
||||||
|
public int? Offset { get; set; }
|
||||||
|
/// <summary>Absent in legacy full-array mode.</summary>
|
||||||
|
public int? PageSize { get; set; }
|
||||||
|
public IReadOnlyList<AppsListItem> Apps { get; set; } = default!;
|
||||||
|
/// <summary>Next-page link; absent in legacy mode and on the last page.</summary>
|
||||||
|
public string? NextLink { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One app row of the apps list (failed desc, then failure rate, then name).</summary>
|
||||||
|
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; }
|
||||||
|
/// <summary>"improving" | "worsening" | "stable".</summary>
|
||||||
|
public string Trend { get; set; } = string.Empty;
|
||||||
|
/// <summary>Failure-rate delta between window halves; absent when either half has under 5 finished installs.</summary>
|
||||||
|
public double? TrendDelta { get; set; }
|
||||||
|
public DateTime LastSeenAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Response of <c>GET apps/{appName}/analytics</c> and its global variant.</summary>
|
||||||
|
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; }
|
||||||
|
/// <summary>"day" (windows up to 30 days) or "week".</summary>
|
||||||
|
public string Bucket { get; set; } = string.Empty;
|
||||||
|
public AppAnalyticsSummary Summary { get; set; } = default!;
|
||||||
|
public IReadOnlyList<AppAnalyticsTimeBucket> TimeSeries { get; set; } = default!;
|
||||||
|
public IReadOnlyList<AppVersionBreakdownItem> VersionBreakdown { get; set; } = default!;
|
||||||
|
public IReadOnlyList<AppInstallerPhaseCount> InstallerPhaseBreakdown { get; set; } = default!;
|
||||||
|
public IReadOnlyList<AppAnalyticsFailureCode> TopFailureCodes { get; set; } = default!;
|
||||||
|
/// <summary>Succeeded installs whose detection re-check reported NotDetected.</summary>
|
||||||
|
public int DetectionLiesCount { get; set; }
|
||||||
|
public IReadOnlyList<AppDeviceModelBreakdownItem> DeviceModelBreakdown { get; set; } = default!;
|
||||||
|
/// <summary>Active duration-regression episodes for this app (tracker rows).</summary>
|
||||||
|
public IReadOnlyList<AppVersionRegressionAlert> VersionRegressions { get; set; } = default!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Headline aggregate of one app's analytics window.</summary>
|
||||||
|
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; }
|
||||||
|
/// <summary>"improving" | "worsening" | "stable".</summary>
|
||||||
|
public string Trend { get; set; } = string.Empty;
|
||||||
|
/// <summary>Absent when either window half has under 5 finished installs.</summary>
|
||||||
|
public double? TrendDelta { get; set; }
|
||||||
|
/// <summary>Share of installs with AttemptNumber > 1 (0-1, three decimals).</summary>
|
||||||
|
public double FlakinessScore { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One day/week bucket of the analytics time series.</summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Per-app-version aggregate (installs descending).</summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Failed installs per installer phase (descending).</summary>
|
||||||
|
public class AppInstallerPhaseCount
|
||||||
|
{
|
||||||
|
public string Phase { get; set; } = string.Empty;
|
||||||
|
public int Failed { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One of the top 5 failure codes of the analytics window.</summary>
|
||||||
|
public class AppAnalyticsFailureCode
|
||||||
|
{
|
||||||
|
public string Code { get; set; } = string.Empty;
|
||||||
|
/// <summary>First observed exit code; absent when none of the rows carried one.</summary>
|
||||||
|
public int? ExitCode { get; set; }
|
||||||
|
public int Count { get; set; }
|
||||||
|
public string SampleMessage { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Per device-model failure aggregate (lift vs the app's baseline rate, descending).</summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Response of <c>GET apps/{appName}/sessions</c> and its global variant (offset-paged).</summary>
|
||||||
|
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<AppSessionItem> Items { get; set; } = default!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One install row of the app-sessions drilldown (failed first, then in-progress, newest first).</summary>
|
||||||
|
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;
|
||||||
|
/// <summary>Absent when the install carried no exit code.</summary>
|
||||||
|
public int? ExitCode { get; set; }
|
||||||
|
public int AttemptNumber { get; set; }
|
||||||
|
public DateTime StartedAt { get; set; }
|
||||||
|
public int DurationSeconds { get; set; }
|
||||||
|
/// <summary>2+ = the IME processed this app in multiple passes (device-ESP evaluation + real install).</summary>
|
||||||
|
public int InstallPassCount { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Response of <c>GET metrics/time-attribution</c> and <c>GET global/metrics/time-attribution</c>:
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public class TimeAttributionMetricsResponse : IApiResponse
|
||||||
|
{
|
||||||
|
public bool Success { get; set; }
|
||||||
|
public int WindowDays { get; set; }
|
||||||
|
/// <summary>Range statistics per enrollment class (never mixed), class-name ordinal order.</summary>
|
||||||
|
public IReadOnlyList<TimeAttributionDailyAggregate> Classes { get; set; } = default!;
|
||||||
|
/// <summary>Daily rows of the window, date ordinal order.</summary>
|
||||||
|
public IReadOnlyList<TimeAttributionDailyAggregate> Daily { get; set; } = default!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Response of <c>GET metrics/device-journeys</c> and <c>GET global/metrics/device-journeys</c>:
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
public class DeviceJourneyMetricsResponse : IApiResponse
|
||||||
|
{
|
||||||
|
public bool Success { get; set; }
|
||||||
|
public int WindowDays { get; set; }
|
||||||
|
public DeviceJourneyWindowTotals Totals { get; set; } = default!;
|
||||||
|
/// <summary>Daily rows of the window, date ordinal order.</summary>
|
||||||
|
public IReadOnlyList<DeviceJourneyDailyAggregate> Daily { get; set; } = default!;
|
||||||
|
/// <summary>Devices whose current journey took at least 2 attempts; absent on the cross-tenant aggregate (no per-device drill there).</summary>
|
||||||
|
public IReadOnlyList<DeviceJourneyRepeatDevice>? RepeatDevices { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Window totals of the device-journey response (additive sums over the daily rows).</summary>
|
||||||
|
public class DeviceJourneyWindowTotals
|
||||||
|
{
|
||||||
|
public int CompletedJourneys { get; set; }
|
||||||
|
public int FirstTimeRight { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Null with zero completed journeys — no rate claim, never 0 (truthfulness rule 1).</summary>
|
||||||
|
public double? FtrRatePct { get; set; }
|
||||||
|
|
||||||
|
public int ExcludedSessions { get; set; }
|
||||||
|
public List<DeviceJourneyAttemptBucket> AttemptHistogram { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One repeat-device violator row (current journey took at least 2 attempts, newest terminal session in the window).</summary>
|
||||||
|
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; }
|
||||||
|
/// <summary>Failure reason of the newest failed attempt; empty when unavailable (fail-soft point-read).</summary>
|
||||||
|
public string LastFailureReason { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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).
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Response of <c>GET metrics/rule-stats</c> and <c>GET global/metrics/rule-stats</c>:
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public class RuleStatsResponse : IApiResponse
|
||||||
|
{
|
||||||
|
public IReadOnlyList<RuleStatsRuleAggregate> Rules { get; set; } = default!;
|
||||||
|
public IReadOnlyList<RuleRegressionAlert> Regressions { get; set; } = default!;
|
||||||
|
public RuleStatsSummary Summary { get; set; } = default!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One rule's aggregate across all dates in the window (fires descending).</summary>
|
||||||
|
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; }
|
||||||
|
/// <summary>Fires per evaluation as a percentage (one decimal); 0 with no evaluations.</summary>
|
||||||
|
public double HitRate { get; set; }
|
||||||
|
/// <summary>Average confidence score across fires (one decimal); 0 with no fires.</summary>
|
||||||
|
public double AvgConfidenceScore { get; set; }
|
||||||
|
/// <summary>Per-day trend rows, oldest first (one row per stored date).</summary>
|
||||||
|
public IReadOnlyList<RuleTrendPoint> Trend { get; set; } = default!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One day of a rule's trend ("yyyy-MM-dd").</summary>
|
||||||
|
public class RuleTrendPoint
|
||||||
|
{
|
||||||
|
public string Date { get; set; } = string.Empty;
|
||||||
|
public int FireCount { get; set; }
|
||||||
|
public int EvaluationCount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Window totals of a rule-stats response.</summary>
|
||||||
|
public class RuleStatsSummary
|
||||||
|
{
|
||||||
|
public int TotalEvaluations { get; set; }
|
||||||
|
public int TotalFires { get; set; }
|
||||||
|
public double OverallHitRate { get; set; }
|
||||||
|
/// <summary>Rule id with the most fires; absent when the window holds no rules.</summary>
|
||||||
|
public string? TopRuleByFireCount { get; set; }
|
||||||
|
/// <summary>Distinct rule count — set on the global route only (the key is absent on the tenant route, preserving its historical shape).</summary>
|
||||||
|
public int? UniqueRules { get; set; }
|
||||||
|
public RuleStatsPeriod Period { get; set; } = default!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Echo of the effective date window ("yyyy-MM-dd").</summary>
|
||||||
|
public class RuleStatsPeriod
|
||||||
|
{
|
||||||
|
public string Start { get; set; } = string.Empty;
|
||||||
|
public string End { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ namespace AutopilotMonitor.Shared.Models.Metrics
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// SLA metrics response for a given tenant and time window.
|
/// SLA metrics response for a given tenant and time window.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class SlaMetricsResponse
|
public class SlaMetricsResponse : IApiResponse
|
||||||
{
|
{
|
||||||
// Target echo (so the dashboard knows the configured targets)
|
// Target echo (so the dashboard knows the configured targets)
|
||||||
public decimal? TargetSuccessRate { get; set; }
|
public decimal? TargetSuccessRate { get; set; }
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ namespace AutopilotMonitor.Shared.Models
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Platform usage metrics response
|
/// Platform usage metrics response
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class PlatformUsageMetrics
|
public class PlatformUsageMetrics : IApiResponse
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Session metrics
|
/// Session metrics
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Response of <c>GET global/metrics/verdict-calibration</c>: 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.
|
||||||
|
/// </summary>
|
||||||
|
public class VerdictCalibrationResponse : IApiResponse
|
||||||
|
{
|
||||||
|
public bool Success { get; set; }
|
||||||
|
/// <summary>Partition echo: a tenant GUID, or "global" for the cross-tenant aggregate.</summary>
|
||||||
|
public string TenantId { get; set; } = string.Empty;
|
||||||
|
public int WindowDays { get; set; }
|
||||||
|
/// <summary>Inclusive window start ("yyyy-MM-dd").</summary>
|
||||||
|
public string WindowStart { get; set; } = string.Empty;
|
||||||
|
/// <summary>Inclusive window end ("yyyy-MM-dd", today).</summary>
|
||||||
|
public string WindowEnd { get; set; } = string.Empty;
|
||||||
|
/// <summary>Newest aggregate compute time in the window; absent when the window holds no rows.</summary>
|
||||||
|
public DateTime? ComputedAt { get; set; }
|
||||||
|
/// <summary>Distinct aggregation algorithm versions contributing to the window, ascending.</summary>
|
||||||
|
public int[] Versions { get; set; } = default!;
|
||||||
|
public VerdictCalibrationTotals Totals { get; set; } = default!;
|
||||||
|
public VerdictCalibrationTrendMeta Trend { get; set; } = default!;
|
||||||
|
/// <summary>Rows ordered by count descending, then path/status ordinal.</summary>
|
||||||
|
public IReadOnlyList<VerdictCalibrationPathRow> Paths { get; set; } = default!;
|
||||||
|
/// <summary>Active drift episodes, newest first.</summary>
|
||||||
|
public IReadOnlyList<VerdictCalibrationAlert> Alerts { get; set; } = default!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Window totals of the calibration matrix.</summary>
|
||||||
|
public class VerdictCalibrationTotals
|
||||||
|
{
|
||||||
|
public int Sessions { get; set; }
|
||||||
|
public int Terminal { get; set; }
|
||||||
|
public int Derived { get; set; }
|
||||||
|
/// <summary>Aggregate days that contributed to the window.</summary>
|
||||||
|
public int Days { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Trend denominators shared by every row (single source, never per-row copies).</summary>
|
||||||
|
public class VerdictCalibrationTrendMeta
|
||||||
|
{
|
||||||
|
public int WindowDays { get; set; }
|
||||||
|
public int BaselineDays { get; set; }
|
||||||
|
public int WindowSessions { get; set; }
|
||||||
|
public int BaselineSessions { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One verdict path × status row of the calibration matrix.</summary>
|
||||||
|
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; }
|
||||||
|
/// <summary>Null below the minimum eligible count — never a rate on a handful of sessions.</summary>
|
||||||
|
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();
|
||||||
|
/// <summary>Window share ÷ baseline share; null when the baseline share is 0 (a new path has no finite lift — never invented).</summary>
|
||||||
|
public double? Lift { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One trend window of a path row.</summary>
|
||||||
|
public class VerdictCalibrationTrendWindow
|
||||||
|
{
|
||||||
|
public int Count { get; set; }
|
||||||
|
public int Sessions { get; set; }
|
||||||
|
public double SharePct { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace AutopilotMonitor.Shared.Models
|
||||||
|
{
|
||||||
|
// Declaration order == wire order.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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. <see cref="EarliestProcessingAt"/> drives the "data deletion starts in mm ss"
|
||||||
|
/// countdown in the Web UI's drain-barrier state.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
public DateTime? EarliestProcessingAt { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -671,6 +671,20 @@ namespace AutopilotMonitor.Shared.Models
|
|||||||
public string? MsiSourceUrl { get; set; }
|
public string? MsiSourceUrl { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Redacted IME version row served to non-global callers of <c>GET metrics/ime-versions</c>:
|
||||||
|
/// the platform-wide rollout facts without the first-seen tenant/session identifiers or
|
||||||
|
/// the archive columns (see <c>GetImeVersionHistoryFunction.BuildResponsePayload</c>).
|
||||||
|
/// </summary>
|
||||||
|
[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; }
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Result of recording one IME version sighting (<c>RecordImeVersionAsync</c>): whether
|
/// Result of recording one IME version sighting (<c>RecordImeVersionAsync</c>): whether
|
||||||
/// the version is new to the fleet, plus the archive columns the ingest path needs to
|
/// the version is new to the fleet, plus the archive columns the ingest path needs to
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace AutopilotMonitor.Shared.Models
|
||||||
|
{
|
||||||
|
// Declaration order == wire order.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
public class SessionRestoreResponse : IApiResponse
|
||||||
|
{
|
||||||
|
public bool Success { get; set; }
|
||||||
|
/// <summary>SessionRestoreOutcome name (e.g. "Restored", "DryRunOk", "RejectManifestNotFound").</summary>
|
||||||
|
public string Outcome { get; set; } = string.Empty;
|
||||||
|
/// <summary>"full" | "partial" | "dryRun"; absent on early rejects.</summary>
|
||||||
|
public string? Mode { get; set; }
|
||||||
|
/// <summary>Operator-readable reason; absent on clean successes.</summary>
|
||||||
|
public string? Message { get; set; }
|
||||||
|
/// <summary>Reject diagnostics; absent otherwise.</summary>
|
||||||
|
public string? CurrentState { get; set; }
|
||||||
|
/// <summary>Reject diagnostics; absent otherwise.</summary>
|
||||||
|
public string? PendingManifestId { get; set; }
|
||||||
|
public Dictionary<string, int> RowsRestoredByTable { get; set; } = default!;
|
||||||
|
public Dictionary<string, int> RowsSkippedByTable { get; set; } = default!;
|
||||||
|
public Dictionary<string, int> WouldRestoreByTable { get; set; } = default!;
|
||||||
|
public int InventoryReIncrements { get; set; }
|
||||||
|
public long DurationMs { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
public class SessionDeletionQueuedResponse : IApiResponse
|
||||||
|
{
|
||||||
|
public bool Success { get; set; }
|
||||||
|
/// <summary>Always "queued".</summary>
|
||||||
|
public string Status { get; set; } = string.Empty;
|
||||||
|
public string? ManifestId { get; set; }
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user