Diagnosis: the culprit app in the tenant's other enrollments, and the rule line beside each finding (D-358)

GET apps/fleet-context (MemberRead, QueryParam) counts one app over the
tenant's last 7 days: installed, skipped, failed, incomplete, in progress,
collision rows left out, the three most frequent failure codes. It reads
the apps dashboard's partition query, leaves out the asking session and,
with the culprit's app id, another app of the same name; no session point
reads. AppFleetContextService caches the app's rows per tenant, channel,
name and minute for 60 s (single flight).

The classification is the analytics view's: MetricsMath.IsInstalled
replaces six inline copies, AppsAnalyticsHelper.IsSameApp and
TopFailureCodes are shared by analytics, sessions and the fleet context.

The diagnosis page shows the result as a tile in "Around the failure"
only when other enrollments exist (the read fails soft), with "Show
sessions" on the app page where that page shows the viewed tenant. An
expanded finding shows "Fired in N enrollments in the last 14 days"
through useRuleFireStats and RecentFiresLine, both extracted from the
session page, whose cross-tenant check now ignores case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-10-14 03:14:55 +02:00
parent 9ad3cb2df1
commit 325632e780
24 changed files with 1052 additions and 114 deletions
@@ -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;
/// <summary>
/// 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.
/// </summary>
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<AppInstallSummary> 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<IMetricsRepository>();
repo.Setup(r => r.GetAppsDashboardSummariesAsync(It.IsAny<DateTime>(), TenantId))
.ReturnsAsync(() => new List<AppInstallSummary>
{
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<DateTime>(), TenantId), Times.Once);
repo.Verify(r => r.GetAppsDashboardSummariesAsync(
It.Is<DateTime>(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<DateTime>(), 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<IMetricsRepository>();
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<IMetricsRepository>();
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<IMetricsRepository>();
repo.Setup(r => r.GetAppsDashboardSummariesAsync(It.IsAny<DateTime>(), TenantId))
.ReturnsAsync(new List<AppInstallSummary> { 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<GetAppFleetContextFunction>.Instance, new AppFleetContextService(repo));
}
@@ -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<GetAppFleetContextFunction>.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)
@@ -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]
@@ -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
};
}
/// <summary>
/// The rows of one app: its name case-insensitively and its normalized install channel. Shared
/// by the analytics, sessions and fleet-context views.
/// </summary>
internal static bool IsSameApp(AppInstallSummary s, string appName, string source)
=> string.Equals(s.AppName, appName, StringComparison.OrdinalIgnoreCase)
&& AppInstallSources.Normalize(s.Source) == source;
/// <summary>
/// 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.
/// </summary>
internal static List<AppAnalyticsFailureCode> TopFailureCodes(IEnumerable<AppInstallSummary> 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 ─────────────────────────────────────────────
/// <summary>Window of the fleet context: the week before the request.</summary>
public const int FleetContextDays = 7;
/// <summary>
/// How one app fared in the tenant's other enrollments. <paramref name="appRows"/> are the app's
/// rows (<see cref="IsSameApp"/>); one row is one enrollment, its row key starts with the session
/// id. Left out: rows before <paramref name="cutoff"/>, the rows of
/// <paramref name="excludeSessionId"/>, and with <paramref name="appId"/> 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.
/// </summary>
public static GetAppFleetContextResponse BuildFleetContextResponse(
IEnumerable<AppInstallSummary> 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<AppSessionsResponse> 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
{
@@ -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
{
/// <summary>
/// GET /api/apps/fleet-context?appName=&amp;source=&amp;appId=&amp;excludeSessionId=&amp;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.
/// </summary>
public class GetAppFleetContextFunction
{
private readonly ILogger<GetAppFleetContextFunction> _logger;
private readonly AppFleetContextService _fleet;
public GetAppFleetContextFunction(ILogger<GetAppFleetContextFunction> logger, AppFleetContextService fleet)
{
_logger = logger;
_fleet = fleet;
}
[Function("GetAppFleetContext")]
public async Task<HttpResponseData> 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");
}
}
}
}
@@ -32,6 +32,13 @@ public static class MetricsMath
public static bool IsSkipTerminalState(AppInstallSummary s)
=> s.TerminalState == "Skipped" || s.TerminalState == "Postponed";
/// <summary>
/// A real install: the row succeeded and was not a skip (<see cref="IsSkipTerminalState"/>). The
/// population of every success count and of the failure rate's denominator.
/// </summary>
public static bool IsInstalled(AppInstallSummary s)
=> s.Status == "Succeeded" && !IsSkipTerminalState(s);
/// <summary>
/// Upper plausibility bound for an observed install duration: 6 h, the agent's default
/// max observation lifetime (AgentMaxLifetimeMinutes = 360) — nothing longer can have
@@ -283,6 +283,8 @@ builder.Services.AddSingleton<HealthCheckService>();
builder.Services.AddSingleton<BackendBuildInfo>();
builder.Services.AddSingleton<GatherRuleService>();
builder.Services.AddSingleton<AnalyzeRuleService>();
// The fleet context's 60 s per-app cache must outlive a function instance.
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.Apps.AppFleetContextService>();
// 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<AutopilotMonitor.Functions.Services.Analyze.InterimTriggerRegistry>();
@@ -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),
@@ -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;
/// <summary>
/// The fleet context of one app (<c>GET apps/fleet-context</c>). 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.
/// </summary>
public sealed class AppFleetContextService
{
internal static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(60);
/// <summary>Distinct apps cached at once; one more drops the others (see <see cref="SingleFlightCache{T}"/>).</summary>
private const int MaxCachedApps = 256;
private readonly IMetricsRepository _metricsRepo;
private readonly SingleFlightCache<List<AppInstallSummary>> _appRows = new(MaxCachedApps);
public AppFleetContextService(IMetricsRepository metricsRepo)
{
_metricsRepo = metricsRepo;
}
public async Task<GetAppFleetContextResponse> 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);
}
}
@@ -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[];
@@ -151,6 +151,32 @@ namespace AutopilotMonitor.Shared.Models
public double LiftVsBaseline { get; set; }
}
/// <summary>
/// Response of <c>GET apps/fleet-context?appName=</c>: how one app fared in the tenant's other
/// enrollments of the last <see cref="WindowDays"/> days, for the diagnosis page. One row is one
/// enrollment; the asking session is left out.
/// </summary>
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; }
/// <summary>Succeeded and not a skip.</summary>
public int Installed { get; set; }
/// <summary>Succeeded as Skipped or Postponed: no install attempt.</summary>
public int Skipped { get; set; }
public int Failed { get; set; }
/// <summary>The observation ended before an outcome.</summary>
public int Incomplete { get; set; }
/// <summary>No outcome yet.</summary>
public int InProgress { get; set; }
/// <summary>Rows left out because the name stood for two apps in one enrollment.</summary>
public int CollisionExcluded { get; set; }
/// <summary>The three most frequent failure codes of the failed rows, most frequent first.</summary>
public IReadOnlyList<AppAnalyticsFailureCode> TopFailureCodes { get; set; } = default!;
}
/// <summary>Response of <c>GET apps/sessions?appName=</c> and its global variant (offset-paged).</summary>
public class AppSessionsResponse : IApiResponse
{
@@ -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<ContextIcon, string> = {
// Outline icon paths (24 px grid); "fleet" is the stack of the tenant's other enrollments.
const ICONS: Record<ContextIcon | "fleet", string> = {
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<ContextIcon, string> = {
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 (
<section className="rounded-lg bg-white p-5 shadow sm:p-6" aria-labelledby="context-title">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<h2 id="context-title" className="text-lg font-semibold text-gray-900">
{outcome === "failed" ? "Around the failure" : "Around this enrollment"}
</h2>
<p className="text-xs text-gray-500">Observed on the device. Context, not proof of cause.</p>
<p className="text-xs text-gray-500">Context, not proof of cause.</p>
</div>
{items.length > 0 && (
<ul className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2">
{items.map((item) => (
<li key={item.key} className="flex gap-3 rounded-lg border border-gray-200 p-3">
<span className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gray-100 text-gray-600">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d={ICONS[item.icon]} />
</svg>
</span>
<div className="min-w-0">
<p className="text-sm font-medium text-gray-900">{item.title}</p>
{item.detail && <p className="mt-0.5 break-words text-xs text-gray-500">{item.detail}</p>}
</div>
</li>
))}
</ul>
)}
{children}
<ul className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2">
{items.map((item) => (
<Tile key={item.key} icon={item.icon} title={item.title} detail={item.detail} />
))}
{fleet && (
<Tile icon="fleet" title={fleet.title} detail={fleet.detail}>
{fleet.href && (
<Link
href={fleet.href}
onClick={() => trackEvent("diagnosis_action", { action: "open_fleet_sessions" })}
className="mt-1 inline-block text-xs text-blue-700 hover:underline"
>
Show sessions →
</Link>
)}
</Tile>
)}
</ul>
</section>
);
}
function Tile({ icon, title, detail, children }: { icon: ContextIcon | "fleet"; title: string; detail: string | null; children?: ReactNode }) {
return (
<li className="flex gap-3 rounded-lg border border-gray-200 p-3">
<span className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gray-100 text-gray-600">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d={ICONS[icon]} />
</svg>
</span>
<div className="min-w-0">
<p className="text-sm font-medium text-gray-900">{title}</p>
{detail && <p className="mt-0.5 break-words text-xs text-gray-500">{detail}</p>}
{children}
</div>
</li>
);
}
@@ -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<string, string> = {
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<string, number>;
href: (ruleId: string) => Route;
}
const PRIMARY_TONE: Record<PrimaryTone, string> = {
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({
</p>
<div className="divide-y divide-gray-200 border-b border-gray-200">
{section.items.map((f) => (
<FindingRow key={f.result.ruleId} finding={f} eventHref={eventHref} />
<FindingRow key={f.result.ruleId} finding={f} eventHref={eventHref} ruleFires={ruleFires} />
))}
</div>
</div>
@@ -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: (
</button>
{open && (
<div id={panelId} className="px-4 pb-5 sm:px-5 md:pl-[8.5rem]">
<FindingDetail finding={finding} eventHref={eventHref} />
<FindingDetail finding={finding} eventHref={eventHref} ruleFires={ruleFires} />
</div>
)}
</div>
);
}
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
</dl>
)}
<RuleText blocks={ruleTextBlocks(result.explanation, result.matchedConditions)} className="space-y-2 text-sm text-gray-600" />
{(ruleFires.counts[result.ruleId] ?? 0) > 0 && (
<p>
<RecentFiresLine
count={ruleFires.counts[result.ruleId]}
href={ruleFires.href(result.ruleId)}
linkClassName="not-italic font-medium text-blue-700 hover:underline"
/>
</p>
)}
</div>
<div className="min-w-0 space-y-4">
<div>
@@ -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<string | null>
): 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<GetAppFleetContextResponse>(
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;
}
@@ -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>): 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();
});
});
@@ -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<string, string> = {
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;
}
@@ -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
/>
)}
<ContextSection items={context} outcome={headline?.outcome ?? ""} />
<ContextSection items={context} outcome={headline?.outcome ?? ""} fleet={fleet} />
{steps && <StepsSection steps={steps} onCopy={onCopySteps} copied={copied === "steps"} />}
<TimeSection
lanes={lanes}
@@ -267,7 +292,7 @@ function DiagnosisContent({ requestedSessionId, tenantIdOverride }: { requestedS
eventHref={eventHref}
timelineHref={sessionUrl(sessionId, { tenantId: linkTenant })}
/>
<FindingsSection groups={groups} analysisNote={analysisNote} eventHref={eventHref} />
<FindingsSection groups={groups} analysisNote={analysisNote} eventHref={eventHref} ruleFires={ruleFires} />
{session?.status === "Failed" && analysis.analysisLoaded && results.length === 0 && !analysisMayRun && (
<InvestigateWithAiHint
@@ -280,7 +305,7 @@ function DiagnosisContent({ requestedSessionId, tenantIdOverride }: { requestedS
/>
)}
{sessionId && <NotesSection sessionId={sessionId} tenantId={detail.sessionTenantId || tenantIdOverride} user={user} getAccessToken={getAccessToken} />}
{sessionId && <NotesSection sessionId={sessionId} tenantId={sessionTenant} user={user} getAccessToken={getAccessToken} />}
<ProblemsSection events={problems} eventHref={eventHref} />
</>
)}
@@ -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<Record<string, number>>({});
const [ruleRecentFires, setRuleRecentFires] = useState<Record<string, number>>({});
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<RuleStatsResponse>(url, getAccessToken);
const map: Record<string, number> = {};
const fires: Record<string, number> = {};
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 (
<div className="bg-white shadow rounded-lg p-6 mb-6">
<div
@@ -334,20 +296,12 @@ function AnalysisResultCard({
</span>
)}
{recentFires != null && recentFires > 0 && (
<span
className="text-xs text-gray-500 italic"
title="May include this enrollment; based on rule telemetry (enrollments, not distinct devices)"
>
Fired in {recentFires} enrollment{recentFires === 1 ? "" : "s"} in the last 14 days
{" · "}
<Link
href={sessionsHref}
onClick={(e) => e.stopPropagation()}
className="not-italic font-medium text-amber-700 hover:text-amber-800 underline"
>
View sessions →
</Link>
</span>
<RecentFiresLine
count={recentFires}
href={sessionsHref}
onLinkClick={(e) => e.stopPropagation()}
linkClassName="not-italic font-medium text-amber-700 hover:text-amber-800 underline"
/>
)}
</div>
</div>
@@ -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<string, number>;
/** Enrollments each rule fired on in the last 14 days; rules that fired only. */
recentFires: Record<string, number>;
/** 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<Omit<RuleFireStats, "crossTenant">>({ 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<RuleStatsResponse>(url, getAccessToken);
const hitRates: Record<string, number> = {};
const recentFires: Record<string, number> = {};
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 };
}
@@ -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<HTMLAnchorElement>) => void;
}) {
return (
<span className="text-xs italic text-gray-500" title="May include this enrollment; based on rule telemetry (enrollments, not distinct devices)">
Fired in {count} enrollment{count === 1 ? "" : "s"} in the last 14 days
{" · "}
<Link href={href} onClick={onLinkClick} className={linkClassName}>
View sessions →
</Link>
</span>
);
}
@@ -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 () => {
+10
View File
@@ -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
@@ -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.",
@@ -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[];