From 325632e7804da65ee700828fc4d29f3eb4230f35 Mon Sep 17 00:00:00 2001
From: Oliver Kieselbach
Date: Wed, 14 Oct 2026 03:14:55 +0200
Subject: [PATCH] Diagnosis: the culprit app in the tenant's other enrollments,
and the rule line beside each finding (D-358)
GET apps/fleet-context (MemberRead, QueryParam) counts one app over the
tenant's last 7 days: installed, skipped, failed, incomplete, in progress,
collision rows left out, the three most frequent failure codes. It reads
the apps dashboard's partition query, leaves out the asking session and,
with the culprit's app id, another app of the same name; no session point
reads. AppFleetContextService caches the app's rows per tenant, channel,
name and minute for 60 s (single flight).
The classification is the analytics view's: MetricsMath.IsInstalled
replaces six inline copies, AppsAnalyticsHelper.IsSameApp and
TopFailureCodes are shared by analytics, sessions and the fleet context.
The diagnosis page shows the result as a tile in "Around the failure"
only when other enrollments exist (the read fails soft), with "Show
sessions" on the app page where that page shows the viewed tenant. An
expanded finding shows "Fired in N enrollments in the last 14 days"
through useRuleFireStats and RecentFiresLine, both extracted from the
session page, whose cross-tenant check now ignores case.
Co-Authored-By: Claude Opus 5.5
---
.../AppFleetContextTests.cs | 183 ++++++++++++++++++
.../FreeTextNameQueryParamTests.cs | 16 ++
.../TypedContractCompletionWireParityTests.cs | 70 +++++++
.../Functions/Apps/AppsAnalyticsHelper.cs | 109 ++++++++---
.../Apps/GetAppFleetContextFunction.cs | 60 ++++++
.../Helpers/MetricsMath.cs | 7 +
.../AutopilotMonitor.Functions/Program.cs | 2 +
.../Security/EndpointAccessPolicyCatalog.cs | 3 +
.../Services/Apps/AppFleetContextService.cs | 44 +++++
.../src/generated/wire-types.generated.ts | 21 ++
.../Models/Metrics/AppsDashboardApiModels.cs | 26 +++
.../diagnosis/components/ContextSection.tsx | 69 ++++---
.../diagnosis/components/FindingsSection.tsx | 26 ++-
.../app/diagnosis/hooks/useAppFleetContext.ts | 49 +++++
.../lib/__tests__/fleetContext.test.ts | 85 ++++++++
.../app/diagnosis/lib/fleetContext.ts | 71 +++++++
.../app/diagnosis/page.tsx | 37 +++-
.../components/AnalysisResultsSection.tsx | 66 +------
.../app/sessions/hooks/useRuleFireStats.ts | 62 ++++++
.../components/findings/RecentFiresLine.tsx | 29 +++
.../lib/__tests__/apiPathEncoding.test.ts | 8 +
src/Web/autopilot-monitor-web/lib/api.ts | 10 +
.../lib/generated/shared-manifests.json | 92 +++++++++
.../lib/generated/wire-types.generated.ts | 21 ++
24 files changed, 1052 insertions(+), 114 deletions(-)
create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/AppFleetContextTests.cs
create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetAppFleetContextFunction.cs
create mode 100644 src/Backend/AutopilotMonitor.Functions/Services/Apps/AppFleetContextService.cs
create mode 100644 src/Web/autopilot-monitor-web/app/diagnosis/hooks/useAppFleetContext.ts
create mode 100644 src/Web/autopilot-monitor-web/app/diagnosis/lib/__tests__/fleetContext.test.ts
create mode 100644 src/Web/autopilot-monitor-web/app/diagnosis/lib/fleetContext.ts
create mode 100644 src/Web/autopilot-monitor-web/app/sessions/hooks/useRuleFireStats.ts
create mode 100644 src/Web/autopilot-monitor-web/components/findings/RecentFiresLine.tsx
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AppFleetContextTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AppFleetContextTests.cs
new file mode 100644
index 00000000..26611ce3
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/AppFleetContextTests.cs
@@ -0,0 +1,183 @@
+using System.Net;
+using System.Text.Json;
+using AutopilotMonitor.Functions.Functions.Apps;
+using AutopilotMonitor.Functions.Services.Apps;
+using AutopilotMonitor.Shared.DataAccess;
+using AutopilotMonitor.Shared.Models;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// The diagnosis page's fleet line: one app's outcomes in the tenant's other enrollments of the last
+/// week, classified like the apps analytics view, without the asking session and without another app
+/// of the same name.
+///
+public class AppFleetContextTests
+{
+ private const string TenantId = "00000000-0000-4000-8000-0000000000b1";
+ private const string NotesId = "00000000-0000-4000-8000-0000000000a1";
+ private const string OtherAppId = "00000000-0000-4000-8000-0000000000a9";
+ private const string AskingSession = "00000000-0000-4000-8000-00000000000a";
+
+ private static AppInstallSummary Row(
+ string session, string status, string terminal = "", string code = "", string appId = NotesId,
+ bool collision = false, double hoursAgo = 2, int? exitCode = null, string message = "") => new()
+ {
+ TenantId = TenantId,
+ SessionId = session,
+ AppName = "Contoso Notes",
+ AppId = appId,
+ Status = status,
+ TerminalState = terminal,
+ FailureCode = code,
+ FailureMessage = message,
+ ExitCode = exitCode,
+ AppIdCollision = collision,
+ StartedAt = DateTime.UtcNow.AddHours(-hoursAgo),
+ };
+
+ private static GetAppFleetContextResponse Build(IEnumerable rows, string? appId = NotesId, string? exclude = AskingSession)
+ => AppsAnalyticsHelper.BuildFleetContextResponse(rows, "Contoso Notes", AppInstallSources.Ime, appId, exclude, DateTime.UtcNow.AddDays(-7));
+
+ [Fact]
+ public void Counts_the_other_enrollments_like_the_analytics_view()
+ {
+ var context = Build(new[]
+ {
+ Row("s1", "Succeeded", "Installed"),
+ Row("s2", "Succeeded"), // legacy row without a terminal state: an install
+ Row("s3", "Succeeded", "Skipped"),
+ Row("s4", "Succeeded", "Postponed"),
+ Row("s5", "Failed", "Error", "0x80d03805"),
+ Row("s6", "Failed", "Error", "0x80d03805"),
+ Row("s7", "Failed", "Error", "esp_apps_timeout"),
+ Row("s8", "Incomplete"),
+ Row("s9", "InProgress"),
+ });
+
+ Assert.Equal(
+ (2, 2, 3, 1, 1, 0),
+ (context.Installed, context.Skipped, context.Failed, context.Incomplete, context.InProgress, context.CollisionExcluded));
+ Assert.Equal(new[] { ("0x80d03805", 2), ("esp_apps_timeout", 1) }, context.TopFailureCodes.Select(c => (c.Code, c.Count)));
+ Assert.Equal((7, "Contoso Notes", "ime"), (context.WindowDays, context.AppName, context.Source));
+ }
+
+ [Fact]
+ public void Leaves_out_the_asking_session_another_app_and_older_rows_and_counts_collisions()
+ {
+ var context = Build(new[]
+ {
+ Row(AskingSession.ToUpperInvariant(), "Failed", code: "0x80d03805"),
+ Row("s1", "Failed", code: "0x80d03805", appId: OtherAppId),
+ Row("s2", "Succeeded", "Installed", appId: ""), // no app id recorded: same name, kept
+ Row("s3", "Succeeded", "Installed", appId: NotesId.ToUpperInvariant()),
+ Row("s4", "Failed", code: "0x87d1041c", collision: true),
+ Row("s5", "Failed", code: "0x87d1041c", hoursAgo: 24 * 8),
+ });
+
+ Assert.Equal((2, 0, 1), (context.Installed, context.Failed, context.CollisionExcluded));
+ Assert.Empty(context.TopFailureCodes);
+ }
+
+ [Fact]
+ public void Without_an_app_id_every_row_of_the_name_counts()
+ {
+ var context = Build(new[] { Row("s1", "Failed", code: "0x1", appId: OtherAppId), Row("s2", "Succeeded", "Installed") }, appId: null, exclude: null);
+
+ Assert.Equal((1, 1), (context.Installed, context.Failed));
+ }
+
+ [Fact]
+ public void Keeps_the_three_most_frequent_codes_with_their_exit_code_and_message()
+ {
+ var context = Build(new[]
+ {
+ Row("s1", "Failed", code: "0x1"),
+ Row("s2", "Failed", code: "0x2", exitCode: 1603, message: "Fatal error during installation"),
+ Row("s3", "Failed", code: "0x2"),
+ Row("s4", "Failed", code: "0x3"),
+ Row("s5", "Failed", code: "0x3"),
+ Row("s6", "Failed", code: "0x3"),
+ Row("s7", "Failed", code: "0x4"),
+ });
+
+ Assert.Equal(new[] { "0x3", "0x2", "0x1" }, context.TopFailureCodes.Select(c => c.Code));
+ var second = context.TopFailureCodes[1];
+ Assert.Equal((1603, "Fatal error during installation"), (second.ExitCode, second.SampleMessage));
+ }
+
+ [Fact]
+ public async Task Reads_the_rows_once_per_app_and_minute_for_every_caller()
+ {
+ var repo = new Mock();
+ repo.Setup(r => r.GetAppsDashboardSummariesAsync(It.IsAny(), TenantId))
+ .ReturnsAsync(() => new List
+ {
+ Row(AskingSession, "Failed", code: "0x80d03805"),
+ Row("s1", "Failed", code: "0x80d03805"),
+ Row("s2", "Succeeded", "Installed"),
+ });
+ var service = new AppFleetContextService(repo.Object);
+
+ var asking = await service.GetAsync(TenantId, "Contoso Notes", AppInstallSources.Ime, NotesId, AskingSession);
+ var another = await service.GetAsync(TenantId, "contoso notes", AppInstallSources.Ime, NotesId, "s1");
+
+ Assert.Equal((1, 1), (asking.Failed, asking.Installed));
+ Assert.Equal((1, 1), (another.Failed, another.Installed));
+ repo.Verify(r => r.GetAppsDashboardSummariesAsync(It.IsAny(), TenantId), Times.Once);
+ repo.Verify(r => r.GetAppsDashboardSummariesAsync(
+ It.Is(d => d <= DateTime.UtcNow.AddDays(-7) && d > DateTime.UtcNow.AddDays(-7).AddMinutes(-2)), TenantId), Times.Once);
+
+ await service.GetAsync(TenantId, "Contoso Agent", AppInstallSources.Ime, null, AskingSession);
+ repo.Verify(r => r.GetAppsDashboardSummariesAsync(It.IsAny(), TenantId), Times.Exactly(2));
+ }
+
+ [Theory]
+ [InlineData("?source=ime")]
+ [InlineData("?appName=%20")]
+ [InlineData("?appName=Contoso+Notes&source=bogus")]
+ [InlineData("?appName=Contoso+Notes&excludeSessionId=not-a-guid")]
+ public async Task A_request_without_a_name_or_with_a_bad_parameter_is_a_bad_request(string query)
+ {
+ var repo = new Mock();
+ var function = Function(repo.Object);
+
+ var res = await function.Run(EndpointHarness.Request(TenantId, queryString: query).Req);
+
+ Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode);
+ repo.VerifyNoOtherCalls();
+ }
+
+ [Fact]
+ public async Task A_target_tenant_that_is_no_guid_is_a_bad_request()
+ {
+ var repo = new Mock();
+
+ var res = await Function(repo.Object).Run(EndpointHarness.Request("not-a-guid", queryString: "?appName=Contoso+Notes").Req);
+
+ Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode);
+ repo.VerifyNoOtherCalls();
+ }
+
+ [Fact]
+ public async Task Answers_for_the_target_tenant_of_the_request()
+ {
+ var repo = new Mock();
+ repo.Setup(r => r.GetAppsDashboardSummariesAsync(It.IsAny(), TenantId))
+ .ReturnsAsync(new List { Row("s1", "Failed", code: "0x80d03805"), Row(AskingSession, "Failed", code: "0x80d03805") });
+
+ var res = await Function(repo.Object).Run(EndpointHarness.Request(
+ TenantId, queryString: $"?appName=Contoso+Notes&appId={NotesId}&excludeSessionId={AskingSession}&tenantId={TenantId}").Req);
+
+ Assert.Equal(HttpStatusCode.OK, res.StatusCode);
+ res.Body.Position = 0;
+ using var doc = JsonDocument.Parse(res.Body);
+ Assert.Equal(1, doc.RootElement.GetProperty("failed").GetInt32());
+ Assert.Equal("0x80d03805", doc.RootElement.GetProperty("topFailureCodes")[0].GetProperty("code").GetString());
+ }
+
+ private static GetAppFleetContextFunction Function(IMetricsRepository repo)
+ => new(NullLogger.Instance, new AppFleetContextService(repo));
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/FreeTextNameQueryParamTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/FreeTextNameQueryParamTests.cs
index bf3015cf..77f1c003 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/FreeTextNameQueryParamTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/FreeTextNameQueryParamTests.cs
@@ -40,6 +40,7 @@ public class FreeTextNameQueryParamTests
[Theory]
[InlineData("GET", "/api/apps/analytics", "apps/analytics", EndpointPolicy.MemberRead, TenantScoping.None)]
[InlineData("GET", "/api/apps/sessions", "apps/sessions", EndpointPolicy.MemberRead, TenantScoping.None)]
+ [InlineData("GET", "/api/apps/fleet-context", "apps/fleet-context", EndpointPolicy.MemberRead, TenantScoping.QueryParam)]
[InlineData("GET", "/api/global/apps/analytics", "global/apps/analytics", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam)]
[InlineData("GET", "/api/global/apps/sessions", "global/apps/sessions", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam)]
[InlineData("DELETE", "/api/devices/block", "devices/block", EndpointPolicy.GlobalAdminOnly, TenantScoping.None)]
@@ -97,6 +98,21 @@ public class FreeTextNameQueryParamTests
Assert.Equal(1, Json(res).GetProperty("total").GetInt32());
}
+ [Theory]
+ [MemberData(nameof(Names))]
+ public async Task App_fleet_context_matches_the_exact_name(string name, string encoded)
+ {
+ var function = new GetAppFleetContextFunction(
+ NullLogger.Instance, new AutopilotMonitor.Functions.Services.Apps.AppFleetContextService(MetricsWith(name)));
+
+ var res = await function.Run(EndpointHarness.Request(TenantId, queryString: $"?appName={encoded}&tenantId={TenantId}").Req);
+
+ Assert.Equal(HttpStatusCode.OK, res.StatusCode);
+ var root = Json(res);
+ Assert.Equal(name, root.GetProperty("appName").GetString());
+ Assert.Equal(1, root.GetProperty("installed").GetInt32());
+ }
+
[Theory]
[MemberData(nameof(Names))]
public async Task Global_app_analytics_and_sessions_match_the_exact_name(string name, string encoded)
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TypedContractCompletionWireParityTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TypedContractCompletionWireParityTests.cs
index 280391fa..e590eac7 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/TypedContractCompletionWireParityTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/TypedContractCompletionWireParityTests.cs
@@ -931,6 +931,76 @@ public class TypedContractCompletionWireParityTests
});
}
+ [Fact]
+ public void GetAppFleetContextResponse_matches_the_anonymous_shape()
+ {
+ AssertWireIdentical(
+ new
+ {
+ success = true,
+ appName = "Contoso Notes",
+ source = "ime",
+ windowDays = 7,
+ installed = 11,
+ skipped = 1,
+ failed = 2,
+ incomplete = 1,
+ inProgress = 1,
+ collisionExcluded = 0,
+ topFailureCodes = new[]
+ {
+ new { code = "0x80d03805", exitCode = (int?)1603, count = 2, sampleMessage = "Fatal error during installation" },
+ },
+ },
+ new GetAppFleetContextResponse
+ {
+ Success = true,
+ AppName = "Contoso Notes",
+ Source = "ime",
+ WindowDays = 7,
+ Installed = 11,
+ Skipped = 1,
+ Failed = 2,
+ Incomplete = 1,
+ InProgress = 1,
+ CollisionExcluded = 0,
+ TopFailureCodes = new[]
+ {
+ new AppAnalyticsFailureCode { Code = "0x80d03805", ExitCode = 1603, Count = 2, SampleMessage = "Fatal error during installation" },
+ },
+ });
+ }
+
+ [Fact]
+ public void GetAppFleetContextResponse_drops_an_absent_exit_code()
+ {
+ AssertWireIdentical(
+ new
+ {
+ success = true,
+ appName = "Contoso Notes",
+ source = "ime",
+ windowDays = 7,
+ installed = 0,
+ skipped = 0,
+ failed = 1,
+ incomplete = 0,
+ inProgress = 0,
+ collisionExcluded = 1,
+ topFailureCodes = new[] { new { code = "esp_apps_timeout", count = 1, sampleMessage = "" } },
+ },
+ new GetAppFleetContextResponse
+ {
+ Success = true,
+ AppName = "Contoso Notes",
+ Source = "ime",
+ WindowDays = 7,
+ Failed = 1,
+ CollisionExcluded = 1,
+ TopFailureCodes = new[] { new AppAnalyticsFailureCode { Code = "esp_apps_timeout", Count = 1 } },
+ });
+ }
+
// ── global/tenants/{tenantId}/offboarding ────────────────────────────────────────────
[Fact]
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/AppsAnalyticsHelper.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/AppsAnalyticsHelper.cs
index 5fb5e5b8..46fddc85 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/AppsAnalyticsHelper.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/AppsAnalyticsHelper.cs
@@ -253,9 +253,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
var midpoint = now.AddDays(-days / 2.0);
var inWindow = allSummaries
- .Where(s => string.Equals(s.AppName, appName, StringComparison.OrdinalIgnoreCase)
- && AppInstallSources.Normalize(s.Source) == source
- && s.StartedAt >= cutoff)
+ .Where(s => IsSameApp(s, appName, source) && s.StartedAt >= cutoff)
.ToList();
// F1 PR1 (audit Q3): collision rows under this name mix in a second app's outcomes —
@@ -335,12 +333,12 @@ namespace AutopilotMonitor.Functions.Functions.Apps
var vTotal = g.Count();
var vFailed = g.Count(s => s.Status == "Failed");
// Same PR0 convention as the top-level rate: skips are not attempts.
- var vSucceeded = g.Count(s => s.Status == "Succeeded" && !MetricsMath.IsSkipTerminalState(s));
+ var vSucceeded = g.Count(s => MetricsMath.IsInstalled(s));
// Same measured population as the top-level duration stats: succeeded,
// non-skip, and a plausible observed duration (0s = start unobserved,
// >6h = back-stamped batch — both excluded, never averaged in).
var vDurations = g
- .Where(s => s.Status == "Succeeded" && !MetricsMath.IsSkipTerminalState(s))
+ .Where(s => MetricsMath.IsInstalled(s))
.Where(MetricsMath.HasMeasuredDuration)
.Select(s => s.DurationSeconds)
.ToList();
@@ -365,19 +363,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
.OrderByDescending(p => p.Failed)
.ToList();
- var topFailureCodes = summaries
- .Where(s => s.Status == "Failed" && !string.IsNullOrEmpty(s.FailureCode))
- .GroupBy(s => s.FailureCode)
- .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
- })
- .OrderByDescending(f => f.Count)
- .Take(5)
- .ToList();
+ var topFailureCodes = TopFailureCodes(summaries, 5);
var detectionLiesCount = summaries.Count(s =>
s.Status == "Succeeded" &&
@@ -407,7 +393,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
var modelTotal = g.Count();
var modelFailed = g.Count(x => x.Summary.Status == "Failed");
// PR0 convention: skips are not attempts (same population as the headline rate).
- var modelSucceeded = g.Count(x => x.Summary.Status == "Succeeded" && !MetricsMath.IsSkipTerminalState(x.Summary));
+ var modelSucceeded = g.Count(x => MetricsMath.IsInstalled(x.Summary));
var modelFailureRate = MetricsMath.TerminalFailureRatePct(modelFailed, modelSucceeded);
var lift = failureRate > 0
? Math.Round(modelFailureRate / failureRate, 2)
@@ -462,6 +448,81 @@ namespace AutopilotMonitor.Functions.Functions.Apps
};
}
+ ///
+ /// The rows of one app: its name case-insensitively and its normalized install channel. Shared
+ /// by the analytics, sessions and fleet-context views.
+ ///
+ internal static bool IsSameApp(AppInstallSummary s, string appName, string source)
+ => string.Equals(s.AppName, appName, StringComparison.OrdinalIgnoreCase)
+ && AppInstallSources.Normalize(s.Source) == source;
+
+ ///
+ /// The most frequent failure codes of the failed rows, most frequent first: the code as the row
+ /// carries it (an error code, an exit code or a failure type), with the first exit code and the
+ /// first message of its rows.
+ ///
+ internal static List TopFailureCodes(IEnumerable rows, int take)
+ => rows
+ .Where(s => s.Status == "Failed" && !string.IsNullOrEmpty(s.FailureCode))
+ .GroupBy(s => s.FailureCode)
+ .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
+ })
+ .OrderByDescending(f => f.Count)
+ .Take(take)
+ .ToList();
+
+ // ── /apps/fleet-context ─────────────────────────────────────────────
+
+ /// Window of the fleet context: the week before the request.
+ public const int FleetContextDays = 7;
+
+ ///
+ /// How one app fared in the tenant's other enrollments. are the app's
+ /// rows (); one row is one enrollment, its row key starts with the session
+ /// id. Left out: rows before , the rows of
+ /// , and with a row carrying a
+ /// different app id (another app of the same name). Collision rows are left out and counted, as
+ /// in the analytics view. The outcome classes follow the analytics view.
+ ///
+ public static GetAppFleetContextResponse BuildFleetContextResponse(
+ IEnumerable appRows,
+ string appName,
+ string source,
+ string? appId,
+ string? excludeSessionId,
+ DateTime cutoff)
+ {
+ var others = appRows
+ .Where(s => s.StartedAt >= cutoff)
+ .Where(s => string.IsNullOrEmpty(excludeSessionId)
+ || !string.Equals(s.SessionId, excludeSessionId, StringComparison.OrdinalIgnoreCase))
+ .Where(s => string.IsNullOrEmpty(appId)
+ || string.IsNullOrEmpty(s.AppId)
+ || string.Equals(s.AppId, appId, StringComparison.OrdinalIgnoreCase))
+ .ToList();
+ var rows = others.Where(s => !s.AppIdCollision).ToList();
+
+ return new GetAppFleetContextResponse
+ {
+ Success = true,
+ AppName = appName,
+ Source = source,
+ WindowDays = FleetContextDays,
+ Installed = rows.Count(MetricsMath.IsInstalled),
+ Skipped = rows.Count(s => s.Status == "Succeeded" && MetricsMath.IsSkipTerminalState(s)),
+ Failed = rows.Count(s => s.Status == "Failed"),
+ Incomplete = rows.Count(s => s.Status == "Incomplete"),
+ InProgress = rows.Count(s => s.Status is not ("Succeeded" or "Failed" or "Incomplete")),
+ CollisionExcluded = others.Count - rows.Count,
+ TopFailureCodes = TopFailureCodes(rows, 3),
+ };
+ }
+
// ── /apps/sessions ──────────────────────────────────────────────────
public static async Task BuildSessionsResponseAsync(
@@ -479,9 +540,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
var cutoff = DateTime.UtcNow.AddDays(-days);
var summaries = allSummaries
- .Where(s => string.Equals(s.AppName, appName, StringComparison.OrdinalIgnoreCase)
- && AppInstallSources.Normalize(s.Source) == source
- && s.StartedAt >= cutoff)
+ .Where(s => IsSameApp(s, appName, source) && s.StartedAt >= cutoff)
.ToList();
if (statusFilter == "failed")
@@ -565,9 +624,9 @@ namespace AutopilotMonitor.Functions.Functions.Apps
// as the headline failure rate, or a wave of not-applicable update policies would
// fake an "improving" trend.
var fhFailed = firstHalf.Count(s => s.Status == "Failed");
- var fhSucceeded = firstHalf.Count(s => s.Status == "Succeeded" && !MetricsMath.IsSkipTerminalState(s));
+ var fhSucceeded = firstHalf.Count(s => MetricsMath.IsInstalled(s));
var shFailed = secondHalf.Count(s => s.Status == "Failed");
- var shSucceeded = secondHalf.Count(s => s.Status == "Succeeded" && !MetricsMath.IsSkipTerminalState(s));
+ var shSucceeded = secondHalf.Count(s => MetricsMath.IsInstalled(s));
if (fhFailed + fhSucceeded < 5 || shFailed + shSucceeded < 5)
return ("stable", null);
@@ -606,7 +665,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
var bTotal = items.Count;
var bFailed = items.Count(s => s.Status == "Failed");
// PR0 convention: skips are not attempts; durations read measured rows only.
- var bInstalled = items.Where(s => s.Status == "Succeeded" && !MetricsMath.IsSkipTerminalState(s)).ToList();
+ var bInstalled = items.Where(s => MetricsMath.IsInstalled(s)).ToList();
var bMeasured = bInstalled.Where(MetricsMath.HasMeasuredDuration).ToList();
return new AppAnalyticsTimeBucket
{
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetAppFleetContextFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetAppFleetContextFunction.cs
new file mode 100644
index 00000000..29f817f5
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetAppFleetContextFunction.cs
@@ -0,0 +1,60 @@
+using AutopilotMonitor.Functions.Helpers;
+using AutopilotMonitor.Functions.Security;
+using AutopilotMonitor.Functions.Services.Apps;
+using AutopilotMonitor.Shared.Models;
+using Microsoft.Azure.Functions.Worker;
+using Microsoft.Azure.Functions.Worker.Http;
+using Microsoft.Extensions.Logging;
+
+namespace AutopilotMonitor.Functions.Functions.Apps
+{
+ ///
+ /// GET /api/apps/fleet-context?appName=&source=&appId=&excludeSessionId=&tenantId=
+ /// How one app fared in the tenant's other enrollments of the last week: the diagnosis page's
+ /// fleet line. MemberRead with the tenant from the query (TenantScoping.QueryParam), so the line
+ /// also works in a cross-tenant view. The name is a query value, never a path segment.
+ ///
+ public class GetAppFleetContextFunction
+ {
+ private readonly ILogger _logger;
+ private readonly AppFleetContextService _fleet;
+
+ public GetAppFleetContextFunction(ILogger logger, AppFleetContextService fleet)
+ {
+ _logger = logger;
+ _fleet = fleet;
+ }
+
+ [Function("GetAppFleetContext")]
+ public async Task Run(
+ [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "apps/fleet-context")] HttpRequestData req)
+ {
+ var query = req.Query;
+ var appName = query["appName"];
+ if (string.IsNullOrWhiteSpace(appName))
+ return await req.BadRequestAsync("appName is required");
+ if (!AppsAnalyticsHelper.TryParseSourceQueryParam(query["source"], out var source))
+ return await req.BadRequestAsync($"source must be one of: {string.Join(", ", AppInstallSources.All)}");
+ var excludeSessionId = query["excludeSessionId"];
+ if (!string.IsNullOrEmpty(excludeSessionId) && !SecurityValidator.IsValidGuid(excludeSessionId))
+ return await req.BadRequestAsync("excludeSessionId must be a valid GUID");
+
+ // Authentication and MemberRead are enforced by PolicyEnforcementMiddleware; a cross-tenant
+ // read arrives as TargetTenantId, which the middleware does not check for shape.
+ var tenantId = req.GetRequestContext().TargetTenantId;
+ if (!SecurityValidator.IsValidGuid(tenantId))
+ return await req.BadRequestAsync("tenantId must be a valid GUID");
+
+ try
+ {
+ var appId = query["appId"];
+ return await req.OkAsync(await _fleet.GetAsync(
+ tenantId, appName, source, string.IsNullOrWhiteSpace(appId) ? null : appId.Trim(), excludeSessionId));
+ }
+ catch (Exception ex)
+ {
+ return await req.InternalServerErrorAsync(_logger, ex, "GetAppFleetContext");
+ }
+ }
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions/Helpers/MetricsMath.cs b/src/Backend/AutopilotMonitor.Functions/Helpers/MetricsMath.cs
index ca3b681a..36a4ba03 100644
--- a/src/Backend/AutopilotMonitor.Functions/Helpers/MetricsMath.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Helpers/MetricsMath.cs
@@ -32,6 +32,13 @@ public static class MetricsMath
public static bool IsSkipTerminalState(AppInstallSummary s)
=> s.TerminalState == "Skipped" || s.TerminalState == "Postponed";
+ ///
+ /// A real install: the row succeeded and was not a skip (). The
+ /// population of every success count and of the failure rate's denominator.
+ ///
+ public static bool IsInstalled(AppInstallSummary s)
+ => s.Status == "Succeeded" && !IsSkipTerminalState(s);
+
///
/// Upper plausibility bound for an observed install duration: 6 h, the agent's default
/// max observation lifetime (AgentMaxLifetimeMinutes = 360) — nothing longer can have
diff --git a/src/Backend/AutopilotMonitor.Functions/Program.cs b/src/Backend/AutopilotMonitor.Functions/Program.cs
index 38379ad6..c7c8ab75 100644
--- a/src/Backend/AutopilotMonitor.Functions/Program.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Program.cs
@@ -283,6 +283,8 @@ builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
+// The fleet context's 60 s per-app cache must outlive a function instance.
+builder.Services.AddSingleton();
// Cached per-tenant evaluateOn trigger sets for the ingest-side interim analyze enqueue
// (internal/docs/rules/analyze-rule-triggers.md). Singleton so the 5-min TTL cache is shared.
builder.Services.AddSingleton();
diff --git a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs
index 421b83ce..7fd819d5 100644
--- a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs
@@ -320,6 +320,9 @@ public static class EndpointAccessPolicyCatalog
new("GET", "apps/list", EndpointPolicy.MemberRead),
new("GET", "apps/analytics", EndpointPolicy.MemberRead),
new("GET", "apps/sessions", EndpointPolicy.MemberRead),
+ // The diagnosis page's fleet line: QueryParam scoping so it works in a cross-tenant view
+ // (Global Admin, delegated drill-in) like metrics/device-history.
+ new("GET", "apps/fleet-context", EndpointPolicy.MemberRead, TenantScoping.QueryParam),
new("GET", "metrics/usage", EndpointPolicy.MemberRead),
new("GET", "metrics/sla", EndpointPolicy.MemberRead),
new("GET", "metrics/rule-stats", EndpointPolicy.MemberRead),
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Apps/AppFleetContextService.cs b/src/Backend/AutopilotMonitor.Functions/Services/Apps/AppFleetContextService.cs
new file mode 100644
index 00000000..d39aea09
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions/Services/Apps/AppFleetContextService.cs
@@ -0,0 +1,44 @@
+using AutopilotMonitor.Functions.Functions.Apps;
+using AutopilotMonitor.Functions.Services.Caching;
+using AutopilotMonitor.Shared.DataAccess;
+using AutopilotMonitor.Shared.Models;
+
+namespace AutopilotMonitor.Functions.Services.Apps;
+
+///
+/// The fleet context of one app (GET apps/fleet-context). The app's rows of the tenant's last
+/// week are read once per tenant, channel, app and minute and shared by every caller within 60 s on
+/// this instance (single flight); each request then counts them without its own session. The read is
+/// the apps dashboard's partition query, which fails soft: a storage error reads as no rows.
+///
+public sealed class AppFleetContextService
+{
+ internal static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(60);
+
+ /// Distinct apps cached at once; one more drops the others (see ).
+ private const int MaxCachedApps = 256;
+
+ private readonly IMetricsRepository _metricsRepo;
+ private readonly SingleFlightCache> _appRows = new(MaxCachedApps);
+
+ public AppFleetContextService(IMetricsRepository metricsRepo)
+ {
+ _metricsRepo = metricsRepo;
+ }
+
+ public async Task GetAsync(
+ string tenantId, string appName, string source, string? appId, string? excludeSessionId)
+ {
+ var cutoff = DateTime.UtcNow.AddDays(-AppsAnalyticsHelper.FleetContextDays);
+ // The server filter starts at the minute, never after the exact cutoff applied when counting.
+ var minute = new DateTime(cutoff.Ticks - cutoff.Ticks % TimeSpan.TicksPerMinute, DateTimeKind.Utc);
+ var key = $"{tenantId.ToLowerInvariant()}|{source}|{appName.ToLowerInvariant()}|{minute:yyyyMMddHHmm}";
+
+ var rows = await _appRows.GetOrAddAsync(key, CacheTtl, async () =>
+ (await _metricsRepo.GetAppsDashboardSummariesAsync(minute, tenantId))
+ .Where(s => AppsAnalyticsHelper.IsSameApp(s, appName, source))
+ .ToList());
+
+ return AppsAnalyticsHelper.BuildFleetContextResponse(rows, appName, source, appId, excludeSessionId, cutoff);
+ }
+}
diff --git a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts
index 0e63ab54..3592242a 100644
--- a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts
+++ b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts
@@ -2178,6 +2178,27 @@ export interface GetAllTenantConfigurationsResponse {
nextLink?: string;
}
+/** Response of GET apps/fleet-context?appName=: how one app fared in the tenant's other enrollments of the last WindowDays days, for the diagnosis page. One row is one enrollment; the asking session is left out. */
+export interface GetAppFleetContextResponse {
+ success: boolean;
+ appName: string;
+ source: string;
+ windowDays: number;
+ /** Succeeded and not a skip. */
+ installed: number;
+ /** Succeeded as Skipped or Postponed: no install attempt. */
+ skipped: number;
+ failed: number;
+ /** The observation ended before an outcome. */
+ incomplete: number;
+ /** No outcome yet. */
+ inProgress: number;
+ /** Rows left out because the name stood for two apps in one enrollment. */
+ collisionExcluded: number;
+ /** The three most frequent failure codes of the failed rows, most frequent first. */
+ topFailureCodes: AppAnalyticsFailureCode[];
+}
+
/** Response of GET vulnerability/cpe-mappings: every mapping plus the count. */
export interface GetCpeMappingsResponse {
mappings: CpeMappingItem[];
diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Metrics/AppsDashboardApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/Metrics/AppsDashboardApiModels.cs
index 16f67e81..7c164f8c 100644
--- a/src/Shared/AutopilotMonitor.Shared/Models/Metrics/AppsDashboardApiModels.cs
+++ b/src/Shared/AutopilotMonitor.Shared/Models/Metrics/AppsDashboardApiModels.cs
@@ -151,6 +151,32 @@ namespace AutopilotMonitor.Shared.Models
public double LiftVsBaseline { get; set; }
}
+ ///
+ /// Response of GET apps/fleet-context?appName=: how one app fared in the tenant's other
+ /// enrollments of the last days, for the diagnosis page. One row is one
+ /// enrollment; the asking session is left out.
+ ///
+ public class GetAppFleetContextResponse : IApiResponse
+ {
+ public bool Success { get; set; }
+ public string AppName { get; set; } = string.Empty;
+ public string Source { get; set; } = string.Empty;
+ public int WindowDays { get; set; }
+ /// Succeeded and not a skip.
+ public int Installed { get; set; }
+ /// Succeeded as Skipped or Postponed: no install attempt.
+ public int Skipped { get; set; }
+ public int Failed { get; set; }
+ /// The observation ended before an outcome.
+ public int Incomplete { get; set; }
+ /// No outcome yet.
+ public int InProgress { get; set; }
+ /// Rows left out because the name stood for two apps in one enrollment.
+ public int CollisionExcluded { get; set; }
+ /// The three most frequent failure codes of the failed rows, most frequent first.
+ public IReadOnlyList TopFailureCodes { get; set; } = default!;
+ }
+
/// Response of GET apps/sessions?appName= and its global variant (offset-paged).
public class AppSessionsResponse : IApiResponse
{
diff --git a/src/Web/autopilot-monitor-web/app/diagnosis/components/ContextSection.tsx b/src/Web/autopilot-monitor-web/app/diagnosis/components/ContextSection.tsx
index f0f4cff3..c73514ba 100644
--- a/src/Web/autopilot-monitor-web/app/diagnosis/components/ContextSection.tsx
+++ b/src/Web/autopilot-monitor-web/app/diagnosis/components/ContextSection.tsx
@@ -1,9 +1,15 @@
"use client";
import type { ReactNode } from "react";
+import Link from "next/link";
+import { trackEvent } from "@/lib/appInsights";
import type { ContextIcon, ContextItem } from "../lib/contextItems";
+import type { FleetView } from "../lib/fleetContext";
-const ICONS: Record = {
+// Outline icon paths (24 px grid); "fleet" is the stack of the tenant's other enrollments.
+const ICONS: Record = {
+ fleet:
+ "M6 6.878V6a2.25 2.25 0 012.25-2.25h7.5A2.25 2.25 0 0118 6v.878m-12 0c.235-.083.487-.128.75-.128h10.5c.263 0 .515.045.75.128m-12 0A2.25 2.25 0 004.5 9v.878m13.5-3A2.25 2.25 0 0119.5 9v.878m0 0a2.246 2.246 0 00-.75-.128H5.25c-.263 0-.515.045-.75.128m15 0A2.25 2.25 0 0121 12v6a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18v-6c0-.98.626-1.813 1.5-2.122",
power: "M21 10.5h.375c.621 0 1.125.504 1.125 1.125v2.25c0 .621-.504 1.125-1.125 1.125H21M3.75 18h15A2.25 2.25 0 0021 15.75v-6a2.25 2.25 0 00-2.25-2.25h-15A2.25 2.25 0 001.5 9.75v6A2.25 2.25 0 003.75 18z",
update: "M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99",
clock: "M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z",
@@ -13,35 +19,52 @@ const ICONS: Record = {
info: "M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z",
};
-/** Circumstances around the outcome: context findings and the limits of the observation. */
-export default function ContextSection({ items, outcome, children }: { items: ContextItem[]; outcome: string; children?: ReactNode }) {
- if (items.length === 0 && !children) return null;
+/** Circumstances around the outcome: context findings, the limits of the observation and the app in the fleet. */
+export default function ContextSection({ items, outcome, fleet }: { items: ContextItem[]; outcome: string; fleet: FleetView | null }) {
+ if (items.length === 0 && !fleet) return null;
return (
{outcome === "failed" ? "Around the failure" : "Around this enrollment"}
-
Observed on the device. Context, not proof of cause.
+
Context, not proof of cause.
- {items.length > 0 && (
-
- {items.map((item) => (
- -
-
-
-
-
-
{item.title}
- {item.detail &&
{item.detail}
}
-
-
- ))}
-
- )}
- {children}
+
+ {items.map((item) => (
+
+ ))}
+ {fleet && (
+
+ {fleet.href && (
+ trackEvent("diagnosis_action", { action: "open_fleet_sessions" })}
+ className="mt-1 inline-block text-xs text-blue-700 hover:underline"
+ >
+ Show sessions →
+
+ )}
+
+ )}
+
);
}
+
+function Tile({ icon, title, detail, children }: { icon: ContextIcon | "fleet"; title: string; detail: string | null; children?: ReactNode }) {
+ return (
+
+
+
+
+
+
{title}
+ {detail &&
{detail}
}
+ {children}
+
+
+ );
+}
diff --git a/src/Web/autopilot-monitor-web/app/diagnosis/components/FindingsSection.tsx b/src/Web/autopilot-monitor-web/app/diagnosis/components/FindingsSection.tsx
index 0f0cc474..dfca1baa 100644
--- a/src/Web/autopilot-monitor-web/app/diagnosis/components/FindingsSection.tsx
+++ b/src/Web/autopilot-monitor-web/app/diagnosis/components/FindingsSection.tsx
@@ -4,6 +4,7 @@ import { useState } from "react";
import type { Route } from "next";
import { RuleInlines, RuleText } from "@/components/findings/RuleText";
import { EvidenceChips } from "@/components/findings/EvidenceChips";
+import { RecentFiresLine } from "@/components/findings/RecentFiresLine";
import { ruleTextBlocks } from "@/lib/ruleText";
import { safeHttpUrl } from "@/lib/safeDocUrl";
import { trackEvent } from "@/lib/appInsights";
@@ -18,6 +19,12 @@ const SEVERITY: Record = {
info: "border-blue-200 bg-blue-100 text-blue-800",
};
+/** The rule telemetry next to each finding: enrollments it fired on in 14 days, and the dashboard filtered to it. */
+export interface RuleFires {
+ counts: Record;
+ href: (ruleId: string) => Route;
+}
+
const PRIMARY_TONE: Record = {
red: "text-red-700",
orange: "text-orange-700",
@@ -37,11 +44,13 @@ export default function FindingsSection({
groups,
analysisNote,
eventHref,
+ ruleFires,
}: {
groups: FindingGroups;
/** Shown instead of rows while there are none (analysis running, no rule fired, read failed). */
analysisNote: string | null;
eventHref: (eventId: string) => Route;
+ ruleFires: RuleFires;
}) {
const sections: { label: string; tone: string; items: FindingView[] }[] = [
{ label: groups.primary.label, tone: PRIMARY_TONE[groups.primary.tone], items: groups.primary.items },
@@ -66,7 +75,7 @@ export default function FindingsSection({
{section.items.map((f) => (
-
+
))}
@@ -75,7 +84,7 @@ export default function FindingsSection({
);
}
-function FindingRow({ finding, eventHref }: { finding: FindingView; eventHref: (eventId: string) => Route }) {
+function FindingRow({ finding, eventHref, ruleFires }: { finding: FindingView; eventHref: (eventId: string) => Route; ruleFires: RuleFires }) {
const [open, setOpen] = useState(false);
const { result } = finding;
const panelId = `finding-${result.ruleId}`;
@@ -120,14 +129,14 @@ function FindingRow({ finding, eventHref }: { finding: FindingView; eventHref: (
{open && (
-
+
)}
);
}
-function FindingDetail({ finding, eventHref }: { finding: FindingView; eventHref: (eventId: string) => Route }) {
+function FindingDetail({ finding, eventHref, ruleFires }: { finding: FindingView; eventHref: (eventId: string) => Route; ruleFires: RuleFires }) {
const { result, presentation } = finding;
const facts = presentation?.facts ?? [];
const fixes = remediationGroups(result);
@@ -147,6 +156,15 @@ function FindingDetail({ finding, eventHref }: { finding: FindingView; eventHref
)}
+ {(ruleFires.counts[result.ruleId] ?? 0) > 0 && (
+
+
+
+ )}
diff --git a/src/Web/autopilot-monitor-web/app/diagnosis/hooks/useAppFleetContext.ts b/src/Web/autopilot-monitor-web/app/diagnosis/hooks/useAppFleetContext.ts
new file mode 100644
index 00000000..3a07dc63
--- /dev/null
+++ b/src/Web/autopilot-monitor-web/app/diagnosis/hooks/useAppFleetContext.ts
@@ -0,0 +1,49 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { api } from "@/lib/api";
+import { fetchJson } from "@/lib/apiClient";
+import { useLatest } from "@/hooks/useLatest";
+import { isGuid } from "@/lib/inputValidation";
+import type { DiagnosisCulprit, GetAppFleetContextResponse } from "@/lib/generated/wire-types.generated";
+
+/**
+ * How the verdict's app fared in the tenant's other enrollments of the last week, read once per app
+ * and session (the headline's live refetches do not repeat it). Null while loading, without an app
+ * culprit and on any error: the fleet line is context, never required.
+ */
+export function useAppFleetContext(
+ culprit: DiagnosisCulprit | null | undefined,
+ sessionId: string,
+ tenantId: string | null | undefined,
+ getAccessToken: (forceRefresh?: boolean) => Promise
+): GetAppFleetContextResponse | null {
+ const getToken = useLatest(getAccessToken);
+ const appName = culprit?.kind === "app" ? culprit.name : undefined;
+ const appId = culprit?.kind === "app" ? culprit.key : undefined;
+ const key = appName && sessionId && tenantId && isGuid(tenantId) ? [tenantId, sessionId, appName, appId ?? ""].join("|") : null;
+ const [loaded, setLoaded] = useState<{ key: string; context: GetAppFleetContextResponse | null } | null>(null);
+
+ useEffect(() => {
+ if (!key || !appName || !tenantId) return;
+ let cancelled = false;
+ const run = async () => {
+ try {
+ const context = await fetchJson(
+ api.apps.fleetContext(appName, { appId: appId || undefined, excludeSessionId: sessionId, tenantId }),
+ (force?: boolean) => getToken.current(force)
+ );
+ if (!cancelled) setLoaded({ key, context });
+ } catch {
+ // fail-soft: no fleet line
+ if (!cancelled) setLoaded({ key, context: null });
+ }
+ };
+ void run();
+ return () => {
+ cancelled = true;
+ };
+ }, [key, appName, appId, sessionId, tenantId, getToken]);
+
+ return loaded?.key === key ? loaded.context : null;
+}
diff --git a/src/Web/autopilot-monitor-web/app/diagnosis/lib/__tests__/fleetContext.test.ts b/src/Web/autopilot-monitor-web/app/diagnosis/lib/__tests__/fleetContext.test.ts
new file mode 100644
index 00000000..b8e63a26
--- /dev/null
+++ b/src/Web/autopilot-monitor-web/app/diagnosis/lib/__tests__/fleetContext.test.ts
@@ -0,0 +1,85 @@
+import { describe, it, expect } from "vitest";
+import type { GetAppFleetContextResponse } from "@/lib/generated/wire-types.generated";
+import { fleetSessionsHref, fleetView } from "../fleetContext";
+
+const context = (over: Partial): GetAppFleetContextResponse =>
+ Object.assign(
+ {
+ success: true,
+ appName: "Contoso Notes",
+ source: "ime",
+ windowDays: 7,
+ installed: 0,
+ skipped: 0,
+ failed: 0,
+ incomplete: 0,
+ inProgress: 0,
+ collisionExcluded: 0,
+ topFailureCodes: [],
+ },
+ over
+ );
+
+describe("fleetView", () => {
+ it("counts the other enrollments, failures first with their most frequent codes", () => {
+ const view = fleetView(
+ context({
+ failed: 4,
+ installed: 11,
+ inProgress: 1,
+ incomplete: 1,
+ skipped: 2,
+ topFailureCodes: [
+ { code: "0x80d03805", count: 2, sampleMessage: "" },
+ { code: "esp_apps_timeout", count: 1, sampleMessage: "" },
+ { code: "0x87d1041c", count: 1, sampleMessage: "" },
+ ],
+ }),
+ "/apps/detail?name=Contoso%20Notes&days=7"
+ );
+ expect(view).toEqual({
+ title: "Same app in other enrollments",
+ detail: "Last 7 days: 4 failed (0x80d03805 ×2, ESP timeout) · 11 installed · 1 still installing · 1 not finished · 2 skipped",
+ href: "/apps/detail?name=Contoso%20Notes&days=7",
+ });
+ });
+
+ it("names an unknown failure type in words and leaves codes as they are", () => {
+ const failed = (code: string) => fleetView(context({ failed: 1, topFailureCodes: [{ code, count: 1, sampleMessage: "" }] }), null)!.detail;
+ expect(failed("esp_apps_policy_gap")).toBe("Last 7 days: 1 failed (esp apps policy gap)");
+ expect(failed("1603")).toBe("Last 7 days: 1 failed (1603)");
+ });
+
+ it("shows nothing without an answer or without another enrollment (the read fails soft)", () => {
+ expect(fleetView(null, null)).toBeNull();
+ expect(fleetView(context({ collisionExcluded: 3 }), null)).toBeNull();
+ });
+});
+
+describe("fleetSessionsHref", () => {
+ const base = {
+ appName: "Contoso Notes",
+ windowDays: 7,
+ viewedTenantId: "00000000-0000-4000-8000-0000000000aa",
+ ownTenantId: "00000000-0000-4000-8000-0000000000AA",
+ globalAdminMode: false,
+ delegatedScope: false,
+ };
+
+ it("links a member to the app page of the own tenant", () => {
+ expect(fleetSessionsHref(base)).toBe("/apps/detail?name=Contoso%20Notes&days=7");
+ expect(fleetSessionsHref({ ...base, source: "realmjoin" })).toBe("/apps/detail?name=Contoso%20Notes&days=7&source=realmjoin");
+ });
+
+ it("names the tenant in Global Admin mode, which the app page follows", () => {
+ expect(fleetSessionsHref({ ...base, globalAdminMode: true, ownTenantId: "00000000-0000-4000-8000-0000000000bb" })).toBe(
+ "/apps/detail?name=Contoso%20Notes&days=7&tenantId=00000000-0000-4000-8000-0000000000aa"
+ );
+ });
+
+ it("has no link where the app page would show another tenant", () => {
+ expect(fleetSessionsHref({ ...base, ownTenantId: "00000000-0000-4000-8000-0000000000bb" })).toBeNull();
+ expect(fleetSessionsHref({ ...base, delegatedScope: true })).toBeNull();
+ expect(fleetSessionsHref({ ...base, viewedTenantId: "" })).toBeNull();
+ });
+});
diff --git a/src/Web/autopilot-monitor-web/app/diagnosis/lib/fleetContext.ts b/src/Web/autopilot-monitor-web/app/diagnosis/lib/fleetContext.ts
new file mode 100644
index 00000000..9b44ec9d
--- /dev/null
+++ b/src/Web/autopilot-monitor-web/app/diagnosis/lib/fleetContext.ts
@@ -0,0 +1,71 @@
+import type { Route } from "next";
+import type { GetAppFleetContextResponse } from "@/lib/generated/wire-types.generated";
+import { appDetailUrl } from "@/lib/routes";
+import { humanizeKey } from "@/lib/evidenceChips";
+
+/** The fleet tile of "Around the failure". */
+export interface FleetView {
+ title: string;
+ detail: string;
+ /** The app's page, filtered to the same window; null when that page would show another tenant. */
+ href: Route | null;
+}
+
+// Failure types the agent writes instead of an error code when the ESP ended the install.
+const FAILURE_TYPES: Record = {
+ esp_apps_timeout: "ESP timeout",
+ esp_apps_detection_failure: "detection failure",
+ esp_apps_install_failure: "install failure",
+};
+
+function codeText(code: string): string {
+ return FAILURE_TYPES[code] ?? (/^[a-z]+(_[a-z]+)+$/.test(code) ? humanizeKey(code).toLowerCase() : code);
+}
+
+/**
+ * The fleet tile, or null when no other enrollment of the window had the app. The read behind it
+ * fails soft, so an empty answer may be a failed read: the page never claims "nowhere else".
+ */
+export function fleetView(context: GetAppFleetContextResponse | null, href: Route | null): FleetView | null {
+ if (!context) return null;
+ const { installed, skipped, failed, incomplete, inProgress } = context;
+ if (installed + skipped + failed + incomplete + inProgress === 0) return null;
+
+ const codes = context.topFailureCodes
+ .slice(0, 2)
+ .map((c) => `${codeText(c.code)}${c.count > 1 ? ` ×${c.count}` : ""}`)
+ .join(", ");
+ const parts = [
+ failed > 0 && `${failed} failed${codes ? ` (${codes})` : ""}`,
+ installed > 0 && `${installed} installed`,
+ inProgress > 0 && `${inProgress} still installing`,
+ incomplete > 0 && `${incomplete} not finished`,
+ skipped > 0 && `${skipped} skipped`,
+ ].filter(Boolean);
+ return {
+ title: "Same app in other enrollments",
+ detail: `Last ${context.windowDays} days: ${parts.join(" · ")}`,
+ href,
+ };
+}
+
+/**
+ * The app page's link for the fleet tile when that page shows the viewed tenant: it follows
+ * `tenantId` only for a Global Admin or Reader in Global Admin mode, shows a member the own tenant,
+ * and ignores the URL for a delegated (MSP) scope.
+ */
+export function fleetSessionsHref(args: {
+ appName: string;
+ source?: string;
+ windowDays: number;
+ viewedTenantId: string;
+ ownTenantId: string | null | undefined;
+ globalAdminMode: boolean;
+ delegatedScope: boolean;
+}): Route | null {
+ if (args.delegatedScope || !args.viewedTenantId) return null;
+ const days = String(args.windowDays);
+ if (args.globalAdminMode) return appDetailUrl(args.appName, { days, source: args.source, tenantId: args.viewedTenantId });
+ const own = !!args.ownTenantId && args.ownTenantId.toLowerCase() === args.viewedTenantId.toLowerCase();
+ return own ? appDetailUrl(args.appName, { days, source: args.source }) : null;
+}
diff --git a/src/Web/autopilot-monitor-web/app/diagnosis/page.tsx b/src/Web/autopilot-monitor-web/app/diagnosis/page.tsx
index ac35109a..c94f3a41 100644
--- a/src/Web/autopilot-monitor-web/app/diagnosis/page.tsx
+++ b/src/Web/autopilot-monitor-web/app/diagnosis/page.tsx
@@ -18,14 +18,16 @@ import { trackEvent } from "@/lib/appInsights";
import { DOCS_PATHS } from "@/lib/docsPaths";
import { asGuidOrUndefined, isGuid } from "@/lib/inputValidation";
import { isTerminalStatus } from "@/lib/sessionStatus";
-import { diagnosisUrl, sessionUrl } from "@/lib/routes";
+import { dashboardUrl, diagnosisUrl, sessionUrl } from "@/lib/routes";
import { hasTenantReadScope } from "@/lib/tenantScope";
import { useSessionDetail } from "../sessions/hooks/useSessionDetail";
import { useSessionAnalysis } from "../sessions/hooks/useSessionAnalysis";
import { useSessionEvents } from "../sessions/hooks/useSessionEvents";
import { useSessionSignalR } from "../sessions/hooks/useSessionSignalR";
import { useSessionTimeAttribution } from "../sessions/hooks/useSessionTimeAttribution";
+import { useRuleFireStats } from "../sessions/hooks/useRuleFireStats";
import { useSessionDiagnosis } from "./hooks/useSessionDiagnosis";
+import { useAppFleetContext } from "./hooks/useAppFleetContext";
import VerdictCard from "./components/VerdictCard";
import ContextSection from "./components/ContextSection";
import StepsSection from "./components/StepsSection";
@@ -39,6 +41,7 @@ import { contextItems } from "./lib/contextItems";
import { copySummary, stepsFrom, stepsPlain } from "./lib/steps";
import { timeLanes } from "./lib/timeLanes";
import { keyEvents, problemEvents } from "./lib/eventLists";
+import { fleetSessionsHref, fleetView } from "./lib/fleetContext";
/** How long after the end a session without findings may still be in its queued analysis. */
const ANALYSIS_GRACE_MS = 5 * 60_000;
@@ -66,7 +69,7 @@ function DiagnosisRoute() {
}
function DiagnosisContent({ requestedSessionId, tenantIdOverride }: { requestedSessionId: string; tenantIdOverride?: string }) {
- const { getAccessToken, user } = useAuth();
+ const { getAccessToken, user, hasGlobalScope } = useAuth();
// A member without a tenant role sees ProtectedRoute's notice; the empty id keeps the hooks inert.
const sessionId = !user || hasTenantReadScope(user) ? requestedSessionId : "";
const { globalAdminMode } = useAdminMode();
@@ -110,8 +113,11 @@ function DiagnosisContent({ requestedSessionId, tenantIdOverride }: { requestedS
});
const session = detail.session;
- const breakdown = useSessionTimeAttribution(sessionId, session?.status, detail.sessionTenantId || tenantIdOverride, getAccessToken);
+ const sessionTenant = detail.sessionTenantId || tenantIdOverride;
+ const breakdown = useSessionTimeAttribution(sessionId, session?.status, sessionTenant, getAccessToken);
const headline = diagnosis.headline;
+ const culprit = headline?.culprits?.[0];
+ const fleetContext = useAppFleetContext(culprit, sessionId, sessionTenant, getAccessToken);
// Links keep the tenant of a cross-tenant view (Global Admin, MSP drill-in).
const viewedTenantId = (session?.tenantId ?? detail.sessionTenantId ?? tenantIdOverride ?? "").toLowerCase();
@@ -120,6 +126,25 @@ function DiagnosisContent({ requestedSessionId, tenantIdOverride }: { requestedS
const eventHref = (eventId: string) => sessionUrl(sessionId, { tenantId: linkTenant, hash: `event-${eventId}` });
const results = analysis.analysisResults;
+ const fireStats = useRuleFireStats(sessionTenant || undefined, results.length);
+ const ruleFires = {
+ counts: fireStats.recentFires,
+ href: (ruleId: string) => dashboardUrl({ ruleId, tenant: fireStats.crossTenant ? sessionTenant : undefined }),
+ };
+ const fleet =
+ culprit?.kind === "app"
+ ? fleetView(
+ fleetContext,
+ fleetSessionsHref({
+ appName: culprit.name,
+ windowDays: fleetContext?.windowDays ?? 7,
+ viewedTenantId,
+ ownTenantId: tenantId,
+ globalAdminMode: globalAdminMode && hasGlobalScope,
+ delegatedScope: !!user?.isDelegated && !hasGlobalScope,
+ })
+ )
+ : null;
const groups = useMemo(() => groupFindings(results, headline?.outcome ?? "unknown", headline?.culprits), [results, headline]);
const verdict = useMemo(() => (headline ? verdictText(headline) : null), [headline]);
const steps = useMemo(() => stepsFrom(groups), [groups]);
@@ -258,7 +283,7 @@ function DiagnosisContent({ requestedSessionId, tenantIdOverride }: { requestedS
/>
)}
-
+
{steps && }
-
+
{session?.status === "Failed" && analysis.analysisLoaded && results.length === 0 && !analysisMayRun && (
)}
- {sessionId && }
+ {sessionId && }
>
)}
diff --git a/src/Web/autopilot-monitor-web/app/sessions/components/AnalysisResultsSection.tsx b/src/Web/autopilot-monitor-web/app/sessions/components/AnalysisResultsSection.tsx
index 501d6f1c..e4d875c8 100644
--- a/src/Web/autopilot-monitor-web/app/sessions/components/AnalysisResultsSection.tsx
+++ b/src/Web/autopilot-monitor-web/app/sessions/components/AnalysisResultsSection.tsx
@@ -1,20 +1,15 @@
"use client";
-import { useState, useEffect, type ReactNode } from "react";
-import Link from "next/link";
+import { useState, type ReactNode } from "react";
import type { Route } from "next";
import { RuleResult } from "@/types";
import { ruleStepInlines, ruleTextBlocks } from "@/lib/ruleText";
import { RuleInlines, RuleText } from "@/components/findings/RuleText";
import { EvidenceChips } from "@/components/findings/EvidenceChips";
-import { api } from "@/lib/api";
import { dashboardUrl } from "@/lib/routes";
-import { recentWindowStartIso, sumRecentFires } from "@/lib/ruleRecentFires";
-import { useAuth } from "@/contexts/AuthContext";
-import { useTenant } from "@/contexts/TenantContext";
import { safeHttpUrl } from "@/lib/safeDocUrl";
-import type { RuleStatsResponse } from "@/lib/generated/wire-types.generated";
-import { fetchJson } from "@/lib/apiClient";
+import { RecentFiresLine } from "@/components/findings/RecentFiresLine";
+import { useRuleFireStats } from "../hooks/useRuleFireStats";
interface AnalysisResultsSectionProps {
analysisResults: RuleResult[];
@@ -53,47 +48,14 @@ export default function AnalysisResultsSection({
sessionTenantId,
emptyState,
}: AnalysisResultsSectionProps) {
- const { getAccessToken } = useAuth();
- const { tenantId } = useTenant();
- const [ruleHitRates, setRuleHitRates] = useState>({});
- const [ruleRecentFires, setRuleRecentFires] = useState>({});
const [showResolved, setShowResolved] = useState(false);
-
- const crossTenant = !!sessionTenantId && !!tenantId && sessionTenantId !== tenantId;
+ const { hitRates: ruleHitRates, recentFires: ruleRecentFires, crossTenant } = useRuleFireStats(sessionTenantId, analysisResults.length);
// Resolved findings (a later evaluation no longer fired the rule — the session healed)
// are kept for audit but are not open issues; they hide behind a toggle.
const openResults = analysisResults.filter((r) => !r.resolvedAt);
const resolvedResults = analysisResults.filter((r) => !!r.resolvedAt);
- useEffect(() => {
- if (!tenantId || analysisResults.length === 0) return;
- const fetchStats = async () => {
- try {
- // Cross-tenant viewers read the session tenant's stats via the global route;
- // a 403 (viewer without that scope) degrades to no stats — better than
- // showing the viewer's own-tenant numbers next to a foreign session.
- const url = crossTenant
- ? api.metrics.globalRuleStats(undefined, undefined, "analyze", sessionTenantId)
- : api.metrics.ruleStats(undefined, undefined, "analyze");
- const data = await fetchJson(url, getAccessToken);
- const map: Record = {};
- const fires: Record = {};
- const windowStart = recentWindowStartIso(new Date(), 14);
- for (const r of data.rules ?? []) {
- if (r.hitRate > 0) map[r.ruleId] = r.hitRate;
- const recent = sumRecentFires(r.trend, windowStart);
- if (recent > 0) fires[r.ruleId] = recent;
- }
- setRuleHitRates(map);
- setRuleRecentFires(fires);
- } catch {
- // Non-critical
- }
- };
- fetchStats();
- }, [tenantId, crossTenant, sessionTenantId, analysisResults.length, getAccessToken]);
-
return (
)}
{recentFires != null && recentFires > 0 && (
-
- Fired in {recentFires} enrollment{recentFires === 1 ? "" : "s"} in the last 14 days
- {" · "}
- e.stopPropagation()}
- className="not-italic font-medium text-amber-700 hover:text-amber-800 underline"
- >
- View sessions →
-
-
+ e.stopPropagation()}
+ linkClassName="not-italic font-medium text-amber-700 hover:text-amber-800 underline"
+ />
)}
diff --git a/src/Web/autopilot-monitor-web/app/sessions/hooks/useRuleFireStats.ts b/src/Web/autopilot-monitor-web/app/sessions/hooks/useRuleFireStats.ts
new file mode 100644
index 00000000..9e67019f
--- /dev/null
+++ b/src/Web/autopilot-monitor-web/app/sessions/hooks/useRuleFireStats.ts
@@ -0,0 +1,62 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { api } from "@/lib/api";
+import { fetchJson } from "@/lib/apiClient";
+import { recentWindowStartIso, sumRecentFires } from "@/lib/ruleRecentFires";
+import { useAuth } from "@/contexts/AuthContext";
+import { useTenant } from "@/contexts/TenantContext";
+import type { RuleStatsResponse } from "@/lib/generated/wire-types.generated";
+
+export interface RuleFireStats {
+ /** Share of the tenant's enrollments each rule fired on in 30 days; rules that fired only. */
+ hitRates: Record;
+ /** Enrollments each rule fired on in the last 14 days; rules that fired only. */
+ recentFires: Record;
+ /** The session belongs to another tenant than the viewer's: the stats and their links name that tenant. */
+ crossTenant: boolean;
+}
+
+/**
+ * Analyze-rule telemetry next to a session's findings, for the tenant the session belongs to: the
+ * member route for the viewer's own tenant, the global route scoped to the session's tenant in a
+ * cross-tenant view (GA, delegated drill-in), where a 403 leaves the stats empty rather than showing
+ * the viewer's own numbers next to a foreign session. Fetched while the session has findings and again
+ * when their number changes; fail-soft.
+ */
+export function useRuleFireStats(sessionTenantId: string | undefined, findingCount: number): RuleFireStats {
+ const { getAccessToken } = useAuth();
+ const { tenantId } = useTenant();
+ const [stats, setStats] = useState>({ hitRates: {}, recentFires: {} });
+ const crossTenant = !!sessionTenantId && !!tenantId && sessionTenantId.toLowerCase() !== tenantId.toLowerCase();
+
+ useEffect(() => {
+ if (!tenantId || findingCount === 0) return;
+ let cancelled = false;
+ const run = async () => {
+ try {
+ const url = crossTenant
+ ? api.metrics.globalRuleStats(undefined, undefined, "analyze", sessionTenantId)
+ : api.metrics.ruleStats(undefined, undefined, "analyze");
+ const data = await fetchJson(url, getAccessToken);
+ const hitRates: Record = {};
+ const recentFires: Record = {};
+ const windowStart = recentWindowStartIso(new Date(), 14);
+ for (const r of data.rules ?? []) {
+ if (r.hitRate > 0) hitRates[r.ruleId] = r.hitRate;
+ const recent = sumRecentFires(r.trend, windowStart);
+ if (recent > 0) recentFires[r.ruleId] = recent;
+ }
+ if (!cancelled) setStats({ hitRates, recentFires });
+ } catch {
+ // Non-critical: no rule telemetry next to the findings.
+ }
+ };
+ void run();
+ return () => {
+ cancelled = true;
+ };
+ }, [tenantId, crossTenant, sessionTenantId, findingCount, getAccessToken]);
+
+ return { ...stats, crossTenant };
+}
diff --git a/src/Web/autopilot-monitor-web/components/findings/RecentFiresLine.tsx b/src/Web/autopilot-monitor-web/components/findings/RecentFiresLine.tsx
new file mode 100644
index 00000000..76697cf8
--- /dev/null
+++ b/src/Web/autopilot-monitor-web/components/findings/RecentFiresLine.tsx
@@ -0,0 +1,29 @@
+import type { MouseEvent } from "react";
+import Link from "next/link";
+import type { Route } from "next";
+
+/**
+ * "Fired in N enrollments in the last 14 days · View sessions →" next to a finding: rule telemetry,
+ * counted in enrollments (never devices), and the dashboard filtered to the rule.
+ */
+export function RecentFiresLine({
+ count,
+ href,
+ linkClassName,
+ onLinkClick,
+}: {
+ count: number;
+ href: Route;
+ linkClassName: string;
+ onLinkClick?: (e: MouseEvent) => void;
+}) {
+ return (
+
+ Fired in {count} enrollment{count === 1 ? "" : "s"} in the last 14 days
+ {" · "}
+
+ View sessions →
+
+
+ );
+}
diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/apiPathEncoding.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/apiPathEncoding.test.ts
index 51458e7a..29e3b0be 100644
--- a/src/Web/autopilot-monitor-web/lib/__tests__/apiPathEncoding.test.ts
+++ b/src/Web/autopilot-monitor-web/lib/__tests__/apiPathEncoding.test.ts
@@ -71,6 +71,14 @@ describe("api path ids are percent-encoded", () => {
const analytics = new URL(api.apps.globalAnalytics("a/b", 30));
expect(analytics.pathname).toBe("/api/global/apps/analytics");
expect(analytics.searchParams.get("appName")).toBe("a/b");
+ const fleet = new URL(api.apps.fleetContext("100% a/b", { appId: "x", excludeSessionId: "s", tenantId: "t" }));
+ expect(fleet.pathname).toBe("/api/apps/fleet-context");
+ expect([...fleet.searchParams.entries()]).toEqual([
+ ["appName", "100% a/b"],
+ ["appId", "x"],
+ ["excludeSessionId", "s"],
+ ["tenantId", "t"],
+ ]);
});
it("no path builder lets an id escape its segment", async () => {
diff --git a/src/Web/autopilot-monitor-web/lib/api.ts b/src/Web/autopilot-monitor-web/lib/api.ts
index 9e172e36..124064b1 100644
--- a/src/Web/autopilot-monitor-web/lib/api.ts
+++ b/src/Web/autopilot-monitor-web/lib/api.ts
@@ -415,6 +415,16 @@ export const api = {
version: opts?.version,
source: appInstallSourceParam(opts?.source),
})}`,
+ // The diagnosis page's fleet line (GetAppFleetContextResponse): one app in the tenant's other
+ // enrollments of the last week. tenantId enables the cross-tenant read (TenantScoping.QueryParam).
+ fleetContext: (appName: string, opts?: { source?: string; appId?: string; excludeSessionId?: string; tenantId?: string }) =>
+ `${API_BASE_URL}/api/apps/fleet-context${qs({
+ appName,
+ source: appInstallSourceParam(opts?.source),
+ appId: opts?.appId,
+ excludeSessionId: opts?.excludeSessionId,
+ tenantId: opts?.tenantId,
+ })}`,
// Global Admin variants — tenantId is optional:
// - undefined → aggregates across ALL tenants
diff --git a/src/Web/autopilot-monitor-web/lib/generated/shared-manifests.json b/src/Web/autopilot-monitor-web/lib/generated/shared-manifests.json
index baa05bbb..5d85dd15 100644
--- a/src/Web/autopilot-monitor-web/lib/generated/shared-manifests.json
+++ b/src/Web/autopilot-monitor-web/lib/generated/shared-manifests.json
@@ -10478,6 +10478,98 @@
}
]
},
+ "GetAppFleetContextResponse": {
+ "kind": "object",
+ "doc": "Response of GET apps/fleet-context?appName=: how one app fared in the tenant's other enrollments of the last WindowDays days, for the diagnosis page. One row is one enrollment; the asking session is left out.",
+ "fields": [
+ {
+ "name": "success",
+ "type": {
+ "kind": "primitive",
+ "name": "boolean"
+ }
+ },
+ {
+ "name": "appName",
+ "type": {
+ "kind": "primitive",
+ "name": "string"
+ }
+ },
+ {
+ "name": "source",
+ "type": {
+ "kind": "primitive",
+ "name": "string"
+ }
+ },
+ {
+ "name": "windowDays",
+ "type": {
+ "kind": "primitive",
+ "name": "number"
+ }
+ },
+ {
+ "name": "installed",
+ "type": {
+ "kind": "primitive",
+ "name": "number"
+ },
+ "doc": "Succeeded and not a skip."
+ },
+ {
+ "name": "skipped",
+ "type": {
+ "kind": "primitive",
+ "name": "number"
+ },
+ "doc": "Succeeded as Skipped or Postponed: no install attempt."
+ },
+ {
+ "name": "failed",
+ "type": {
+ "kind": "primitive",
+ "name": "number"
+ }
+ },
+ {
+ "name": "incomplete",
+ "type": {
+ "kind": "primitive",
+ "name": "number"
+ },
+ "doc": "The observation ended before an outcome."
+ },
+ {
+ "name": "inProgress",
+ "type": {
+ "kind": "primitive",
+ "name": "number"
+ },
+ "doc": "No outcome yet."
+ },
+ {
+ "name": "collisionExcluded",
+ "type": {
+ "kind": "primitive",
+ "name": "number"
+ },
+ "doc": "Rows left out because the name stood for two apps in one enrollment."
+ },
+ {
+ "name": "topFailureCodes",
+ "type": {
+ "kind": "array",
+ "element": {
+ "kind": "ref",
+ "name": "AppAnalyticsFailureCode"
+ }
+ },
+ "doc": "The three most frequent failure codes of the failed rows, most frequent first."
+ }
+ ]
+ },
"GetCpeMappingsResponse": {
"kind": "object",
"doc": "Response of GET vulnerability/cpe-mappings: every mapping plus the count.",
diff --git a/src/Web/autopilot-monitor-web/lib/generated/wire-types.generated.ts b/src/Web/autopilot-monitor-web/lib/generated/wire-types.generated.ts
index 523be572..13661dd9 100644
--- a/src/Web/autopilot-monitor-web/lib/generated/wire-types.generated.ts
+++ b/src/Web/autopilot-monitor-web/lib/generated/wire-types.generated.ts
@@ -2178,6 +2178,27 @@ export interface GetAllTenantConfigurationsResponse {
nextLink?: string;
}
+/** Response of GET apps/fleet-context?appName=: how one app fared in the tenant's other enrollments of the last WindowDays days, for the diagnosis page. One row is one enrollment; the asking session is left out. */
+export interface GetAppFleetContextResponse {
+ success: boolean;
+ appName: string;
+ source: string;
+ windowDays: number;
+ /** Succeeded and not a skip. */
+ installed: number;
+ /** Succeeded as Skipped or Postponed: no install attempt. */
+ skipped: number;
+ failed: number;
+ /** The observation ended before an outcome. */
+ incomplete: number;
+ /** No outcome yet. */
+ inProgress: number;
+ /** Rows left out because the name stood for two apps in one enrollment. */
+ collisionExcluded: number;
+ /** The three most frequent failure codes of the failed rows, most frequent first. */
+ topFailureCodes: AppAnalyticsFailureCode[];
+}
+
/** Response of GET vulnerability/cpe-mappings: every mapping plus the count. */
export interface GetCpeMappingsResponse {
mappings: CpeMappingItem[];