mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +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),
|
||||
};
|
||||
|
||||
var root = JsonSerializer.SerializeToElement(MetricsMath.BuildAppMetricsPayload(summaries));
|
||||
var root = TestWire.SerializeToElement(MetricsMath.BuildAppMetricsPayload(summaries));
|
||||
|
||||
Assert.Equal(1, root.GetProperty("totalApps").GetInt32()); // collided name forms no group
|
||||
Assert.Equal(4, root.GetProperty("totalInstalls").GetInt32()); // raw row count unchanged
|
||||
@@ -282,7 +282,7 @@ public class AppInstallIdentityAndEspBlockingTests
|
||||
Summary("Company Portal", collision: true),
|
||||
};
|
||||
|
||||
var root = JsonSerializer.SerializeToElement(
|
||||
var root = TestWire.SerializeToElement(
|
||||
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
|
||||
|
||||
var apps = root.GetProperty("apps").EnumerateArray().ToList();
|
||||
@@ -305,7 +305,7 @@ public class AppInstallIdentityAndEspBlockingTests
|
||||
Summary("Company Portal", status: "Failed", collision: true), // the second identity's failure
|
||||
};
|
||||
|
||||
var root = JsonSerializer.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
var root = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
summaries, repo.Object, "Company Portal", days: 30));
|
||||
|
||||
Assert.Equal(1, root.GetProperty("collisionExcluded").GetInt32());
|
||||
@@ -324,7 +324,7 @@ public class AppInstallIdentityAndEspBlockingTests
|
||||
|
||||
var summaries = new List<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));
|
||||
|
||||
Assert.Equal(1, root.GetProperty("collisionExcluded").GetInt32());
|
||||
|
||||
@@ -363,7 +363,7 @@ public class AppVersionRegressionRadarTests
|
||||
Install("1.0", 50, status: "Failed"), // failed — excluded from durations
|
||||
};
|
||||
|
||||
var root = JsonSerializer.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
var root = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
rows, sessionRepo.Object, App, days: 30));
|
||||
|
||||
var version = root.GetProperty("versionBreakdown")[0];
|
||||
@@ -383,19 +383,18 @@ public class AppVersionRegressionRadarTests
|
||||
.ReturnsAsync((SessionSummary?)null);
|
||||
|
||||
var alert = Alert();
|
||||
// The Functions host serializes camelCase on the wire; mirror that here so the
|
||||
// TestWire serializes with the REAL wire options (ApiJsonOptions — camelCase), so the
|
||||
// asserted property names match what the web client actually parses.
|
||||
var wireOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);
|
||||
|
||||
// Empty-data early return still surfaces the block (episodes can outlive window data).
|
||||
var emptyRoot = JsonSerializer.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
var emptyRoot = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
new List<AppInstallSummary>(), sessionRepo.Object, App, days: 30,
|
||||
new List<AppVersionRegressionAlert> { alert }), wireOptions);
|
||||
new List<AppVersionRegressionAlert> { alert }));
|
||||
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<AppVersionRegressionAlert> { alert }), wireOptions);
|
||||
new List<AppVersionRegressionAlert> { alert }));
|
||||
|
||||
var regression = root.GetProperty("versionRegressions")[0];
|
||||
Assert.Equal("2.0", regression.GetProperty("currentVersion").GetString());
|
||||
|
||||
@@ -53,7 +53,7 @@ public class AppsAnalyticsFailureRateTests
|
||||
A("InProgress"),
|
||||
};
|
||||
|
||||
var root = JsonSerializer.SerializeToElement(
|
||||
var root = TestWire.SerializeToElement(
|
||||
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
|
||||
|
||||
var app = root.GetProperty("apps")[0];
|
||||
@@ -67,7 +67,7 @@ public class AppsAnalyticsFailureRateTests
|
||||
{
|
||||
var summaries = new List<AppInstallSummary> { A("InProgress"), A("InProgress") };
|
||||
|
||||
var root = JsonSerializer.SerializeToElement(
|
||||
var root = TestWire.SerializeToElement(
|
||||
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
|
||||
|
||||
Assert.Equal(0d, root.GetProperty("apps")[0].GetProperty("failureRate").GetDouble());
|
||||
@@ -90,7 +90,7 @@ public class AppsAnalyticsFailureRateTests
|
||||
for (var i = 0; i < 2; i++) summaries.Add(A("Succeeded", startedAt: secondHalf));
|
||||
for (var i = 0; i < 10; i++) summaries.Add(A("InProgress", startedAt: secondHalf));
|
||||
|
||||
var root = JsonSerializer.SerializeToElement(
|
||||
var root = TestWire.SerializeToElement(
|
||||
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
|
||||
|
||||
var app = root.GetProperty("apps")[0];
|
||||
@@ -109,12 +109,13 @@ public class AppsAnalyticsFailureRateTests
|
||||
for (var i = 0; i < 4; i++) summaries.Add(A("Failed", startedAt: now.AddDays(-5)));
|
||||
for (var i = 0; i < 10; i++) summaries.Add(A("InProgress", startedAt: now.AddDays(-5)));
|
||||
|
||||
var root = JsonSerializer.SerializeToElement(
|
||||
var root = TestWire.SerializeToElement(
|
||||
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
|
||||
|
||||
var app = root.GetProperty("apps")[0];
|
||||
Assert.Equal("stable", app.GetProperty("trend").GetString());
|
||||
Assert.Equal(JsonValueKind.Null, app.GetProperty("trendDelta").ValueKind);
|
||||
// Wire options omit null keys (WhenWritingNull): a withheld delta has no key at all.
|
||||
Assert.False(app.TryGetProperty("trendDelta", out _));
|
||||
}
|
||||
|
||||
// ── /apps/{appName}/analytics ───────────────────────────────────────────
|
||||
@@ -131,7 +132,7 @@ public class AppsAnalyticsFailureRateTests
|
||||
}
|
||||
var result = await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
summaries, sessionRepo, "Contoso App", days: 30);
|
||||
return JsonSerializer.SerializeToElement(result);
|
||||
return TestWire.SerializeToElement(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -176,7 +177,7 @@ public class AppsAnalyticsFailureRateTests
|
||||
|
||||
var summaries = new List<AppInstallSummary> { A("Failed"), A("Succeeded"), skip, unmeasured };
|
||||
|
||||
var root = JsonSerializer.SerializeToElement(
|
||||
var root = TestWire.SerializeToElement(
|
||||
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
|
||||
var app = root.GetProperty("apps")[0];
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ public class AppsAnalyticsHelperPagingTests
|
||||
}
|
||||
|
||||
private static JsonElement Build(int? pageSize, int skip = 0) =>
|
||||
JsonSerializer.SerializeToElement(AppsAnalyticsHelper.BuildAppsListResponse(
|
||||
TestWire.SerializeToElement(AppsAnalyticsHelper.BuildAppsListResponse(
|
||||
FiveApps(), days: 30, pageSize: pageSize, skip: skip,
|
||||
nextLinkForOffset: o => $"/api/apps/list?pageSize={pageSize}&skip={o}"));
|
||||
|
||||
@@ -55,12 +55,13 @@ public class AppsAnalyticsHelperPagingTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PageSize_LastPage_HasNullNextLink()
|
||||
public void PageSize_LastPage_OmitsNextLink()
|
||||
{
|
||||
var root = Build(pageSize: 2, skip: 4);
|
||||
|
||||
Assert.Equal(1, root.GetProperty("count").GetInt32());
|
||||
Assert.Equal("Echo", root.GetProperty("apps")[0].GetProperty("appName").GetString());
|
||||
Assert.Equal(JsonValueKind.Null, root.GetProperty("nextLink").ValueKind);
|
||||
// Wire options omit null keys (WhenWritingNull): no further page → no nextLink key.
|
||||
Assert.False(root.TryGetProperty("nextLink", out _));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,16 +159,18 @@ public class DeleteSessionFunctionTests
|
||||
|
||||
// ── Anonymous-object reflection helpers ───────────────────────────────────────────────
|
||||
|
||||
// IgnoreCase: the Enqueued arm is a typed DTO (PascalCase properties, camelCased on the
|
||||
// wire by ApiJsonOptions); the error arms stay anonymous (camelCase). Both resolve here.
|
||||
private static void AssertProperty(object body, string propertyName, object? expected)
|
||||
{
|
||||
var prop = body.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);
|
||||
var prop = body.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
|
||||
Assert.NotNull(prop);
|
||||
Assert.Equal(expected, prop!.GetValue(body));
|
||||
}
|
||||
|
||||
private static T GetProperty<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);
|
||||
var value = prop!.GetValue(body);
|
||||
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(2, totals.FirstTimeRight);
|
||||
@@ -716,7 +716,7 @@ public class DeviceJourneyAndFtrTests
|
||||
[Fact]
|
||||
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 },
|
||||
});
|
||||
@@ -734,7 +734,7 @@ public class DeviceJourneyAndFtrTests
|
||||
[InlineData("999", 180)] // clamped to aggregate retention
|
||||
public void ClampDays_DefaultsAndClamps(string? raw, int expected)
|
||||
{
|
||||
Assert.Equal(expected, DeviceJourneyMetricsResponse.ClampDays(raw));
|
||||
Assert.Equal(expected, DeviceJourneyMetricsResponseBuilder.ClampDays(raw));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -743,8 +743,8 @@ public class DeviceJourneyAndFtrTests
|
||||
// Codex review: both range ends are inclusive, so "today - days" returned N+1 keys —
|
||||
// days=1 summed yesterday AND today.
|
||||
var today = new DateTime(2026, 7, 27);
|
||||
Assert.Equal(today, DeviceJourneyMetricsResponse.InclusiveWindowStart(today, 1));
|
||||
Assert.Equal(today.AddDays(-29), DeviceJourneyMetricsResponse.InclusiveWindowStart(today, 30));
|
||||
Assert.Equal(today, DeviceJourneyMetricsResponseBuilder.InclusiveWindowStart(today, 1));
|
||||
Assert.Equal(today.AddDays(-29), DeviceJourneyMetricsResponseBuilder.InclusiveWindowStart(today, 30));
|
||||
}
|
||||
|
||||
private static DeviceHistory RepeaterHistory(
|
||||
@@ -780,7 +780,7 @@ public class DeviceJourneyAndFtrTests
|
||||
Ref("w2", SessionStatus.Succeeded, T0.AddDays(-2))),
|
||||
};
|
||||
|
||||
var selected = DeviceJourneyMetricsResponse.SelectRepeatDevices(histories, T0.AddDays(-30));
|
||||
var selected = DeviceJourneyMetricsResponseBuilder.SelectRepeatDevices(histories, T0.AddDays(-30));
|
||||
|
||||
Assert.Equal(2, selected.Count);
|
||||
Assert.Equal("hot-1", selected[0].History.SerialKey); // attempts desc
|
||||
@@ -799,7 +799,7 @@ public class DeviceJourneyAndFtrTests
|
||||
Ref($"r-{i}", SessionStatus.Failed, T0.AddDays(-i))));
|
||||
}
|
||||
|
||||
var selected = DeviceJourneyMetricsResponse.SelectRepeatDevices(histories, T0.AddDays(-30));
|
||||
var selected = DeviceJourneyMetricsResponseBuilder.SelectRepeatDevices(histories, T0.AddDays(-30));
|
||||
|
||||
Assert.Equal(10, selected.Count);
|
||||
Assert.Equal("dev-11", selected[0].History.SerialKey); // highest attempts first
|
||||
|
||||
@@ -19,7 +19,7 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
private static JsonElement Serialize(TenantConfiguration config)
|
||||
{
|
||||
var payload = GetTenantFeatureFlagsFunction.BuildPayload(config, Now);
|
||||
var json = JsonSerializer.Serialize(payload);
|
||||
var json = TestWire.Serialize(payload);
|
||||
return JsonDocument.Parse(json).RootElement;
|
||||
}
|
||||
|
||||
@@ -49,7 +49,9 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
"isTrial",
|
||||
"showScriptOutput",
|
||||
"trialAvailable",
|
||||
"trialExpiresUtc",
|
||||
// trialExpiresUtc is absent here: the default tenant is not on a trial and the
|
||||
// wire options omit null keys (WhenWritingNull) — exactly what production always
|
||||
// served. Payload_ActiveTrial_IsTrialWithExpiry pins the key on an active trial.
|
||||
"unrestrictedMode",
|
||||
"validateAutopilotDevice",
|
||||
}, fieldNames);
|
||||
@@ -162,7 +164,7 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
WebhookUrl = "https://hooks.example.com/services/secret-generic-hook",
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(GetTenantFeatureFlagsFunction.BuildPayload(config, Now));
|
||||
var json = TestWire.Serialize(GetTenantFeatureFlagsFunction.BuildPayload(config, Now));
|
||||
|
||||
Assert.DoesNotContain("secret-sas-token", json);
|
||||
Assert.DoesNotContain("secret-team-hook", json);
|
||||
@@ -270,7 +272,7 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
[Fact]
|
||||
public void Payload_ContactEmailSet_NeverLeaksTheAddress()
|
||||
{
|
||||
var json = JsonSerializer.Serialize(GetTenantFeatureFlagsFunction.BuildPayload(
|
||||
var json = TestWire.Serialize(GetTenantFeatureFlagsFunction.BuildPayload(
|
||||
new TenantConfiguration { ContactEmail = "secret-contact@contoso.com" }, Now));
|
||||
|
||||
Assert.DoesNotContain("secret-contact", json);
|
||||
|
||||
@@ -18,7 +18,7 @@ public class MetricsMathAppPayloadTests
|
||||
private static JsonElement Build(IEnumerable<AppInstallSummary> summaries)
|
||||
{
|
||||
var payload = MetricsMath.BuildAppMetricsPayload(summaries);
|
||||
return JsonSerializer.SerializeToElement(payload);
|
||||
return TestWire.SerializeToElement(payload);
|
||||
}
|
||||
|
||||
[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.
|
||||
var today = new System.DateTime(2026, 7, 27);
|
||||
Assert.Equal(today.AddDays(-29),
|
||||
Functions.Metrics.TimeAttributionResponse.InclusiveWindowStart(today, Functions.Metrics.TimeAttributionResponse.WindowDays));
|
||||
Functions.Metrics.TimeAttributionResponseBuilder.InclusiveWindowStart(today, Functions.Metrics.TimeAttributionResponseBuilder.WindowDays));
|
||||
}
|
||||
}
|
||||
|
||||
+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>
|
||||
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]
|
||||
public void Anonymous_helper_success_bodies_never_exceed_the_frozen_baseline()
|
||||
{
|
||||
@@ -66,6 +110,42 @@ public class TypedResponseGuardTests
|
||||
"anonymous success WriteAsJsonAsync(new { ... })");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Anonymous_objects_smuggled_through_a_variable_into_WriteAsJsonAsync_are_flagged()
|
||||
{
|
||||
AssertRatchet(
|
||||
text => WriteAsJsonIdentifier.Matches(text).Count(m =>
|
||||
{
|
||||
var ident = m.Groups[1].Value;
|
||||
if (ident == "new") return false;
|
||||
var assign = new Regex(@"(?:var\s+)?" + Regex.Escape(ident) + @"\s*=\s*new\s*\{");
|
||||
var a = assign.Match(text);
|
||||
// Tolerate error-shaped variables the same way inline literals are tolerated.
|
||||
return a.Success && !IsErrorShape(text, a.Index + a.Length);
|
||||
}),
|
||||
IdentifierBaseline,
|
||||
"WriteAsJsonAsync(<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>
|
||||
/// 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
|
||||
|
||||
@@ -217,7 +217,7 @@ public class VerdictCalibrationTests
|
||||
private static readonly JsonSerializerOptions Wire = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
||||
|
||||
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]
|
||||
public void Response_sums_the_window_and_computes_share_rate_and_trend()
|
||||
@@ -297,5 +297,5 @@ public class VerdictCalibrationTests
|
||||
[InlineData("500", 180)]
|
||||
[InlineData("14", 14)]
|
||||
public void ClampDays_bounds_the_window(string? raw, int expected)
|
||||
=> Assert.Equal(expected, VerdictCalibrationResponse.ClampDays(raw));
|
||||
=> Assert.Equal(expected, VerdictCalibrationResponseBuilder.ClampDays(raw));
|
||||
}
|
||||
|
||||
@@ -750,24 +750,8 @@ public class TenantOffboardFunction
|
||||
}
|
||||
}
|
||||
|
||||
/// <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
|
||||
{
|
||||
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; }
|
||||
}
|
||||
// OffboardResponse (the 202/200 offboarding body) moved to AutopilotMonitor.Shared.Models
|
||||
// (Models/OffboardApiModels.cs) so the shared manifest exports it as a wire type.
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// route-specific nextLink for the next offset; it is only invoked when more pages remain.
|
||||
/// </summary>
|
||||
public static object BuildAppsListResponse(
|
||||
public static AppsListResponse BuildAppsListResponse(
|
||||
List<AppInstallSummary> allSummaries,
|
||||
int days,
|
||||
int? pageSize = null,
|
||||
@@ -161,40 +161,40 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
var secondHalf = g.Where(s => s.StartedAt >= midpoint).ToList();
|
||||
var (trend, trendDelta) = ComputeFailureTrend(firstHalf, secondHalf);
|
||||
|
||||
return new
|
||||
return new AppsListItem
|
||||
{
|
||||
appName = g.Key,
|
||||
appType = g.Select(s => s.AppType).FirstOrDefault(t => !string.IsNullOrEmpty(t)) ?? string.Empty,
|
||||
totalInstalls = total,
|
||||
succeeded = installed.Count,
|
||||
skipped,
|
||||
unmeasured = installed.Count - measured.Count,
|
||||
failed,
|
||||
failureRate,
|
||||
avgDurationSeconds = measured.Count > 0 ? Math.Round(measured.Average(s => s.DurationSeconds), 0) : 0,
|
||||
maxDurationSeconds = measured.Count > 0 ? measured.Max(s => s.DurationSeconds) : 0,
|
||||
avgDownloadBytes = measured.Count > 0 ? (long)measured.Average(s => s.DownloadBytes) : 0,
|
||||
trend,
|
||||
trendDelta,
|
||||
lastSeenAt = g.Max(s => s.CompletedAt ?? s.StartedAt)
|
||||
AppName = g.Key,
|
||||
AppType = g.Select(s => s.AppType).FirstOrDefault(t => !string.IsNullOrEmpty(t)) ?? string.Empty,
|
||||
TotalInstalls = total,
|
||||
Succeeded = installed.Count,
|
||||
Skipped = skipped,
|
||||
Unmeasured = installed.Count - measured.Count,
|
||||
Failed = failed,
|
||||
FailureRate = failureRate,
|
||||
AvgDurationSeconds = measured.Count > 0 ? Math.Round(measured.Average(s => s.DurationSeconds), 0) : 0,
|
||||
MaxDurationSeconds = measured.Count > 0 ? measured.Max(s => s.DurationSeconds) : 0,
|
||||
AvgDownloadBytes = measured.Count > 0 ? (long)measured.Average(s => s.DownloadBytes) : 0,
|
||||
Trend = trend,
|
||||
TrendDelta = trendDelta,
|
||||
LastSeenAt = g.Max(s => s.CompletedAt ?? s.StartedAt)
|
||||
};
|
||||
})
|
||||
.OrderByDescending(a => a.failed)
|
||||
.ThenByDescending(a => a.failureRate)
|
||||
.ThenBy(a => a.appName, StringComparer.OrdinalIgnoreCase) // deterministic tiebreaker for stable paging cursors
|
||||
.OrderByDescending(a => a.Failed)
|
||||
.ThenByDescending(a => a.FailureRate)
|
||||
.ThenBy(a => a.AppName, StringComparer.OrdinalIgnoreCase) // deterministic tiebreaker for stable paging cursors
|
||||
.ToList();
|
||||
|
||||
// Legacy mode: caller did not opt into pagination → full array (web UI pages client-side).
|
||||
if (pageSize == null)
|
||||
{
|
||||
return new
|
||||
return new AppsListResponse
|
||||
{
|
||||
success = true,
|
||||
totalApps = apps.Count,
|
||||
totalInstalls = summaries.Count,
|
||||
collisionExcluded,
|
||||
windowDays = days,
|
||||
apps
|
||||
Success = true,
|
||||
TotalApps = apps.Count,
|
||||
TotalInstalls = summaries.Count,
|
||||
CollisionExcluded = collisionExcluded,
|
||||
WindowDays = days,
|
||||
Apps = apps
|
||||
};
|
||||
}
|
||||
|
||||
@@ -204,18 +204,18 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
var nextOffset = offset + page.Count;
|
||||
var hasMore = nextOffset < apps.Count;
|
||||
|
||||
return new
|
||||
return new AppsListResponse
|
||||
{
|
||||
success = true,
|
||||
totalApps = apps.Count,
|
||||
totalInstalls = summaries.Count,
|
||||
collisionExcluded,
|
||||
windowDays = days,
|
||||
count = page.Count,
|
||||
offset,
|
||||
pageSize = pageSize.Value,
|
||||
apps = page,
|
||||
nextLink = hasMore ? nextLinkForOffset?.Invoke(nextOffset) : null
|
||||
Success = true,
|
||||
TotalApps = apps.Count,
|
||||
TotalInstalls = summaries.Count,
|
||||
CollisionExcluded = collisionExcluded,
|
||||
WindowDays = days,
|
||||
Count = page.Count,
|
||||
Offset = offset,
|
||||
PageSize = pageSize.Value,
|
||||
Apps = page,
|
||||
NextLink = hasMore ? nextLinkForOffset?.Invoke(nextOffset) : null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -225,7 +225,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
/// Builds the analytics response body for a single app.
|
||||
/// Loads sessions individually via the session repository for the device-model join.
|
||||
/// </summary>
|
||||
public static async Task<object> BuildAnalyticsResponseAsync(
|
||||
public static async Task<AppAnalyticsResponse> BuildAnalyticsResponseAsync(
|
||||
List<AppInstallSummary> allSummaries,
|
||||
ISessionRepository sessionRepo,
|
||||
string appName,
|
||||
@@ -252,38 +252,38 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
|
||||
if (summaries.Count == 0)
|
||||
{
|
||||
return new
|
||||
return new AppAnalyticsResponse
|
||||
{
|
||||
success = true,
|
||||
appName,
|
||||
appType = string.Empty,
|
||||
windowDays = days,
|
||||
collisionExcluded,
|
||||
bucket = "day",
|
||||
summary = new
|
||||
Success = true,
|
||||
AppName = appName,
|
||||
AppType = string.Empty,
|
||||
WindowDays = days,
|
||||
CollisionExcluded = collisionExcluded,
|
||||
Bucket = "day",
|
||||
Summary = new AppAnalyticsSummary
|
||||
{
|
||||
totalInstalls = 0,
|
||||
succeeded = 0,
|
||||
skipped = 0,
|
||||
unmeasured = 0,
|
||||
failed = 0,
|
||||
failureRate = 0,
|
||||
avgDurationSeconds = 0,
|
||||
p95DurationSeconds = 0,
|
||||
avgDownloadBytes = 0,
|
||||
trend = "stable",
|
||||
trendDelta = (double?)null,
|
||||
flakinessScore = 0.0
|
||||
TotalInstalls = 0,
|
||||
Succeeded = 0,
|
||||
Skipped = 0,
|
||||
Unmeasured = 0,
|
||||
Failed = 0,
|
||||
FailureRate = 0,
|
||||
AvgDurationSeconds = 0,
|
||||
P95DurationSeconds = 0,
|
||||
AvgDownloadBytes = 0,
|
||||
Trend = "stable",
|
||||
TrendDelta = null,
|
||||
FlakinessScore = 0.0
|
||||
},
|
||||
timeSeries = Array.Empty<object>(),
|
||||
versionBreakdown = Array.Empty<object>(),
|
||||
installerPhaseBreakdown = Array.Empty<object>(),
|
||||
topFailureCodes = Array.Empty<object>(),
|
||||
detectionLiesCount = 0,
|
||||
deviceModelBreakdown = Array.Empty<object>(),
|
||||
TimeSeries = Array.Empty<AppAnalyticsTimeBucket>(),
|
||||
VersionBreakdown = Array.Empty<AppVersionBreakdownItem>(),
|
||||
InstallerPhaseBreakdown = Array.Empty<AppInstallerPhaseCount>(),
|
||||
TopFailureCodes = Array.Empty<AppAnalyticsFailureCode>(),
|
||||
DetectionLiesCount = 0,
|
||||
DeviceModelBreakdown = Array.Empty<AppDeviceModelBreakdownItem>(),
|
||||
// Lockstep with the full response: episodes can outlive the window's data
|
||||
// (e.g. a shrunk days= selection), so the block is still surfaced here.
|
||||
versionRegressions = appVersionRegressions
|
||||
VersionRegressions = appVersionRegressions
|
||||
};
|
||||
}
|
||||
|
||||
@@ -329,38 +329,38 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
.Where(MetricsMath.HasMeasuredDuration)
|
||||
.Select(s => s.DurationSeconds)
|
||||
.ToList();
|
||||
return new
|
||||
return new AppVersionBreakdownItem
|
||||
{
|
||||
appVersion = g.Key,
|
||||
installs = vTotal,
|
||||
failed = vFailed,
|
||||
failureRate = MetricsMath.TerminalFailureRatePct(vFailed, vSucceeded),
|
||||
measuredInstalls = vDurations.Count,
|
||||
medianDurationSeconds = Percentile(vDurations, 0.50),
|
||||
p95DurationSeconds = Percentile(vDurations, 0.95)
|
||||
AppVersion = g.Key,
|
||||
Installs = vTotal,
|
||||
Failed = vFailed,
|
||||
FailureRate = MetricsMath.TerminalFailureRatePct(vFailed, vSucceeded),
|
||||
MeasuredInstalls = vDurations.Count,
|
||||
MedianDurationSeconds = Percentile(vDurations, 0.50),
|
||||
P95DurationSeconds = Percentile(vDurations, 0.95)
|
||||
};
|
||||
})
|
||||
.OrderByDescending(v => v.installs)
|
||||
.OrderByDescending(v => v.Installs)
|
||||
.ToList();
|
||||
|
||||
var installerPhaseBreakdown = summaries
|
||||
.Where(s => s.Status == "Failed" && !string.IsNullOrEmpty(s.InstallerPhase))
|
||||
.GroupBy(s => s.InstallerPhase)
|
||||
.Select(g => new { phase = g.Key, failed = g.Count() })
|
||||
.OrderByDescending(p => p.failed)
|
||||
.Select(g => new AppInstallerPhaseCount { Phase = g.Key, Failed = g.Count() })
|
||||
.OrderByDescending(p => p.Failed)
|
||||
.ToList();
|
||||
|
||||
var topFailureCodes = summaries
|
||||
.Where(s => s.Status == "Failed" && !string.IsNullOrEmpty(s.FailureCode))
|
||||
.GroupBy(s => s.FailureCode)
|
||||
.Select(g => new
|
||||
.Select(g => new AppAnalyticsFailureCode
|
||||
{
|
||||
code = g.Key,
|
||||
exitCode = g.Select(s => s.ExitCode).FirstOrDefault(e => e.HasValue),
|
||||
count = g.Count(),
|
||||
sampleMessage = g.Select(s => s.FailureMessage).FirstOrDefault(m => !string.IsNullOrEmpty(m)) ?? string.Empty
|
||||
Code = g.Key,
|
||||
ExitCode = g.Select(s => s.ExitCode).FirstOrDefault(e => e.HasValue),
|
||||
Count = g.Count(),
|
||||
SampleMessage = g.Select(s => s.FailureMessage).FirstOrDefault(m => !string.IsNullOrEmpty(m)) ?? string.Empty
|
||||
})
|
||||
.OrderByDescending(f => f.count)
|
||||
.OrderByDescending(f => f.Count)
|
||||
.Take(5)
|
||||
.ToList();
|
||||
|
||||
@@ -397,57 +397,57 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
var lift = failureRate > 0
|
||||
? Math.Round(modelFailureRate / failureRate, 2)
|
||||
: 0;
|
||||
return new
|
||||
return new AppDeviceModelBreakdownItem
|
||||
{
|
||||
manufacturer = g.Key.Manufacturer,
|
||||
model = g.Key.Model,
|
||||
installs = modelTotal,
|
||||
failed = modelFailed,
|
||||
failureRate = modelFailureRate,
|
||||
liftVsBaseline = lift
|
||||
Manufacturer = g.Key.Manufacturer,
|
||||
Model = g.Key.Model,
|
||||
Installs = modelTotal,
|
||||
Failed = modelFailed,
|
||||
FailureRate = modelFailureRate,
|
||||
LiftVsBaseline = lift
|
||||
};
|
||||
})
|
||||
.OrderByDescending(m => m.liftVsBaseline)
|
||||
.OrderByDescending(m => m.LiftVsBaseline)
|
||||
.ToList();
|
||||
|
||||
var appType = summaries.Select(s => s.AppType).FirstOrDefault(t => !string.IsNullOrEmpty(t)) ?? string.Empty;
|
||||
|
||||
return new
|
||||
return new AppAnalyticsResponse
|
||||
{
|
||||
success = true,
|
||||
appName,
|
||||
appType,
|
||||
windowDays = days,
|
||||
collisionExcluded,
|
||||
bucket,
|
||||
summary = new
|
||||
Success = true,
|
||||
AppName = appName,
|
||||
AppType = appType,
|
||||
WindowDays = days,
|
||||
CollisionExcluded = collisionExcluded,
|
||||
Bucket = bucket,
|
||||
Summary = new AppAnalyticsSummary
|
||||
{
|
||||
totalInstalls = total,
|
||||
succeeded,
|
||||
skipped,
|
||||
unmeasured = installed.Count - measured.Count,
|
||||
failed,
|
||||
failureRate,
|
||||
avgDurationSeconds,
|
||||
p95DurationSeconds,
|
||||
avgDownloadBytes,
|
||||
trend,
|
||||
trendDelta,
|
||||
flakinessScore
|
||||
TotalInstalls = total,
|
||||
Succeeded = succeeded,
|
||||
Skipped = skipped,
|
||||
Unmeasured = installed.Count - measured.Count,
|
||||
Failed = failed,
|
||||
FailureRate = failureRate,
|
||||
AvgDurationSeconds = avgDurationSeconds,
|
||||
P95DurationSeconds = p95DurationSeconds,
|
||||
AvgDownloadBytes = avgDownloadBytes,
|
||||
Trend = trend,
|
||||
TrendDelta = trendDelta,
|
||||
FlakinessScore = flakinessScore
|
||||
},
|
||||
timeSeries,
|
||||
versionBreakdown,
|
||||
installerPhaseBreakdown,
|
||||
topFailureCodes,
|
||||
detectionLiesCount,
|
||||
deviceModelBreakdown,
|
||||
versionRegressions = appVersionRegressions
|
||||
TimeSeries = timeSeries,
|
||||
VersionBreakdown = versionBreakdown,
|
||||
InstallerPhaseBreakdown = installerPhaseBreakdown,
|
||||
TopFailureCodes = topFailureCodes,
|
||||
DetectionLiesCount = detectionLiesCount,
|
||||
DeviceModelBreakdown = deviceModelBreakdown,
|
||||
VersionRegressions = appVersionRegressions
|
||||
};
|
||||
}
|
||||
|
||||
// ── /apps/{appName}/sessions ────────────────────────────────────────
|
||||
|
||||
public static async Task<object> BuildSessionsResponseAsync(
|
||||
public static async Task<AppSessionsResponse> BuildSessionsResponseAsync(
|
||||
List<AppInstallSummary> allSummaries,
|
||||
ISessionRepository sessionRepo,
|
||||
string appName,
|
||||
@@ -499,34 +499,34 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
var items = page.Select(s =>
|
||||
{
|
||||
sessionLookup.TryGetValue($"{s.TenantId}|{s.SessionId}", out var sess);
|
||||
return new
|
||||
return new AppSessionItem
|
||||
{
|
||||
sessionId = s.SessionId,
|
||||
tenantId = s.TenantId,
|
||||
deviceName = sess?.DeviceName ?? string.Empty,
|
||||
manufacturer = sess?.Manufacturer ?? string.Empty,
|
||||
model = sess?.Model ?? string.Empty,
|
||||
appVersion = s.AppVersion,
|
||||
status = s.Status,
|
||||
installerPhase = s.InstallerPhase,
|
||||
failureCode = s.FailureCode,
|
||||
exitCode = s.ExitCode,
|
||||
attemptNumber = s.AttemptNumber,
|
||||
startedAt = s.StartedAt,
|
||||
durationSeconds = s.DurationSeconds,
|
||||
SessionId = s.SessionId,
|
||||
TenantId = s.TenantId,
|
||||
DeviceName = sess?.DeviceName ?? string.Empty,
|
||||
Manufacturer = sess?.Manufacturer ?? string.Empty,
|
||||
Model = sess?.Model ?? string.Empty,
|
||||
AppVersion = s.AppVersion,
|
||||
Status = s.Status,
|
||||
InstallerPhase = s.InstallerPhase,
|
||||
FailureCode = s.FailureCode,
|
||||
ExitCode = s.ExitCode,
|
||||
AttemptNumber = s.AttemptNumber,
|
||||
StartedAt = s.StartedAt,
|
||||
DurationSeconds = s.DurationSeconds,
|
||||
// 2+ = the IME processed this app in multiple passes (device-ESP
|
||||
// evaluation + real install) — explains a completion far after startedAt.
|
||||
installPassCount = s.InstallPassCount
|
||||
InstallPassCount = s.InstallPassCount
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
return new
|
||||
return new AppSessionsResponse
|
||||
{
|
||||
success = true,
|
||||
total,
|
||||
offset,
|
||||
limit,
|
||||
items
|
||||
Success = true,
|
||||
Total = total,
|
||||
Offset = offset,
|
||||
Limit = limit,
|
||||
Items = items
|
||||
};
|
||||
}
|
||||
|
||||
@@ -559,7 +559,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
return (trend, delta);
|
||||
}
|
||||
|
||||
private static List<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 end = now.Date;
|
||||
@@ -589,14 +589,14 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
// PR0 convention: skips are not attempts; durations read measured rows only.
|
||||
var bInstalled = items.Where(s => s.Status == "Succeeded" && !MetricsMath.IsSkipTerminalState(s)).ToList();
|
||||
var bMeasured = bInstalled.Where(MetricsMath.HasMeasuredDuration).ToList();
|
||||
return (object)new
|
||||
return new AppAnalyticsTimeBucket
|
||||
{
|
||||
bucketStart = DateTime.SpecifyKind(kv.Key, DateTimeKind.Utc),
|
||||
installs = bTotal,
|
||||
succeeded = bInstalled.Count,
|
||||
failed = bFailed,
|
||||
failureRate = MetricsMath.TerminalFailureRatePct(bFailed, bInstalled.Count),
|
||||
avgDurationSeconds = bMeasured.Count > 0 ? Math.Round(bMeasured.Average(s => s.DurationSeconds), 0) : 0
|
||||
BucketStart = DateTime.SpecifyKind(kv.Key, DateTimeKind.Utc),
|
||||
Installs = bTotal,
|
||||
Succeeded = bInstalled.Count,
|
||||
Failed = bFailed,
|
||||
FailureRate = MetricsMath.TerminalFailureRatePct(bFailed, bInstalled.Count),
|
||||
AvgDurationSeconds = bMeasured.Count > 0 ? Math.Round(bMeasured.Average(s => s.DurationSeconds), 0) : 0
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
|
||||
@@ -43,7 +43,7 @@ namespace AutopilotMonitor.Functions.Functions.Backup
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
response.Headers.Add("Content-Type", "application/json; charset=utf-8");
|
||||
var json = JsonSerializer.Serialize(new { backupIds = ids }, BackupManifestJson.SerializerOptions);
|
||||
var json = JsonSerializer.Serialize(new ListBackupsResponse { BackupIds = ids }, BackupManifestJson.SerializerOptions);
|
||||
await response.WriteStringAsync(json);
|
||||
return response;
|
||||
}
|
||||
|
||||
+20
-20
@@ -70,56 +70,56 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
/// without standing up an HttpRequestData mock. <paramref name="nowUtc"/> feeds the
|
||||
/// read-time edition resolution (trial expiry degrades automatically).
|
||||
/// </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 entitlements = FeatureEntitlementCatalog.Get(edition);
|
||||
var isTrial = edition == TenantEdition.Pro &&
|
||||
!FeatureEntitlementCatalog.IsPermanentProTier(config.PlanTier);
|
||||
|
||||
return new
|
||||
return new TenantFeatureFlagsResponse
|
||||
{
|
||||
// EFFECTIVE bootstrap availability (Pro includes it; the GA flag is the additive
|
||||
// Community enable) — field name kept for web compatibility.
|
||||
bootstrapTokenEnabled = TenantEntitlementService.IsBootstrapEnabled(config, nowUtc),
|
||||
BootstrapTokenEnabled = TenantEntitlementService.IsBootstrapEnabled(config, nowUtc),
|
||||
// Session-detail "Collect Logs" button: whether an on-demand diagnostics upload can
|
||||
// succeed right now (mode not Off + a usable destination). Members below Admin cannot
|
||||
// read the full config, so this boolean is their only signal. Deliberately exposes no
|
||||
// destination detail — just "would an upload work".
|
||||
diagnosticsUploadConfigured = DiagnosticsUploadConfigChange.IsConfigured(config),
|
||||
DiagnosticsUploadConfigured = DiagnosticsUploadConfigChange.IsConfigured(config),
|
||||
// Drives the "Autopilot Device Validation disabled" dashboard banner
|
||||
// (useTenantSecurityConfig).
|
||||
validateAutopilotDevice = config.ValidateAutopilotDevice,
|
||||
ValidateAutopilotDevice = config.ValidateAutopilotDevice,
|
||||
// Dual app-reg self-service migration: when true, running the consent flow (or
|
||||
// "Detect existing access") targets the NEW app registration and auto-flips this
|
||||
// tenant's homing after verification — drives the explanatory banner in the
|
||||
// Autopilot Validation settings section. Non-sensitive: exposes no client ids.
|
||||
appHomingFunnelActive,
|
||||
AppHomingFunnelActive = appHomingFunnelActive,
|
||||
// Session-detail UI flags (useSessionTenantConfig). Nullable in the model;
|
||||
// surface the agent-side defaults so the UI does not need a second nullable layer.
|
||||
showScriptOutput = config.ShowScriptOutput ?? true,
|
||||
enableSoftwareInventoryAnalyzer = config.EnableSoftwareInventoryAnalyzer ?? false,
|
||||
enableIntegrityBypassAnalyzer = config.EnableIntegrityBypassAnalyzer ?? true,
|
||||
ShowScriptOutput = config.ShowScriptOutput ?? true,
|
||||
EnableSoftwareInventoryAnalyzer = config.EnableSoftwareInventoryAnalyzer ?? false,
|
||||
EnableIntegrityBypassAnalyzer = config.EnableIntegrityBypassAnalyzer ?? true,
|
||||
// Gather-rules page validation indicator. EFFECTIVE value (requires Pro edition +
|
||||
// GA gate + tenant toggle) — the privileged toggle is UnrestrictedModeEnabled
|
||||
// (admin-only, stays in the full config response).
|
||||
unrestrictedMode = TenantEntitlementService.IsUnrestrictedModeActive(config, nowUtc),
|
||||
UnrestrictedMode = TenantEntitlementService.IsUnrestrictedModeActive(config, nowUtc),
|
||||
// Edition/entitlement surface (read-time resolution — non-sensitive by design):
|
||||
// drives the EditionBadge, trial CTA and retention hint in the web UI.
|
||||
edition = edition.ToString().ToLowerInvariant(),
|
||||
isTrial,
|
||||
trialExpiresUtc = isTrial ? config.TrialExpiresUtc : null,
|
||||
trialAvailable = !config.TrialConsumed && edition == TenantEdition.Community,
|
||||
Edition = edition.ToString().ToLowerInvariant(),
|
||||
IsTrial = isTrial,
|
||||
TrialExpiresUtc = isTrial ? config.TrialExpiresUtc : null,
|
||||
TrialAvailable = !config.TrialConsumed && edition == TenantEdition.Community,
|
||||
// Pro-requires-contact surface: drives the trial CTA gate and the dashboard
|
||||
// "set a contact address" banner for Pro tenants. Boolean only — the address
|
||||
// itself stays in the admin-gated full config response.
|
||||
contactEmailSet = !string.IsNullOrWhiteSpace(config.ContactEmail),
|
||||
entitlements = new
|
||||
ContactEmailSet = !string.IsNullOrWhiteSpace(config.ContactEmail),
|
||||
Entitlements = new TenantFeatureEntitlements
|
||||
{
|
||||
retentionCapDays = entitlements.RetentionCapDays,
|
||||
userRateLimitPerMinute = entitlements.UserRateLimitPerMinute,
|
||||
delegatedAdminAllowed = entitlements.DelegatedAdminAllowed,
|
||||
mcpUsagePlan = entitlements.McpUsagePlanName
|
||||
RetentionCapDays = entitlements.RetentionCapDays,
|
||||
UserRateLimitPerMinute = entitlements.UserRateLimitPerMinute,
|
||||
DelegatedAdminAllowed = entitlements.DelegatedAdminAllowed,
|
||||
McpUsagePlan = entitlements.McpUsagePlanName
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
+10
-10
@@ -59,19 +59,19 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
|
||||
/// <c>condition</c> travels as the enum NAME ("Always" | "RealmJoinWatcher" |
|
||||
/// "DevicePreparation"), never the integer — the web switches on the string.
|
||||
/// </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,
|
||||
zipFolder = s.ZipFolder,
|
||||
sourceFolder = s.SourceFolder,
|
||||
patterns = s.Patterns,
|
||||
includeSubfolders = s.IncludeSubfolders,
|
||||
description = s.Description,
|
||||
condition = s.Condition.ToString(),
|
||||
Id = s.Id,
|
||||
ZipFolder = s.ZipFolder,
|
||||
SourceFolder = s.SourceFolder,
|
||||
Patterns = s.Patterns,
|
||||
IncludeSubfolders = s.IncludeSubfolders,
|
||||
Description = s.Description,
|
||||
Condition = s.Condition.ToString(),
|
||||
}).ToArray(),
|
||||
globalPaths = adminConfig.GetDiagnosticsGlobalLogPaths(),
|
||||
GlobalPaths = adminConfig.GetDiagnosticsGlobalLogPaths(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ namespace AutopilotMonitor.Functions.Functions.Feedback
|
||||
// four extra Table reads on every poll from every active user.
|
||||
var adminConfig = await _adminConfigService.GetConfigurationAsync();
|
||||
if (!adminConfig.FeedbackEnabled)
|
||||
return await WriteJson(req, new { eligible = false });
|
||||
return await WriteJson(req, new FeedbackEligibilityResponse { Eligible = false });
|
||||
|
||||
// 2. User identity
|
||||
string tenantId = TenantHelper.GetTenantId(req);
|
||||
@@ -86,17 +86,17 @@ namespace AutopilotMonitor.Functions.Functions.Feedback
|
||||
var roleInfo = EntraAppRoleResolver.Resolve(
|
||||
tableState, tableRole, context.GetUser()?.GetAppRoles(), tenantConfig.EntraAppRolesEnabled);
|
||||
if (roleInfo == null || roleInfo.Role == Constants.TenantRoles.Viewer)
|
||||
return await WriteJson(req, new { eligible = false });
|
||||
return await WriteJson(req, new FeedbackEligibilityResponse { Eligible = false });
|
||||
|
||||
// 4. Tenant age check
|
||||
if (tenantConfig.OnboardedAt == null ||
|
||||
(DateTime.UtcNow - tenantConfig.OnboardedAt.Value).TotalDays < adminConfig.FeedbackMinTenantAgeDays)
|
||||
return await WriteJson(req, new { eligible = false });
|
||||
return await WriteJson(req, new FeedbackEligibilityResponse { Eligible = false });
|
||||
|
||||
// 5. Sessions check — at least 1 session exists.
|
||||
var sessionPage = sessionsTask.Result;
|
||||
if (sessionPage.Items.Count == 0)
|
||||
return await WriteJson(req, new { eligible = false });
|
||||
return await WriteJson(req, new FeedbackEligibilityResponse { Eligible = false });
|
||||
|
||||
// 6. Cooldown check
|
||||
var feedbackEntry = feedbackTask.Result;
|
||||
@@ -104,14 +104,14 @@ namespace AutopilotMonitor.Functions.Functions.Feedback
|
||||
{
|
||||
// Cooldown = 0 means single wave only
|
||||
if (adminConfig.FeedbackCooldownDays == 0)
|
||||
return await WriteJson(req, new { eligible = false });
|
||||
return await WriteJson(req, new FeedbackEligibilityResponse { Eligible = false });
|
||||
|
||||
var daysSinceInteraction = (DateTime.UtcNow - feedbackEntry.InteractedAt.Value).TotalDays;
|
||||
if (daysSinceInteraction < adminConfig.FeedbackCooldownDays)
|
||||
return await WriteJson(req, new { eligible = false });
|
||||
return await WriteJson(req, new FeedbackEligibilityResponse { Eligible = false });
|
||||
}
|
||||
|
||||
return await WriteJson(req, new { eligible = true });
|
||||
return await WriteJson(req, new FeedbackEligibilityResponse { Eligible = true });
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
@@ -122,7 +122,7 @@ namespace AutopilotMonitor.Functions.Functions.Feedback
|
||||
{
|
||||
_logger.LogError(ex, "Error checking feedback status");
|
||||
// Fail closed — don't show bubble on errors
|
||||
return await WriteJson(req, new { eligible = false });
|
||||
return await WriteJson(req, new FeedbackEligibilityResponse { Eligible = false });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +197,7 @@ namespace AutopilotMonitor.Functions.Functions.Feedback
|
||||
_logger.LogInformation("Feedback {Action} by {Upn} (tenant {TenantId})",
|
||||
body.Dismissed ? "dismissed" : "submitted", upn, tenantId);
|
||||
|
||||
return await WriteJson(req, new { success = true });
|
||||
return await WriteJson(req, new SuccessOnlyResponse { Success = true });
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
@@ -222,22 +222,22 @@ namespace AutopilotMonitor.Functions.Functions.Feedback
|
||||
try
|
||||
{
|
||||
var allEntries = await _feedbackRepo.GetAllAsync();
|
||||
var entries = allEntries.Select(e => new
|
||||
var entries = allEntries.Select(e => new FeedbackEntryWire
|
||||
{
|
||||
type = e.Type,
|
||||
upn = e.Upn,
|
||||
tenantId = e.TenantId,
|
||||
displayName = e.DisplayName,
|
||||
rating = e.Rating,
|
||||
comment = e.Comment,
|
||||
dismissed = e.Dismissed,
|
||||
submitted = e.Submitted,
|
||||
interactedAt = e.InteractedAt?.ToString("o"),
|
||||
historyRowKey = e.HistoryRowKey,
|
||||
domainName = e.DomainName,
|
||||
Type = e.Type,
|
||||
Upn = e.Upn,
|
||||
TenantId = e.TenantId,
|
||||
DisplayName = e.DisplayName,
|
||||
Rating = e.Rating,
|
||||
Comment = e.Comment,
|
||||
Dismissed = e.Dismissed,
|
||||
Submitted = e.Submitted,
|
||||
InteractedAt = e.InteractedAt?.ToString("o"),
|
||||
HistoryRowKey = e.HistoryRowKey,
|
||||
DomainName = e.DomainName,
|
||||
}).ToList();
|
||||
|
||||
return await WriteJson(req, new { feedback = entries });
|
||||
return await WriteJson(req, new FeedbackListResponse { Feedback = entries });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -248,7 +248,11 @@ namespace AutopilotMonitor.Functions.Functions.Feedback
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<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);
|
||||
await response.WriteAsJsonAsync(data);
|
||||
|
||||
+30
-38
@@ -129,10 +129,10 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware
|
||||
var tenantId = TenantHelper.GetTenantId(req);
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var days = DeviceJourneyMetricsResponse.ClampDays(query["days"]);
|
||||
var days = DeviceJourneyMetricsResponseBuilder.ClampDays(query["days"]);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(await DeviceJourneyMetricsResponse.BuildAsync(
|
||||
await response.WriteAsJsonAsync(await DeviceJourneyMetricsResponseBuilder.BuildAsync(
|
||||
_metricsRepo, _sessionRepo, tenantId, days, includeRepeatDevices: true));
|
||||
return response;
|
||||
}
|
||||
@@ -177,11 +177,11 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
// Authentication + GlobalReadOrAdmin authorization enforced by PolicyEnforcementMiddleware
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var tenantIdFilter = query["tenantId"];
|
||||
var days = DeviceJourneyMetricsResponse.ClampDays(query["days"]);
|
||||
var days = DeviceJourneyMetricsResponseBuilder.ClampDays(query["days"]);
|
||||
var partition = string.IsNullOrWhiteSpace(tenantIdFilter) ? "global" : tenantIdFilter!;
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(await DeviceJourneyMetricsResponse.BuildAsync(
|
||||
await response.WriteAsJsonAsync(await DeviceJourneyMetricsResponseBuilder.BuildAsync(
|
||||
_metricsRepo, _sessionRepo, partition, days, includeRepeatDevices: partition != "global"));
|
||||
return response;
|
||||
}
|
||||
@@ -195,8 +195,13 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Shared response shape for the tenant and global variants (mirrors TimeAttributionResponse).</summary>
|
||||
internal static class DeviceJourneyMetricsResponse
|
||||
/// <summary>
|
||||
/// 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 MaxWindowDays = 180; // aggregate retention — older rows no longer exist
|
||||
@@ -215,7 +220,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
/// </summary>
|
||||
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,
|
||||
string partition, int days, bool includeRepeatDevices)
|
||||
{
|
||||
@@ -223,12 +228,12 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
var daily = await metricsRepo.GetDeviceJourneyAggregatesAsync(partition, InclusiveWindowStart(today, days), today);
|
||||
var totals = SumAggregates(daily);
|
||||
|
||||
List<object>? repeatDevices = null;
|
||||
List<DeviceJourneyRepeatDevice>? repeatDevices = null;
|
||||
if (includeRepeatDevices)
|
||||
{
|
||||
var histories = await metricsRepo.GetDeviceHistoriesByTenantAsync(partition);
|
||||
var candidates = SelectRepeatDevices(histories, today.AddDays(-days));
|
||||
repeatDevices = new List<object>(candidates.Count);
|
||||
repeatDevices = new List<DeviceJourneyRepeatDevice>(candidates.Count);
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
// Failure reason of the newest failed attempt — a bounded point-read per top
|
||||
@@ -246,44 +251,31 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
// fail-soft — the row renders without a reason
|
||||
}
|
||||
}
|
||||
repeatDevices.Add(new
|
||||
repeatDevices.Add(new DeviceJourneyRepeatDevice
|
||||
{
|
||||
serialNumber = candidate.History.SerialNumber,
|
||||
manufacturer = candidate.History.Manufacturer,
|
||||
model = candidate.History.Model,
|
||||
attempts = candidate.History.CurrentJourneyAttempts,
|
||||
journeyCount = candidate.History.JourneyCount,
|
||||
lastStatus = candidate.Newest.Status,
|
||||
lastSessionId = candidate.Newest.SessionId,
|
||||
lastStartedAt = candidate.Newest.StartedAt,
|
||||
lastFailureReason,
|
||||
SerialNumber = candidate.History.SerialNumber,
|
||||
Manufacturer = candidate.History.Manufacturer,
|
||||
Model = candidate.History.Model,
|
||||
Attempts = candidate.History.CurrentJourneyAttempts,
|
||||
JourneyCount = candidate.History.JourneyCount,
|
||||
LastStatus = candidate.Newest.Status,
|
||||
LastSessionId = candidate.Newest.SessionId,
|
||||
LastStartedAt = candidate.Newest.StartedAt,
|
||||
LastFailureReason = lastFailureReason,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return new
|
||||
return new DeviceJourneyMetricsResponse
|
||||
{
|
||||
success = true,
|
||||
windowDays = days,
|
||||
totals,
|
||||
daily = daily.OrderBy(d => d.Date, StringComparer.Ordinal).ToList(),
|
||||
repeatDevices,
|
||||
Success = true,
|
||||
WindowDays = days,
|
||||
Totals = totals,
|
||||
Daily = daily.OrderBy(d => d.Date, StringComparer.Ordinal).ToList(),
|
||||
RepeatDevices = repeatDevices,
|
||||
};
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// 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
|
||||
|
||||
+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
|
||||
/// is investigated.
|
||||
///
|
||||
/// Returned as <see cref="object"/> on purpose — System.Text.Json serializes an
|
||||
/// `object` declared type using the RUNTIME type, so both branches keep the exact
|
||||
/// wire format they had while the projection was inline.
|
||||
/// Returned as <c>IReadOnlyList<object></c> — System.Text.Json serializes each
|
||||
/// element using its RUNTIME type, so both branches keep the exact wire format they
|
||||
/// had while the projection was inline. Both element types are manifest-exported
|
||||
/// (<see cref="ImeVersionHistoryEntry"/> / <see cref="ImeVersionHistoryLeanEntry"/>).
|
||||
/// </summary>
|
||||
public static object BuildResponsePayload(
|
||||
public static IReadOnlyList<object> BuildResponsePayload(
|
||||
IEnumerable<ImeVersionHistoryEntry> versions, bool hasGlobalScope)
|
||||
{
|
||||
if (hasGlobalScope)
|
||||
@@ -80,7 +81,13 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
|
||||
return versions
|
||||
.Where(IsFleetConfirmed)
|
||||
.Select(v => new { v.Version, v.FirstSeenAt, v.LastSeenAt, v.SessionCount })
|
||||
.Select(v => new ImeVersionHistoryLeanEntry
|
||||
{
|
||||
Version = v.Version,
|
||||
FirstSeenAt = v.FirstSeenAt,
|
||||
LastSeenAt = v.LastSeenAt,
|
||||
SessionCount = v.SessionCount,
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
|
||||
+14
-10
@@ -93,7 +93,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware
|
||||
var tenantId = TenantHelper.GetTenantId(req);
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(await TimeAttributionResponse.BuildAsync(_metricsRepo, tenantId));
|
||||
await response.WriteAsJsonAsync(await TimeAttributionResponseBuilder.BuildAsync(_metricsRepo, tenantId));
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -132,7 +132,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
var partition = string.IsNullOrWhiteSpace(tenantIdFilter) ? "global" : tenantIdFilter!;
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(await TimeAttributionResponse.BuildAsync(_metricsRepo, partition));
|
||||
await response.WriteAsJsonAsync(await TimeAttributionResponseBuilder.BuildAsync(_metricsRepo, partition));
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -145,8 +145,12 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Shared response shape for the tenant and global variants (mirrors the MetricsMath single-source pattern).</summary>
|
||||
internal static class TimeAttributionResponse
|
||||
/// <summary>
|
||||
/// 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;
|
||||
|
||||
@@ -164,7 +168,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
/// </summary>
|
||||
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 rolling = (await metricsRepo.GetRollingTimeAttributionAggregatesAsync(partition))
|
||||
@@ -173,14 +177,14 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
var today = DateTime.UtcNow.Date;
|
||||
var daily = await metricsRepo.GetTimeAttributionAggregatesAsync(partition, InclusiveWindowStart(today, WindowDays), today);
|
||||
|
||||
return new
|
||||
return new TimeAttributionMetricsResponse
|
||||
{
|
||||
success = true,
|
||||
windowDays = WindowDays,
|
||||
Success = true,
|
||||
WindowDays = WindowDays,
|
||||
// Range statistics per enrollment class (never mixed) — the UI gates rendering
|
||||
// at cleanSessionCount >= 20 and shows "insufficient data (n=…)" below it.
|
||||
classes = rolling.OrderBy(r => r.EnrollmentClass, StringComparer.Ordinal).ToList(),
|
||||
daily = daily.OrderBy(d => d.Date, StringComparer.Ordinal).ToList(),
|
||||
Classes = rolling.OrderBy(r => r.EnrollmentClass, StringComparer.Ordinal).ToList(),
|
||||
Daily = daily.OrderBy(d => d.Date, StringComparer.Ordinal).ToList(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+38
-49
@@ -42,7 +42,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
// Authentication + GlobalReadOrAdmin authorization enforced by PolicyEnforcementMiddleware
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var tenantIdFilter = query["tenantId"];
|
||||
var days = VerdictCalibrationResponse.ClampDays(query["days"]);
|
||||
var days = VerdictCalibrationResponseBuilder.ClampDays(query["days"]);
|
||||
var partition = "global";
|
||||
if (!string.IsNullOrWhiteSpace(tenantIdFilter))
|
||||
{
|
||||
@@ -57,14 +57,14 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
|
||||
var today = DateTime.UtcNow.Date;
|
||||
// One read covers both the requested window and the trend horizon (7d window + 28d baseline).
|
||||
var readDays = Math.Max(days, VerdictCalibrationResponse.TrendHorizonDays);
|
||||
var readDays = Math.Max(days, VerdictCalibrationResponseBuilder.TrendHorizonDays);
|
||||
var daily = await _metricsRepo.GetVerdictCalibrationAggregatesAsync(
|
||||
partition, VerdictCalibrationResponse.InclusiveWindowStart(today, readDays), today);
|
||||
partition, VerdictCalibrationResponseBuilder.InclusiveWindowStart(today, readDays), today);
|
||||
// Active drift episodes of this partition (tracker keyspace; fail-soft empty).
|
||||
var alerts = await _tracker.GetVerdictCalibrationAlertsAsync(partition);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(VerdictCalibrationResponse.Build(daily, partition, today, days, alerts));
|
||||
await response.WriteAsJsonAsync(VerdictCalibrationResponseBuilder.Build(daily, partition, today, days, alerts));
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -77,8 +77,12 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Pure response builder — internal static so the matrix arithmetic is pinned by tests.</summary>
|
||||
internal static class VerdictCalibrationResponse
|
||||
/// <summary>
|
||||
/// 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 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>
|
||||
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>
|
||||
internal const int MinEligibleForRate = 20;
|
||||
|
||||
internal static object Build(
|
||||
internal static VerdictCalibrationResponse Build(
|
||||
IReadOnlyList<VerdictCalibrationDailyAggregate> daily, string partition, DateTime today, int days,
|
||||
IReadOnlyList<VerdictCalibrationAlert>? alerts = null)
|
||||
{
|
||||
@@ -133,18 +110,18 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
var trendWindowStart = Key(InclusiveWindowStart(today, TrendWindowDays));
|
||||
var baselineStart = Key(InclusiveWindowStart(today, TrendHorizonDays));
|
||||
|
||||
var rows = new Dictionary<(string Path, string Status), PathRow>();
|
||||
var rows = new Dictionary<(string Path, string Status), VerdictCalibrationPathRow>();
|
||||
int sessions = 0, terminal = 0, derived = 0;
|
||||
int trendSessions = 0, baselineSessions = 0;
|
||||
DateTime? computedAt = null;
|
||||
var versions = new HashSet<int>();
|
||||
|
||||
PathRow Row(VerdictCalibrationBucket b)
|
||||
VerdictCalibrationPathRow Row(VerdictCalibrationBucket b)
|
||||
{
|
||||
var key = (b.VerdictPath, b.Status);
|
||||
if (!rows.TryGetValue(key, out var row))
|
||||
{
|
||||
row = new PathRow { VerdictPath = b.VerdictPath, Status = b.Status };
|
||||
row = new VerdictCalibrationPathRow { VerdictPath = b.VerdictPath, Status = b.Status };
|
||||
rows[key] = row;
|
||||
}
|
||||
return row;
|
||||
@@ -206,20 +183,32 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
: null;
|
||||
}
|
||||
|
||||
return new
|
||||
return new VerdictCalibrationResponse
|
||||
{
|
||||
success = true,
|
||||
tenantId = partition,
|
||||
windowDays = days,
|
||||
windowStart,
|
||||
windowEnd = Key(today),
|
||||
computedAt,
|
||||
versions = versions.OrderBy(v => v).ToArray(),
|
||||
totals = new { sessions, terminal, derived, days = daily.Count(d => string.CompareOrdinal(d.Date, windowStart) >= 0) },
|
||||
trend = new { windowDays = TrendWindowDays, baselineDays = TrendBaselineDays, windowSessions = trendSessions, baselineSessions },
|
||||
paths = list,
|
||||
Success = true,
|
||||
TenantId = partition,
|
||||
WindowDays = days,
|
||||
WindowStart = windowStart,
|
||||
WindowEnd = Key(today),
|
||||
ComputedAt = computedAt,
|
||||
Versions = versions.OrderBy(v => v).ToArray(),
|
||||
Totals = new VerdictCalibrationTotals
|
||||
{
|
||||
Sessions = sessions,
|
||||
Terminal = terminal,
|
||||
Derived = derived,
|
||||
Days = daily.Count(d => string.CompareOrdinal(d.Date, windowStart) >= 0),
|
||||
},
|
||||
Trend = new VerdictCalibrationTrendMeta
|
||||
{
|
||||
WindowDays = TrendWindowDays,
|
||||
BaselineDays = TrendBaselineDays,
|
||||
WindowSessions = trendSessions,
|
||||
BaselineSessions = baselineSessions,
|
||||
},
|
||||
Paths = list,
|
||||
// Wording contract shared with the rule radar: a dimension is correlated, not causal.
|
||||
alerts = (alerts ?? Array.Empty<VerdictCalibrationAlert>())
|
||||
Alerts = (alerts ?? Array.Empty<VerdictCalibrationAlert>())
|
||||
.OrderByDescending(a => a.FirstNotifiedAt)
|
||||
.ToList(),
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -29,7 +30,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
}
|
||||
|
||||
/// <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).
|
||||
/// </summary>
|
||||
[Function("GetRuleStats")]
|
||||
@@ -48,55 +49,16 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
tenantId, startDate, endDate);
|
||||
|
||||
var entries = await _metricsRepo.GetRuleStatsAsync(tenantId, startDate, endDate, ruleType);
|
||||
var aggregated = BuildRuleAggregates(entries);
|
||||
|
||||
// Aggregate across dates per ruleId
|
||||
var aggregated = entries
|
||||
.GroupBy(e => e.RuleId)
|
||||
.Select(g => new
|
||||
{
|
||||
ruleId = g.Key,
|
||||
ruleType = g.First().RuleType,
|
||||
ruleTitle = g.First().RuleTitle,
|
||||
category = g.First().Category,
|
||||
severity = g.First().Severity,
|
||||
fireCount = g.Sum(e => e.FireCount),
|
||||
evaluationCount = g.Sum(e => e.EvaluationCount),
|
||||
sessionsEvaluated = g.Sum(e => e.SessionsEvaluated),
|
||||
hitRate = g.Sum(e => e.EvaluationCount) > 0
|
||||
? Math.Round(100.0 * g.Sum(e => e.FireCount) / g.Sum(e => e.EvaluationCount), 1)
|
||||
: 0.0,
|
||||
avgConfidenceScore = g.Sum(e => e.FireCount) > 0
|
||||
? Math.Round((double)g.Sum(e => e.ConfidenceScoreSum) / g.Sum(e => e.FireCount), 1)
|
||||
: 0.0,
|
||||
trend = g.OrderBy(e => e.Date).Select(e => new
|
||||
{
|
||||
date = e.Date,
|
||||
fireCount = e.FireCount,
|
||||
evaluationCount = e.EvaluationCount
|
||||
})
|
||||
})
|
||||
.OrderByDescending(r => r.fireCount)
|
||||
.ToList();
|
||||
|
||||
var totalEvaluations = aggregated.Sum(r => r.evaluationCount);
|
||||
var totalFires = aggregated.Sum(r => r.fireCount);
|
||||
|
||||
var result = new
|
||||
var result = new RuleStatsResponse
|
||||
{
|
||||
rules = aggregated,
|
||||
Rules = aggregated,
|
||||
// F3 PR6: active regression episodes (tracker rows) — the rules-page badge
|
||||
// and MCP get_rule_stats read them from here. Empty list = nothing regressed.
|
||||
regressions = await _notificationTracker.GetRuleRegressionsAsync(tenantId),
|
||||
summary = new
|
||||
{
|
||||
totalEvaluations,
|
||||
totalFires,
|
||||
overallHitRate = totalEvaluations > 0
|
||||
? Math.Round(100.0 * totalFires / totalEvaluations, 1)
|
||||
: 0.0,
|
||||
topRuleByFireCount = aggregated.FirstOrDefault()?.ruleId,
|
||||
period = new { start = startDate, end = endDate }
|
||||
}
|
||||
Regressions = await _notificationTracker.GetRuleRegressionsAsync(tenantId),
|
||||
// The tenant route historically carries no uniqueRules key — keep it absent.
|
||||
Summary = BuildSummary(aggregated, startDate, endDate, uniqueRules: null),
|
||||
};
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
@@ -113,7 +75,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
}
|
||||
|
||||
/// <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).
|
||||
/// When tenantId is provided, returns tenant-specific stats instead of global aggregates.
|
||||
/// </summary>
|
||||
@@ -136,57 +98,17 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
queryTenantId, startDate, endDate);
|
||||
|
||||
var entries = await _metricsRepo.GetRuleStatsAsync(queryTenantId, startDate, endDate, ruleType);
|
||||
var aggregated = BuildRuleAggregates(entries);
|
||||
|
||||
var aggregated = entries
|
||||
.GroupBy(e => e.RuleId)
|
||||
.Select(g => new
|
||||
{
|
||||
ruleId = g.Key,
|
||||
ruleType = g.First().RuleType,
|
||||
ruleTitle = g.First().RuleTitle,
|
||||
category = g.First().Category,
|
||||
severity = g.First().Severity,
|
||||
fireCount = g.Sum(e => e.FireCount),
|
||||
evaluationCount = g.Sum(e => e.EvaluationCount),
|
||||
sessionsEvaluated = g.Sum(e => e.SessionsEvaluated),
|
||||
hitRate = g.Sum(e => e.EvaluationCount) > 0
|
||||
? Math.Round(100.0 * g.Sum(e => e.FireCount) / g.Sum(e => e.EvaluationCount), 1)
|
||||
: 0.0,
|
||||
avgConfidenceScore = g.Sum(e => e.FireCount) > 0
|
||||
? Math.Round((double)g.Sum(e => e.ConfidenceScoreSum) / g.Sum(e => e.FireCount), 1)
|
||||
: 0.0,
|
||||
trend = g.OrderBy(e => e.Date).Select(e => new
|
||||
{
|
||||
date = e.Date,
|
||||
fireCount = e.FireCount,
|
||||
evaluationCount = e.EvaluationCount
|
||||
})
|
||||
})
|
||||
.OrderByDescending(r => r.fireCount)
|
||||
.ToList();
|
||||
|
||||
var totalEvaluations = aggregated.Sum(r => r.evaluationCount);
|
||||
var totalFires = aggregated.Sum(r => r.fireCount);
|
||||
|
||||
var result = new
|
||||
var result = new RuleStatsResponse
|
||||
{
|
||||
rules = aggregated,
|
||||
Rules = aggregated,
|
||||
// Regression episodes are tenant-scoped: present when drilling into one
|
||||
// tenant, empty for the cross-tenant "global" aggregate (no episode there).
|
||||
regressions = queryTenantId != "global"
|
||||
Regressions = queryTenantId != "global"
|
||||
? await _notificationTracker.GetRuleRegressionsAsync(queryTenantId)
|
||||
: new List<AutopilotMonitor.Shared.Models.RuleRegressionAlert>(),
|
||||
summary = new
|
||||
{
|
||||
totalEvaluations,
|
||||
totalFires,
|
||||
overallHitRate = totalEvaluations > 0
|
||||
? Math.Round(100.0 * totalFires / totalEvaluations, 1)
|
||||
: 0.0,
|
||||
topRuleByFireCount = aggregated.FirstOrDefault()?.ruleId,
|
||||
uniqueRules = aggregated.Count,
|
||||
period = new { start = startDate, end = endDate }
|
||||
}
|
||||
: new List<RuleRegressionAlert>(),
|
||||
Summary = BuildSummary(aggregated, startDate, endDate, uniqueRules: aggregated.Count),
|
||||
};
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
@@ -201,5 +123,53 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
return errorResponse;
|
||||
}
|
||||
}
|
||||
|
||||
/// <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.Services;
|
||||
using AutopilotMonitor.Functions.Services.Deletion;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Deletion;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
@@ -172,12 +173,12 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
/// </summary>
|
||||
internal static object BuildV2ResponseBody(SessionDeletionEnqueueResult result, string sessionId) => result.Outcome switch
|
||||
{
|
||||
SessionDeletionEnqueueOutcome.Enqueued => new
|
||||
SessionDeletionEnqueueOutcome.Enqueued => new SessionDeletionQueuedResponse
|
||||
{
|
||||
success = true,
|
||||
status = "queued",
|
||||
manifestId = result.ManifestId,
|
||||
message = "Cascade deletion queued; worker will drain asynchronously.",
|
||||
Success = true,
|
||||
Status = "queued",
|
||||
ManifestId = result.ManifestId,
|
||||
Message = "Cascade deletion queued; worker will drain asynchronously.",
|
||||
},
|
||||
SessionDeletionEnqueueOutcome.AlreadyInFlight => new
|
||||
{
|
||||
|
||||
+13
-12
@@ -5,6 +5,7 @@ using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services.Deletion;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -170,19 +171,19 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
{
|
||||
var status = MapOutcomeToStatus(result.Outcome);
|
||||
|
||||
var body = new
|
||||
var body = new SessionRestoreResponse
|
||||
{
|
||||
success = status == HttpStatusCode.OK,
|
||||
outcome = result.Outcome.ToString(),
|
||||
mode = result.Mode,
|
||||
message = result.Message,
|
||||
currentState = result.CurrentState,
|
||||
pendingManifestId = result.PendingManifestId,
|
||||
rowsRestoredByTable = result.RowsRestoredByTable,
|
||||
rowsSkippedByTable = result.RowsSkippedByTable,
|
||||
wouldRestoreByTable = result.WouldRestoreByTable,
|
||||
inventoryReIncrements = result.InventoryReIncrements,
|
||||
durationMs = result.DurationMs,
|
||||
Success = status == HttpStatusCode.OK,
|
||||
Outcome = result.Outcome.ToString(),
|
||||
Mode = result.Mode,
|
||||
Message = result.Message,
|
||||
CurrentState = result.CurrentState,
|
||||
PendingManifestId = result.PendingManifestId,
|
||||
RowsRestoredByTable = result.RowsRestoredByTable,
|
||||
RowsSkippedByTable = result.RowsSkippedByTable,
|
||||
WouldRestoreByTable = result.WouldRestoreByTable,
|
||||
InventoryReIncrements = result.InventoryReIncrements,
|
||||
DurationMs = result.DurationMs,
|
||||
};
|
||||
|
||||
return await WriteJsonAsync(req, status, body);
|
||||
|
||||
@@ -71,7 +71,7 @@ public static class MetricsMath
|
||||
/// final status. Peer bytes and Microsoft Connected Cache (MCC) bytes are reported separately —
|
||||
/// MCC is counted apart from peers by DO — and offload% credits both as "not pulled from the CDN".
|
||||
/// </summary>
|
||||
public static object BuildAppMetricsPayload(IEnumerable<AppInstallSummary> summaries)
|
||||
public static AppMetricsResponse BuildAppMetricsPayload(IEnumerable<AppInstallSummary> summaries)
|
||||
{
|
||||
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.
|
||||
var doG = DoAggregator.Compute(g);
|
||||
|
||||
return new
|
||||
return new AppMetricsAppGroup
|
||||
{
|
||||
appName = g.Key,
|
||||
totalInstalls = total,
|
||||
succeeded = installed.Count,
|
||||
skipped = skipped.Count,
|
||||
unmeasured = installed.Count - measured.Count,
|
||||
failed = failed.Count,
|
||||
AppName = g.Key,
|
||||
TotalInstalls = total,
|
||||
Succeeded = installed.Count,
|
||||
Skipped = skipped.Count,
|
||||
Unmeasured = installed.Count - measured.Count,
|
||||
Failed = failed.Count,
|
||||
// Skips leave the rate (they are not attempts); legacy rows stay in it.
|
||||
failureRate = TerminalFailureRatePct(failed.Count, installed.Count),
|
||||
avgDurationSeconds = measured.Count > 0 ? Math.Round(measured.Average(s => s.DurationSeconds), 0) : 0,
|
||||
maxDurationSeconds = measured.Count > 0 ? measured.Max(s => s.DurationSeconds) : 0,
|
||||
measuredInstalls = measured.Count,
|
||||
avgDownloadBytes = measured.Count > 0 ? (long)measured.Average(s => s.DownloadBytes) : 0,
|
||||
doTotalBytesDownloaded = doG.TotalBytesDownloaded,
|
||||
doBytesFromPeers = doG.BytesFromPeers,
|
||||
doBytesFromCacheServer = doG.BytesFromCacheServer,
|
||||
doBytesFromHttp = doG.BytesFromHttp,
|
||||
peerOffloadPercent = OffloadPercent(doG.BytesFromPeers + doG.BytesFromCacheServer, doG.TotalBytesDownloaded),
|
||||
topFailureCodes = failed
|
||||
FailureRate = TerminalFailureRatePct(failed.Count, installed.Count),
|
||||
AvgDurationSeconds = measured.Count > 0 ? Math.Round(measured.Average(s => s.DurationSeconds), 0) : 0,
|
||||
MaxDurationSeconds = measured.Count > 0 ? measured.Max(s => s.DurationSeconds) : 0,
|
||||
MeasuredInstalls = measured.Count,
|
||||
AvgDownloadBytes = measured.Count > 0 ? (long)measured.Average(s => s.DownloadBytes) : 0,
|
||||
DoTotalBytesDownloaded = doG.TotalBytesDownloaded,
|
||||
DoBytesFromPeers = doG.BytesFromPeers,
|
||||
DoBytesFromCacheServer = doG.BytesFromCacheServer,
|
||||
DoBytesFromHttp = doG.BytesFromHttp,
|
||||
PeerOffloadPercent = OffloadPercent(doG.BytesFromPeers + doG.BytesFromCacheServer, doG.TotalBytesDownloaded),
|
||||
TopFailureCodes = failed
|
||||
.Where(f => !string.IsNullOrEmpty(f.FailureCode))
|
||||
.GroupBy(f => f.FailureCode)
|
||||
.OrderByDescending(fc => fc.Count())
|
||||
.Take(3)
|
||||
.Select(fc => new { code = fc.Key, count = fc.Count() })
|
||||
.Select(fc => new AppFailureCodeCount { Code = fc.Key, Count = fc.Count() })
|
||||
.ToList()
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
// Slowest ranking gates on MEASURED installs — an app whose durations were mostly
|
||||
// unobserved must not rank as "fast" on a handful of zeros (audit §0.5).
|
||||
var slowestApps = SelectSlowestApps(
|
||||
appGroups, a => a.measuredInstalls, a => (double)a.avgDurationSeconds, minSamples: 3, take: 10);
|
||||
appGroups, a => a.MeasuredInstalls, a => a.AvgDurationSeconds, minSamples: 3, take: 10);
|
||||
|
||||
var topFailingApps = appGroups
|
||||
.Where(a => a.failed > 0)
|
||||
.OrderByDescending(a => a.failed)
|
||||
.ThenByDescending(a => a.failureRate)
|
||||
.Where(a => a.Failed > 0)
|
||||
.OrderByDescending(a => a.Failed)
|
||||
.ThenByDescending(a => a.FailureRate)
|
||||
.Take(10)
|
||||
.ToList();
|
||||
|
||||
var doAll = DoAggregator.Compute(summaryList);
|
||||
|
||||
return new
|
||||
return new AppMetricsResponse
|
||||
{
|
||||
success = true,
|
||||
totalApps = appGroups.Count,
|
||||
totalInstalls = summaryList.Count,
|
||||
totalSkipped = appGroups.Sum(a => a.skipped),
|
||||
totalUnmeasured = appGroups.Sum(a => a.unmeasured),
|
||||
totalCollisionExcluded,
|
||||
slowestApps,
|
||||
topFailingApps,
|
||||
deliveryOptimization = new
|
||||
Success = true,
|
||||
TotalApps = appGroups.Count,
|
||||
TotalInstalls = summaryList.Count,
|
||||
TotalSkipped = appGroups.Sum(a => a.Skipped),
|
||||
TotalUnmeasured = appGroups.Sum(a => a.Unmeasured),
|
||||
TotalCollisionExcluded = totalCollisionExcluded,
|
||||
SlowestApps = slowestApps,
|
||||
TopFailingApps = topFailingApps,
|
||||
DeliveryOptimization = new AppMetricsDeliveryOptimization
|
||||
{
|
||||
totalBytesDownloaded = doAll.TotalBytesDownloaded,
|
||||
fromPeers = doAll.BytesFromPeers,
|
||||
fromCacheServer = doAll.BytesFromCacheServer,
|
||||
fromHttp = doAll.BytesFromHttp,
|
||||
peerOffloadPercent = OffloadPercent(doAll.BytesFromPeers + doAll.BytesFromCacheServer, doAll.TotalBytesDownloaded),
|
||||
TotalBytesDownloaded = doAll.TotalBytesDownloaded,
|
||||
FromPeers = doAll.BytesFromPeers,
|
||||
FromCacheServer = doAll.BytesFromCacheServer,
|
||||
FromHttp = doAll.BytesFromHttp,
|
||||
PeerOffloadPercent = OffloadPercent(doAll.BytesFromPeers + doAll.BytesFromCacheServer, doAll.TotalBytesDownloaded),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
// Shallow copy so flipping FromCache never mutates the instance an earlier
|
||||
// (fresh) caller may still be holding/serializing. The buckets are shared
|
||||
// read-only — nothing downstream writes into a response.
|
||||
return entry.metrics.CloneAsCacheHit();
|
||||
return CloneAsCacheHit(entry.metrics);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,6 +287,21 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}));
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
var sorted = values.OrderBy(v => v).ToList();
|
||||
@@ -303,76 +318,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
}
|
||||
|
||||
// ── Response DTOs ────────────────────────────────────────────────────────────
|
||||
|
||||
public class AgentEfficiencyMetricsResponse
|
||||
{
|
||||
public int WindowDays { get; set; }
|
||||
public int SessionLimit { get; set; }
|
||||
/// <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; }
|
||||
}
|
||||
// Response DTOs (AgentEfficiencyMetricsResponse family) moved to
|
||||
// AutopilotMonitor.Shared.Models (Models/Metrics/AgentPerformanceMetrics.cs) so the
|
||||
// shared manifest exports them as wire types.
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
|
||||
@@ -328,81 +328,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
=> AgentMetricsAggregation.GetString(data, key);
|
||||
}
|
||||
|
||||
// ── Response DTOs ────────────────────────────────────────────────────────────
|
||||
|
||||
public class PlatformAgentMetricsResponse
|
||||
{
|
||||
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; }
|
||||
}
|
||||
// Response DTOs (PlatformAgentMetricsResponse family) moved to
|
||||
// AutopilotMonitor.Shared.Models (Models/Metrics/AgentPerformanceMetrics.cs) so the
|
||||
// shared manifest exports them as wire types.
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,8 +14,12 @@ import type {
|
||||
GetMcpUserUsageResponse,
|
||||
GetMyMcpUsageResponse,
|
||||
GetUnmatchedSoftwareResponse,
|
||||
PlatformAgentMetricsResponse,
|
||||
QueryRawTableResponse,
|
||||
RuleStatsResponse,
|
||||
RuleStatsRuleAggregate,
|
||||
SoftwareInventoryResponse,
|
||||
VerdictCalibrationResponse,
|
||||
} from '../generated/wire-types.generated.js';
|
||||
|
||||
/**
|
||||
@@ -457,31 +461,12 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
},
|
||||
async (args) => withToolTelemetry('get_platform_metrics', args, async () => {
|
||||
try {
|
||||
type SessionMetric = {
|
||||
sessionId: string; tenantId: string; deviceName?: string; model?: string; status?: string;
|
||||
agentVersion?: string; snapshotCount: number;
|
||||
avgCpu: number; maxCpu: number; avgWorkingSet: number; maxWorkingSet: number;
|
||||
avgPrivateBytes: number; avgLatency: number;
|
||||
totalBytesUp: number; totalBytesDown: number; totalRequests: number;
|
||||
avgSpoolDepth: number; maxSpoolDepth: number; peakSpoolDepth: number;
|
||||
maxSpoolFileBytes: number; totalEventsEmitted: number; spoolPressureDetected: boolean;
|
||||
};
|
||||
// Backend fans out one storage query per session (bounded to 32 concurrent), so
|
||||
// 2000-session windows take longer than the default 30s client timeout — same
|
||||
// override precedent as get_session_summary. Warm (cached) calls return instantly.
|
||||
const raw = await apiFetch(
|
||||
`/api/global/metrics/platform${buildQuery({ days: args.days, limit: args.maxSessions })}`,
|
||||
{ signal: AbortSignal.timeout(90_000) }) as
|
||||
{
|
||||
sessions?: SessionMetric[]; windowDays?: number; sessionLimit?: number; sessionsScanned?: number;
|
||||
deliveryLatency?: { p50Ms: number; p95Ms: number; p99Ms: number; avgMs: number; sampleCount: number; clockSkewPercent: number };
|
||||
crashRate?: {
|
||||
totalStarts: number; cleanExits: number; exceptionCrashes: number; hardKills: number;
|
||||
rebootKills: number; firstRuns: number; crashRatePercent: number;
|
||||
topExceptions?: { exceptionType: string; count: number }[];
|
||||
};
|
||||
computedAt?: string; computeDurationMs?: number; fromCache?: boolean;
|
||||
};
|
||||
{ signal: AbortSignal.timeout(90_000) }) as PlatformAgentMetricsResponse;
|
||||
const sessions = raw?.sessions ?? [];
|
||||
const requested = { days: args.days, limit: args.maxSessions };
|
||||
// Backend-computed blocks that exist independently of snapshot availability
|
||||
@@ -898,7 +883,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
async (args) => withToolTelemetry('get_verdict_calibration', args, async () => {
|
||||
try {
|
||||
const { tenantId, days, minSharePct, top } = args;
|
||||
const data = await apiFetch(`/api/global/metrics/verdict-calibration${buildQuery({ tenantId, days })}`);
|
||||
const data = await apiFetch(`/api/global/metrics/verdict-calibration${buildQuery({ tenantId, days })}`) as VerdictCalibrationResponse;
|
||||
return toolResultText(shapeVerdictCalibration(data, { minSharePct, top }), MAX_RESULT_SIZE_CHARS.adminStream);
|
||||
} catch (error: unknown) {
|
||||
return toolError('get_verdict_calibration', args, error);
|
||||
@@ -1490,15 +1475,15 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
};
|
||||
if (tenantId) params.tenantId = tenantId;
|
||||
const prefix = pickGlobalOrTenantPath('/api/global/metrics', '/api/metrics', tenantId);
|
||||
const data = await apiFetch(`${prefix}/rule-stats${buildQuery(params)}`) as {
|
||||
rules?: Array<Record<string, unknown>>;
|
||||
trendsNote?: string;
|
||||
};
|
||||
const data = await apiFetch(`${prefix}/rule-stats${buildQuery(params)}`) as
|
||||
RuleStatsResponse & { trendsNote?: string };
|
||||
// The per-rule daily trend arrays are the size driver (one row per rule
|
||||
// per day; a 30-day platform-scope response reaches 150k+ chars and gets
|
||||
// host-truncated). Aggregates stay intact — trends only on request.
|
||||
// (Dropping `trend` and adding `trendsNote` is this tool's OUTPUT contract,
|
||||
// layered on the wire type — hence the Partial view for the delete.)
|
||||
if (!args.includeTrends && Array.isArray(data?.rules)) {
|
||||
for (const rule of data.rules) delete rule.trend;
|
||||
for (const rule of data.rules) delete (rule as Partial<RuleStatsRuleAggregate>).trend;
|
||||
data.trendsNote = 'Per-rule daily trend rows omitted. Re-call with includeTrends=true (plus a narrow startDate/endDate window) to include them.';
|
||||
}
|
||||
return toolResultText(data, MAX_RESULT_SIZE_CHARS.small);
|
||||
|
||||
@@ -12,21 +12,23 @@
|
||||
* in `omitted` so a filtered response never reads as complete coverage.
|
||||
*/
|
||||
|
||||
interface TrendCell {
|
||||
count: number;
|
||||
import type {
|
||||
VerdictCalibrationPathRow as WirePathRow,
|
||||
VerdictCalibrationTrendWindow as WireTrendWindow,
|
||||
} from './generated/wire-types.generated.js';
|
||||
|
||||
// Derived from the generated wire types so a backend rename of any read field goes
|
||||
// tsc-red here; the deltas below are THIS tool's output contract, not the wire.
|
||||
interface TrendCell extends Omit<WireTrendWindow, 'sessions'> {
|
||||
/** Dropped in shaped output — the denominators live once in `trend`. */
|
||||
sessions?: number;
|
||||
sharePct: number;
|
||||
}
|
||||
|
||||
export interface VerdictCalibrationPathRow {
|
||||
verdictPath: string;
|
||||
status: string;
|
||||
count: number;
|
||||
sharePct: number;
|
||||
overriddenByAdmin: number;
|
||||
overriddenByLateCompletion: number;
|
||||
overriddenOther: number;
|
||||
export interface VerdictCalibrationPathRow
|
||||
extends Omit<WirePathRow, 'reEnrollRatePct' | 'lift' | 'window7' | 'baseline28'> {
|
||||
/** Explicit null in shaped output (wire omits the key when withheld). */
|
||||
reEnrollRatePct?: number | null;
|
||||
/** Explicit null in shaped output (wire omits the key when withheld). */
|
||||
lift?: number | null;
|
||||
window7: TrendCell;
|
||||
baseline28: TrendCell;
|
||||
|
||||
@@ -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
|
||||
/// client; this payload carries the data, not the formatting.
|
||||
/// </summary>
|
||||
public sealed class FleetHealthMetrics
|
||||
public sealed class FleetHealthMetrics : IApiResponse
|
||||
{
|
||||
public bool Success { get; set; } = true;
|
||||
public int Days { get; set; }
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// <summary>
|
||||
/// Response containing geographic performance metrics aggregated by location.
|
||||
/// </summary>
|
||||
public class GeographicMetricsResponse
|
||||
public class GeographicMetricsResponse : IApiResponse
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
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>
|
||||
public class ImePatternHealthResponse
|
||||
public class ImePatternHealthResponse : IApiResponse
|
||||
{
|
||||
public string? BaselineVersion { 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>
|
||||
/// SLA metrics response for a given tenant and time window.
|
||||
/// </summary>
|
||||
public class SlaMetricsResponse
|
||||
public class SlaMetricsResponse : IApiResponse
|
||||
{
|
||||
// Target echo (so the dashboard knows the configured targets)
|
||||
public decimal? TargetSuccessRate { get; set; }
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// <summary>
|
||||
/// Platform usage metrics response
|
||||
/// </summary>
|
||||
public class PlatformUsageMetrics
|
||||
public class PlatformUsageMetrics : IApiResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// 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; }
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// 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
|
||||
|
||||
@@ -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