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[];