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