From a0ad769231a301cd512bcae2fbfc2f3b60c3973b Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Mon, 28 Sep 2026 10:19:18 +0200 Subject: [PATCH] RealmJoin packages count as app installs next to Intune apps App install rows now carry their install channel. The ingest folds the RealmJoin watcher's package started/completed events into AppInstallSummaries rows (channel "realmjoin", keyed by scope + package id; duration = completion minus the observed start). IME rows keep their key and have no channel column, so they read as before. Every per-app aggregate groups on (channel, name): apps list, app analytics and sessions (new ?source=, default ime), metrics/app, SLA top failing apps and the version-regression radar (tracker key, episode key, bell link, ops event). The app install SLA rate includes RealmJoin packages. The ESP-blocking stamp stays IME-only. RealmJoin packages do not feed the software inventory or CVE correlation. Web: a RealmJoin pill and an All / Intune / RealmJoin filter on Software > Installs, the pill on app detail, Fleet Health and SLA, source-aware app links and PDF meta line, and a hint in the empty Inventory tab. The pill moves out of InstallProgress into a shared component. MCP: get_app_install_metrics names the source field. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../AppInstallIdentityAndEspBlockingTests.cs | 4 +- .../AppVersionRegressionRadarTests.cs | 87 ++++- .../AppsAnalyticsFailureRateTests.cs | 2 +- .../RealmJoinPackageInstallRowsTests.cs | 314 ++++++++++++++++++ .../SharedManifestParityTests.cs | 2 + .../TypedContractCompletionWireParityTests.cs | 6 + ...bleHardwareRejectionNotificationTracker.cs | 16 +- .../Functions/Apps/AppsAnalyticsHelper.cs | 25 +- .../Functions/Apps/GetAppAnalyticsFunction.cs | 10 +- .../Functions/Apps/GetAppSessionsFunction.cs | 7 +- .../Apps/GetGlobalAppAnalyticsFunction.cs | 9 +- .../Apps/GetGlobalAppSessionsFunction.cs | 7 +- .../Helpers/AppVersionRegressionRadar.cs | 47 ++- .../Helpers/MetricsMath.cs | 6 +- .../EventIngestProcessor.AppInstall.cs | 9 +- .../EventIngestProcessor.RealmJoinPackages.cs | 93 ++++++ ...MaintenanceService.AppVersionRegression.cs | 28 +- .../Services/OpsEventService.cs | 3 +- .../Services/SlaBreachEvaluationService.cs | 6 +- .../Services/SlaMetricsService.cs | 9 +- .../Services/TableStorageService.Metrics.cs | 44 ++- .../src/generated/wire-types.generated.ts | 10 + .../autopilot-monitor-mcp/src/tools/admin.ts | 4 +- .../IHardwareRejectionNotificationTracker.cs | 2 +- .../Models/Metrics/AppInstallSources.cs | 28 ++ .../Models/Metrics/AppInstallSummary.cs | 24 +- .../Models/Metrics/AppMetricsApiModels.cs | 2 + .../Metrics/AppVersionRegressionAlert.cs | 2 + .../Models/Metrics/AppsDashboardApiModels.cs | 4 + .../Models/Metrics/SlaMetrics.cs | 2 + .../app/apps/components/InstallsTab.tsx | 45 ++- .../app/apps/components/InventoryTab.tsx | 4 +- .../app/apps/detail/page.tsx | 11 +- .../app/fleet-health/page.tsx | 29 +- .../autopilot-monitor-web/app/sla/page.tsx | 8 +- .../components/InstallProgress.tsx | 33 +- .../components/InstallSourcePill.tsx | 41 +++ .../lib/__tests__/appInstallSources.test.ts | 28 ++ .../lib/__tests__/scopedApi.test.ts | 9 + src/Web/autopilot-monitor-web/lib/api.ts | 16 +- .../lib/appInstallSources.ts | 20 ++ src/Web/autopilot-monitor-web/lib/routes.ts | 5 +- .../autopilot-monitor-web/lib/scopedApi.ts | 8 +- .../utils/pdf/appReportData.ts | 3 + .../utils/shared-manifests.generated.ts | 4 + .../utils/shared-manifests.json | 44 +++ .../utils/wire-types.generated.ts | 10 + 47 files changed, 996 insertions(+), 134 deletions(-) create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/RealmJoinPackageInstallRowsTests.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.RealmJoinPackages.cs create mode 100644 src/Shared/AutopilotMonitor.Shared/Models/Metrics/AppInstallSources.cs create mode 100644 src/Web/autopilot-monitor-web/components/InstallSourcePill.tsx create mode 100644 src/Web/autopilot-monitor-web/lib/__tests__/appInstallSources.test.ts create mode 100644 src/Web/autopilot-monitor-web/lib/appInstallSources.ts diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AppInstallIdentityAndEspBlockingTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AppInstallIdentityAndEspBlockingTests.cs index 8e77ba88..a7ce5a1d 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/AppInstallIdentityAndEspBlockingTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/AppInstallIdentityAndEspBlockingTests.cs @@ -306,7 +306,7 @@ public class AppInstallIdentityAndEspBlockingTests }; var root = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync( - summaries, repo.Object, "Company Portal", days: 30)); + summaries, repo.Object, "Company Portal", AppInstallSources.Ime, days: 30)); Assert.Equal(1, root.GetProperty("collisionExcluded").GetInt32()); var stats = root.GetProperty("summary"); @@ -325,7 +325,7 @@ public class AppInstallIdentityAndEspBlockingTests var summaries = new List { Summary("Company Portal", collision: true) }; var root = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync( - summaries, repo.Object, "Company Portal", days: 30)); + summaries, repo.Object, "Company Portal", AppInstallSources.Ime, days: 30)); Assert.Equal(1, root.GetProperty("collisionExcluded").GetInt32()); Assert.Equal(0, root.GetProperty("summary").GetProperty("totalInstalls").GetInt32()); diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AppVersionRegressionRadarTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AppVersionRegressionRadarTests.cs index 2d2f1db1..98159781 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/AppVersionRegressionRadarTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/AppVersionRegressionRadarTests.cs @@ -195,6 +195,37 @@ public class AppVersionRegressionRadarTests Assert.Empty(AppVersionRegressionRadar.Evaluate(rows2)); } + [Fact] + public void Evaluate_SameNameInTwoChannels_IsTwoApps() + { + // The RealmJoin package regressed; the Intune app of the same name did not. A shared + // group would dilute the RealmJoin medians with the Intune rows and hide the regression. + var rows = new List(); + rows.AddRange(Batch("1.0", 10, 300, Now.AddDays(-20)).Select(s => { s.Source = AppInstallSources.RealmJoin; return s; })); + rows.AddRange(Batch("2.0", 10, 900, Now.AddDays(-5)).Select(s => { s.Source = AppInstallSources.RealmJoin; return s; })); + rows.AddRange(Batch("1.0", 30, 300, Now.AddDays(-20))); + rows.AddRange(Batch("2.0", 30, 300, Now.AddDays(-5))); + + var finding = Assert.Single(AppVersionRegressionRadar.Evaluate(rows)); + Assert.Equal(AppInstallSources.RealmJoin, finding.Source); + Assert.Equal(App, finding.AppName); + Assert.Equal(900, finding.CurrentMedianSeconds); + } + + [Fact] + public void ShouldReArm_ReadsTheAlertsOwnChannel() + { + // The IME app still regresses; the RealmJoin episode's version has no measured installs. + var rows = new List(); + rows.AddRange(Batch("1.0", 10, 300, Now.AddDays(-20))); + rows.AddRange(Batch("2.0", 10, 900, Now.AddDays(-5))); + + var rjAlert = Alert(); + rjAlert.Source = AppInstallSources.RealmJoin; + Assert.True(AppVersionRegressionRadar.ShouldReArm(rows, rjAlert)); + Assert.False(AppVersionRegressionRadar.ShouldReArm(rows, Alert())); + } + [Fact] public void Evaluate_DeterministicOrdering_LiftDesc_ThenAppNameOrdinal() { @@ -279,13 +310,25 @@ public class AppVersionRegressionRadarTests { Assert.Equal("appversionregression|contoso app_2|1.0_beta_", TableHardwareRejectionNotificationTracker.BuildAppVersionRegressionRowKey( - " Contoso App/2 ", " 1.0#Beta? ")); + AppInstallSources.Ime, " Contoso App/2 ", " 1.0#Beta? ")); + } + + [Fact] + public void AppVersionRegressionRowKey_ImeKeepsPreChannelShape_OtherChannelsPrefixTheApp() + { + // Episodes written before channels existed must keep resolving (re-arm deletes them by key). + Assert.Equal("appversionregression|contoso vpn|2.0", + TableHardwareRejectionNotificationTracker.BuildAppVersionRegressionRowKey(null, App, "2.0")); + Assert.Equal("appversionregression|contoso vpn|2.0", + TableHardwareRejectionNotificationTracker.BuildAppVersionRegressionRowKey(string.Empty, App, "2.0")); + Assert.Equal("appversionregression|realmjoin:contoso vpn|2.0", + TableHardwareRejectionNotificationTracker.BuildAppVersionRegressionRowKey(AppInstallSources.RealmJoin, App, "2.0")); } [Fact] public void AppVersionRegressionRowKey_NoPrefixCollisionWithOtherKeyspaces() { - var key = TableHardwareRejectionNotificationTracker.BuildAppVersionRegressionRowKey(App, "1.0"); + var key = TableHardwareRejectionNotificationTracker.BuildAppVersionRegressionRowKey(AppInstallSources.RealmJoin, App, "1.0"); Assert.StartsWith("appversionregression|", key, StringComparison.Ordinal); // The prefix range [prefix, "appversionregression}") must not swallow the sibling // keyspaces ("ruleregression|", "tpmpss|", "{mfr}|{model}"). @@ -293,6 +336,39 @@ public class AppVersionRegressionRadarTests Assert.True(string.CompareOrdinal("tpmpss|", "appversionregression}") > 0); } + [Fact] + public void AppVersionRegressionEntity_RealmJoinChannel_RoundTripsWithPrefixedKey() + { + var alert = new AppVersionRegressionAlert + { + TenantId = TenantA, + AppName = App, + Source = AppInstallSources.RealmJoin, + CurrentVersion = "2.4.0", + PreviousVersion = "2.3.9", + FirstNotifiedAt = Now, + LastEvaluatedAt = Now, + }; + + var entity = TableHardwareRejectionNotificationTracker.BuildAppVersionRegressionEntity(TenantA, alert); + Assert.Equal("appversionregression|realmjoin:contoso vpn|2.4.0", entity.RowKey); + Assert.Equal(AppInstallSources.RealmJoin, + TableHardwareRejectionNotificationTracker.MapToAppVersionRegressionAlert(entity).Source); + } + + [Fact] + public void AppVersionRegressionEntity_RowWithoutSourceColumn_MapsToIme() + { + var entity = new Azure.Data.Tables.TableEntity(TenantA.ToLowerInvariant(), "appversionregression|contoso vpn|2.4.0") + { + ["TenantId"] = TenantA, + ["AppName"] = App, + ["CurrentVersion"] = "2.4.0", + }; + Assert.Equal(AppInstallSources.Ime, + TableHardwareRejectionNotificationTracker.MapToAppVersionRegressionAlert(entity).Source); + } + [Fact] public void AppVersionRegressionEntity_RoundTripsAllFields() { @@ -318,6 +394,7 @@ public class AppVersionRegressionRadarTests var mapped = TableHardwareRejectionNotificationTracker.MapToAppVersionRegressionAlert(entity); Assert.Equal(TenantA, mapped.TenantId); Assert.Equal(App, mapped.AppName); + Assert.Equal(AppInstallSources.Ime, mapped.Source); Assert.Equal("2.4.0", mapped.CurrentVersion); Assert.Equal("2.3.9", mapped.PreviousVersion); Assert.Equal(1740, mapped.CurrentMedianSeconds); @@ -371,7 +448,7 @@ public class AppVersionRegressionRadarTests }; var root = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync( - rows, sessionRepo.Object, App, days: 30)); + rows, sessionRepo.Object, App, AppInstallSources.Ime, days: 30)); var version = root.GetProperty("versionBreakdown")[0]; Assert.Equal("1.0", version.GetProperty("appVersion").GetString()); @@ -395,12 +472,12 @@ public class AppVersionRegressionRadarTests // Empty-data early return still surfaces the block (episodes can outlive window data). var emptyRoot = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync( - new List(), sessionRepo.Object, App, days: 30, + new List(), sessionRepo.Object, App, AppInstallSources.Ime, days: 30, new List { alert })); Assert.Equal(1, emptyRoot.GetProperty("versionRegressions").GetArrayLength()); var root = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync( - new List { Install("1.0", 100, Recent) }, sessionRepo.Object, App, days: 30, + new List { Install("1.0", 100, Recent) }, sessionRepo.Object, App, AppInstallSources.Ime, days: 30, new List { alert })); var regression = root.GetProperty("versionRegressions")[0]; diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AppsAnalyticsFailureRateTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AppsAnalyticsFailureRateTests.cs index cf930d37..640f64e4 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/AppsAnalyticsFailureRateTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/AppsAnalyticsFailureRateTests.cs @@ -131,7 +131,7 @@ public class AppsAnalyticsFailureRateTests sessionRepo = mock.Object; } var result = await AppsAnalyticsHelper.BuildAnalyticsResponseAsync( - summaries, sessionRepo, "Contoso App", days: 30); + summaries, sessionRepo, "Contoso App", AppInstallSources.Ime, days: 30); return TestWire.SerializeToElement(result); } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/RealmJoinPackageInstallRowsTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/RealmJoinPackageInstallRowsTests.cs new file mode 100644 index 00000000..b94770ee --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/RealmJoinPackageInstallRowsTests.cs @@ -0,0 +1,314 @@ +using System.Text.Json; +using Azure.Data.Tables; +using AutopilotMonitor.Functions.Functions.Apps; +using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// RealmJoin packages as AppInstallSummaries rows (): +/// the ingest fold of realmjoin_package_started/completed, the scope + package-id row +/// identity, the store-side channel columns, and the channel dimension of the per-app aggregates +/// (an Intune app and a RealmJoin package with the same display name never merge). +/// +public class RealmJoinPackageInstallRowsTests +{ + private static readonly DateTime T0 = new(2026, 9, 17, 8, 29, 26, DateTimeKind.Utc); + private const string SessionId = "11111111-2222-3333-4444-555555555555"; + + private static Dictionary Aggregate(params EnrollmentEvent[] events) + { + // Same comparer as the production classification (EventIngestProcessor.Classification). + var summaries = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var evt in events) + EventIngestProcessor.AggregateAppInstallEvent(evt, "tenant", SessionId, summaries); + return summaries; + } + + private static AppInstallSummary Row(Dictionary summaries, string packageId, string scope = "machine") + => summaries[EventIngestProcessor.RealmJoinAggregationKey(scope, packageId)].Summary; + + private static EnrollmentEvent Started(DateTime at, string packageId = "generic-github-desktop", + string displayName = "GitHub Desktop", string scope = "machine", string? version = "3.6.5.0") + { + var data = new Dictionary + { + ["packageId"] = packageId, + ["displayName"] = displayName, + ["scope"] = scope, + }; + if (version != null) data["version"] = version; + return new EnrollmentEvent { EventType = Constants.EventTypes.RealmJoinPackageStarted, Timestamp = at, Data = data }; + } + + private static EnrollmentEvent Completed(DateTime at, bool success = true, int exitCode = 0, + string packageId = "generic-github-desktop", string displayName = "GitHub Desktop", string scope = "machine") + { + var evt = Started(at, packageId, displayName, scope); + evt.EventType = Constants.EventTypes.RealmJoinPackageCompleted; + // Wire form of the agent adapter: strings. + evt.Data!["success"] = success ? "true" : "false"; + evt.Data["lastExitCode"] = exitCode.ToString(); + return evt; + } + + // ── ingest fold: live watcher events ──────────────────────────────────── + + [Fact] + public void StartedThenCompleted_OneRealmJoinRow_WithMeasuredDuration() + { + var s = Row(Aggregate(Started(T0), Completed(T0.AddSeconds(22))), "generic-github-desktop"); + + Assert.Equal(AppInstallSources.RealmJoin, s.Source); + Assert.Equal("generic-github-desktop", s.AppId); + Assert.Equal("machine", s.InstallScope); + Assert.Equal("GitHub Desktop", s.AppName); + Assert.Equal("3.6.5.0", s.AppVersion); + Assert.Equal("Succeeded", s.Status); + Assert.Equal("Installed", s.TerminalState); + Assert.Equal(0, s.ExitCode); + Assert.Equal(string.Empty, s.FailureCode); + Assert.Equal(T0, s.StartedAt); + Assert.Equal(T0, s.LastAttemptStartedAt); + Assert.Equal(T0.AddSeconds(22), s.CompletedAt); + Assert.Equal(22, s.DurationSeconds); + Assert.Equal(1, s.InstallPassCount); + Assert.True(MetricsMath.HasMeasuredDuration(s)); + } + + [Fact] + public void FailedPackage_ExitCodeIsTheFailureCode() + { + var s = Row(Aggregate(Started(T0), Completed(T0.AddSeconds(40), success: false, exitCode: 1603)), "generic-github-desktop"); + + Assert.Equal("Failed", s.Status); + Assert.Equal("Error", s.TerminalState); + Assert.Equal(1603, s.ExitCode); + Assert.Equal("1603", s.FailureCode); + } + + [Fact] + public void StartedAndCompletedInOneRegistryPass_IsUnmeasured() + { + // Older agents report both events in the same watcher pass, milliseconds apart: that is + // no install duration. + var s = Row(Aggregate(Started(T0), Completed(T0.AddMilliseconds(4))), "generic-github-desktop"); + + Assert.Equal("Succeeded", s.Status); + Assert.Equal(0, s.DurationSeconds); + Assert.False(MetricsMath.HasMeasuredDuration(s)); + } + + [Fact] + public void MissingDisplayName_UsesPackageId_UntilANameArrives() + { + var summaries = Aggregate(Started(T0, displayName: string.Empty)); + Assert.Equal("generic-github-desktop", Row(summaries, "generic-github-desktop").AppName); + + EventIngestProcessor.AggregateAppInstallEvent(Completed(T0.AddSeconds(5)), "tenant", SessionId, summaries); + Assert.Equal("GitHub Desktop", Row(summaries, "generic-github-desktop").AppName); + } + + [Fact] + public void SamePackageIdInBothScopes_IsTwoRows() + { + var summaries = Aggregate( + Completed(T0, scope: "machine"), + Completed(T0, success: false, exitCode: 1, scope: "user")); + + Assert.Equal("Succeeded", Row(summaries, "generic-github-desktop", "machine").Status); + Assert.Equal("Failed", Row(summaries, "generic-github-desktop", "user").Status); + } + + [Fact] + public void RealmJoinPackage_AndIntuneApp_WithTheSameName_AreTwoRows() + { + var ime = new EnrollmentEvent + { + EventType = "app_install_completed", + Timestamp = T0, + Data = new Dictionary { ["appName"] = "GitHub Desktop", ["state"] = "Installed" }, + }; + var summaries = Aggregate(ime, Completed(T0.AddSeconds(1))); + + Assert.Equal(2, summaries.Count); + Assert.Equal(string.Empty, summaries["GitHub Desktop"].Summary.Source); + Assert.Equal(AppInstallSources.RealmJoin, Row(summaries, "generic-github-desktop").Source); + } + + [Fact] + public void EventWithoutPackageId_IsIgnored() + { + var evt = Completed(T0); + evt.Data!.Remove("packageId"); + Assert.Empty(Aggregate(evt)); + } + + // ── store: row identity, channel columns, reconcile ───────────────────── + + [Fact] + public void RowKey_ImeUnchanged_RealmJoinKeyedByScopeAndPackageId() + { + Assert.Equal($"{SessionId}_Contoso App_2", + TableStorageService.BuildAppInstallSummaryRowKey(new AppInstallSummary { SessionId = SessionId, AppName = "Contoso App/2" })); + + var rj = new AppInstallSummary + { + SessionId = SessionId, + Source = AppInstallSources.RealmJoin, + InstallScope = "user", + AppId = "generic-7zip-usersettings", + AppName = "7-Zip Settings", + }; + var key = TableStorageService.BuildAppInstallSummaryRowKey(rj); + Assert.Equal($"{SessionId}_realmjoin|user|generic-7zip-usersettings", key); + // The per-session range scans (ESP stamp, close step, deletion) match on this prefix. + Assert.StartsWith($"{SessionId}_", key, StringComparison.Ordinal); + } + + [Fact] + public void Entity_ImeRowHasNoChannelColumns_RealmJoinRowRoundTrips() + { + var ime = TableStorageService.BuildAppInstallSummaryEntity( + new AppInstallSummary { TenantId = "t", SessionId = SessionId, AppName = "App" }, "rk"); + Assert.False(ime.ContainsKey("Source")); + Assert.False(ime.ContainsKey("InstallScope")); + + var summary = new AppInstallSummary + { + TenantId = "t", + SessionId = SessionId, + AppName = "7-Zip", + Source = AppInstallSources.RealmJoin, + InstallScope = "machine", + AppId = "generic-7zip", + }; + var entity = TableStorageService.BuildAppInstallSummaryEntity(summary, "rk"); + var sut = new TableStorageService(new Mock().Object, NullLogger.Instance); + var mapped = sut.MapToAppInstallSummary(entity); + + Assert.Equal(AppInstallSources.RealmJoin, mapped.Source); + Assert.Equal("machine", mapped.InstallScope); + Assert.Equal("generic-7zip", mapped.AppId); + } + + [Fact] + public void Reconcile_PackageIdFallbackName_KeepsTheStoredDisplayName() + { + var existing = new TableEntity("t", "rk") { ["AppName"] = "7-Zip", ["StartedAt"] = T0 }; + var summary = new AppInstallSummary + { + Source = AppInstallSources.RealmJoin, + AppId = "generic-7zip", + AppName = "generic-7zip", + StartedAt = T0.AddMinutes(30), + }; + + TableStorageService.ReconcileAppInstallSummaryWithExisting(summary, existing); + Assert.Equal("7-Zip", summary.AppName); + } + + [Fact] + public void EspBlockingStamp_NeverTouchesRealmJoinRows() + { + var sets = EspBlockingSets.FromEventData(new Dictionary + { + ["espTrackedWin32AppIds"] = new[] { "generic-7zip" }, + })!; + var rj = new TableEntity("t", "rk") { ["Source"] = AppInstallSources.RealmJoin, ["AppId"] = "generic-7zip" }; + var ime = new TableEntity("t", "rk2") { ["AppId"] = "generic-7zip" }; + + Assert.False(TableStorageService.ShouldStampEspBlocking(rj, sets)); + Assert.True(TableStorageService.ShouldStampEspBlocking(ime, sets)); + } + + // ── per-app aggregates: the channel is part of the app ────────────────── + + private static AppInstallSummary Summary(string appName, string source, string status = "Succeeded", int duration = 60) + => new() + { + TenantId = "t", + SessionId = Guid.NewGuid().ToString(), + AppName = appName, + Source = source, + Status = status, + TerminalState = status == "Failed" ? "Error" : "Installed", + DurationSeconds = duration, + StartedAt = DateTime.UtcNow.AddDays(-1), + LastAttemptStartedAt = DateTime.UtcNow.AddDays(-1), + }; + + private static List MixedChannelRows() => new() + { + Summary("GitHub Desktop", string.Empty), + Summary("GitHub Desktop", AppInstallSources.RealmJoin, status: "Failed"), + Summary("GitHub Desktop", AppInstallSources.RealmJoin), + }; + + [Fact] + public void AppsList_SameNameInTwoChannels_IsTwoItems() + { + var root = TestWire.SerializeToElement(AppsAnalyticsHelper.BuildAppsListResponse(MixedChannelRows(), days: 30)); + var apps = root.GetProperty("apps").EnumerateArray().ToList(); + + Assert.Equal(2, apps.Count); + var ime = apps.Single(a => a.GetProperty("source").GetString() == AppInstallSources.Ime); + var rj = apps.Single(a => a.GetProperty("source").GetString() == AppInstallSources.RealmJoin); + Assert.Equal(1, ime.GetProperty("totalInstalls").GetInt32()); + Assert.Equal(2, rj.GetProperty("totalInstalls").GetInt32()); + Assert.Equal(1, rj.GetProperty("failed").GetInt32()); + } + + [Fact] + public async Task Analytics_And_Sessions_FilterByChannel() + { + var sessionRepo = new Mock(); + sessionRepo.Setup(r => r.GetSessionAsync(It.IsAny(), It.IsAny())).ReturnsAsync((SessionSummary?)null); + + var rj = await AppsAnalyticsHelper.BuildAnalyticsResponseAsync( + MixedChannelRows(), sessionRepo.Object, "GitHub Desktop", AppInstallSources.RealmJoin, days: 30); + Assert.Equal(AppInstallSources.RealmJoin, rj.Source); + Assert.Equal(2, rj.Summary.TotalInstalls); + Assert.Equal(1, rj.Summary.Failed); + + var ime = await AppsAnalyticsHelper.BuildAnalyticsResponseAsync( + MixedChannelRows(), sessionRepo.Object, "GitHub Desktop", AppInstallSources.Ime, days: 30); + Assert.Equal(AppInstallSources.Ime, ime.Source); + Assert.Equal(1, ime.Summary.TotalInstalls); + + var sessions = await AppsAnalyticsHelper.BuildSessionsResponseAsync( + MixedChannelRows(), sessionRepo.Object, "GitHub Desktop", AppInstallSources.RealmJoin, days: 30, + statusFilter: "all", modelFilter: null, versionFilter: null, offset: 0, limit: 50); + Assert.Equal(2, sessions.Total); + } + + [Fact] + public void AppMetrics_GroupsPerChannel() + { + var payload = MetricsMath.BuildAppMetricsPayload(MixedChannelRows()); + + Assert.Equal(2, payload.TotalApps); + var failing = Assert.Single(payload.TopFailingApps); + Assert.Equal(AppInstallSources.RealmJoin, failing.Source); + Assert.Equal("GitHub Desktop", failing.AppName); + } + + [Theory] + [InlineData(null, true, "ime")] + [InlineData("", true, "ime")] + [InlineData("realmjoin", true, "realmjoin")] + [InlineData(" RealmJoin ", true, "realmjoin")] + [InlineData("office-c2r", false, "office-c2r")] + public void SourceQueryParam(string? raw, bool valid, string expected) + { + Assert.Equal(valid, AppsAnalyticsHelper.TryParseSourceQueryParam(raw, out var source)); + Assert.Equal(expected, source); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SharedManifestParityTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SharedManifestParityTests.cs index 759b5843..eb2da46d 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/SharedManifestParityTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/SharedManifestParityTests.cs @@ -88,6 +88,8 @@ public sealed class SharedManifestParityTests ["webhookProviderTypes"] = EnumMap(), ["annotationLanes"] = AnnotationLanes.All, ["annotationVerdicts"] = AnnotationVerdicts.All, + // Install channels of the app rows (AppInstallSummary.Source and the per-app DTOs). + ["appInstallSources"] = AppInstallSources.All, // Community rule submissions: kinds, effective statuses (incl. the derived "published"), // attribution modes and reviewer decisions — the MCP tool schemas enumerate these. ["ruleSubmissionKinds"] = RuleSubmissionKinds.All, diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TypedContractCompletionWireParityTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TypedContractCompletionWireParityTests.cs index d8ad386b..1dfd2b49 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/TypedContractCompletionWireParityTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TypedContractCompletionWireParityTests.cs @@ -191,6 +191,7 @@ public class TypedContractCompletionWireParityTests new { appName = "Contoso App", + source = "ime", totalInstalls = 4, succeeded = 2, skipped = 1, @@ -234,6 +235,7 @@ public class TypedContractCompletionWireParityTests new AppMetricsAppGroup { AppName = "Contoso App", + Source = AppInstallSources.Ime, TotalInstalls = 4, Succeeded = 2, Skipped = 1, @@ -593,6 +595,7 @@ public class TypedContractCompletionWireParityTests var item = new AppsListItem { AppName = "Contoso App", + Source = AppInstallSources.Ime, AppType = "Win32", TotalInstalls = 5, Succeeded = 3, @@ -610,6 +613,7 @@ public class TypedContractCompletionWireParityTests var anonymousItem = new { appName = "Contoso App", + source = "ime", appType = "Win32", totalInstalls = 5, succeeded = 3, @@ -677,6 +681,7 @@ public class TypedContractCompletionWireParityTests { success = true, appName = "Contoso App", + source = "ime", appType = string.Empty, windowDays = 30, collisionExcluded = 0, @@ -709,6 +714,7 @@ public class TypedContractCompletionWireParityTests { Success = true, AppName = "Contoso App", + Source = AppInstallSources.Ime, AppType = string.Empty, WindowDays = 30, CollisionExcluded = 0, diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableHardwareRejectionNotificationTracker.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableHardwareRejectionNotificationTracker.cs index 1b7c5592..9fad7533 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableHardwareRejectionNotificationTracker.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableHardwareRejectionNotificationTracker.cs @@ -18,6 +18,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage /// - F3 rule regression: "ruleregression|{ruleId-lower}" (trimmed; payload-carrying rows /// that are refreshed while the episode is active and deleted on re-arm) /// - app-version duration regression: "appversionregression|{app-lower}|{version-lower}" + /// (non-IME channels: "appversionregression|{source}:{app-lower}|{version-lower}") /// (trimmed + table-key-sanitized; same payload-carrying episode semantics — raw /// AppName/CurrentVersion live in columns, the mapper never parses the RowKey) /// Race-safe via AddEntityAsync: Azure Table Storage returns 409 Conflict if the entity already exists. @@ -309,7 +310,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage } } - public async Task DeleteAppVersionRegressionAsync(string tenantId, string appName, string currentVersion) + public async Task DeleteAppVersionRegressionAsync(string tenantId, string source, string appName, string currentVersion) { if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(appName) @@ -320,7 +321,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage try { await _table.DeleteEntityAsync( - tenantId.ToLowerInvariant(), BuildAppVersionRegressionRowKey(appName, currentVersion)); + tenantId.ToLowerInvariant(), BuildAppVersionRegressionRowKey(source, appName, currentVersion)); } catch (RequestFailedException ex) when (ex.Status == 404) { @@ -576,10 +577,14 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage /// two apps share an episode row — tolerable); the raw values live in columns and the /// mapper reads them, never the RowKey. /// - internal static string BuildAppVersionRegressionRowKey(string appName, string version) + internal static string BuildAppVersionRegressionRowKey(string? source, string appName, string version) { var app = SanitizeTableKey((appName ?? string.Empty).Trim().ToLowerInvariant()); var ver = SanitizeTableKey((version ?? string.Empty).Trim().ToLowerInvariant()); + // IME keys predate channels and keep their shape; other channels prefix the app part. + var channel = AppInstallSources.Normalize(source); + if (channel != AppInstallSources.Ime) + app = $"{SanitizeTableKey(channel)}:{app}"; return $"{AppVersionRegressionRowKeyPrefix}{app}|{ver}"; } @@ -598,10 +603,11 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage internal static TableEntity BuildAppVersionRegressionEntity(string tenantId, AppVersionRegressionAlert alert) { return new TableEntity( - tenantId.ToLowerInvariant(), BuildAppVersionRegressionRowKey(alert.AppName, alert.CurrentVersion)) + tenantId.ToLowerInvariant(), BuildAppVersionRegressionRowKey(alert.Source, alert.AppName, alert.CurrentVersion)) { ["TenantId"] = tenantId, ["AppName"] = alert.AppName, + ["Source"] = AppInstallSources.Normalize(alert.Source), ["CurrentVersion"] = alert.CurrentVersion, ["PreviousVersion"] = alert.PreviousVersion ?? string.Empty, ["CurrentMedianSeconds"] = alert.CurrentMedianSeconds, @@ -620,6 +626,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage { TenantId = entity.GetString("TenantId") ?? entity.PartitionKey, AppName = entity.GetString("AppName") ?? string.Empty, + // Rows written before channels existed are IME episodes. + Source = AppInstallSources.Normalize(entity.GetString("Source")), CurrentVersion = entity.GetString("CurrentVersion") ?? string.Empty, PreviousVersion = entity.GetString("PreviousVersion") ?? string.Empty, CurrentMedianSeconds = entity.GetInt32("CurrentMedianSeconds") ?? 0, diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/AppsAnalyticsHelper.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/AppsAnalyticsHelper.cs index 3405370a..4add5b4d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/AppsAnalyticsHelper.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/AppsAnalyticsHelper.cs @@ -30,6 +30,17 @@ namespace AutopilotMonitor.Functions.Functions.Apps return Guid.TryParse(raw, out _); } + /// + /// Parses the optional ?source= install channel of the per-app endpoints. Absent means + /// , the channel every app link carried before channels + /// existed. Returns false for an unknown value (caller should emit a 400). + /// + public static bool TryParseSourceQueryParam(string? raw, out string source) + { + source = string.IsNullOrEmpty(raw) ? AppInstallSources.Ime : raw!.Trim().ToLowerInvariant(); + return AppInstallSources.IsKnown(source); + } + // ── Opt-in pagination ─────────────────────────────────────────────── /// Upper bound for a single apps/list page when the caller opts into pagination. @@ -141,7 +152,9 @@ namespace AutopilotMonitor.Functions.Functions.Apps // they are excluded from every per-app group and disclosed via collisionExcluded. var collisionExcluded = summaries.Count(s => s.AppIdCollision); - var apps = summaries.Where(s => !s.AppIdCollision).GroupBy(s => s.AppName).Select(g => + // One group per (channel, name): an Intune app and a RealmJoin package that share a + // display name are different installs and stay separate rows. + var apps = summaries.Where(s => !s.AppIdCollision).GroupBy(s => (Source: AppInstallSources.Normalize(s.Source), s.AppName)).Select(g => { // PR0 classification (see MetricsMath.IsSkipTerminalState / HasMeasuredDuration): // skips leave the rate + durations; duration stats read measured rows only. @@ -159,7 +172,8 @@ namespace AutopilotMonitor.Functions.Functions.Apps return new AppsListItem { - AppName = g.Key, + AppName = g.Key.AppName, + Source = g.Key.Source, AppType = g.Select(s => s.AppType).FirstOrDefault(t => !string.IsNullOrEmpty(t)) ?? string.Empty, TotalInstalls = total, Succeeded = installed.Count, @@ -178,6 +192,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps .OrderByDescending(a => a.Failed) .ThenByDescending(a => a.FailureRate) .ThenBy(a => a.AppName, StringComparer.OrdinalIgnoreCase) // deterministic tiebreaker for stable paging cursors + .ThenBy(a => a.Source, StringComparer.Ordinal) .ToList(); // Legacy mode: caller did not opt into pagination → full array (web UI pages client-side). @@ -225,6 +240,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps List allSummaries, ISessionRepository sessionRepo, string appName, + string source, int days, IReadOnlyList? versionRegressions = null) { @@ -238,6 +254,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps var inWindow = allSummaries .Where(s => string.Equals(s.AppName, appName, StringComparison.OrdinalIgnoreCase) + && AppInstallSources.Normalize(s.Source) == source && s.StartedAt >= cutoff) .ToList(); @@ -252,6 +269,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps { Success = true, AppName = appName, + Source = source, AppType = string.Empty, WindowDays = days, CollisionExcluded = collisionExcluded, @@ -413,6 +431,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps { Success = true, AppName = appName, + Source = source, AppType = appType, WindowDays = days, CollisionExcluded = collisionExcluded, @@ -449,6 +468,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps List allSummaries, ISessionRepository sessionRepo, string appName, + string source, int days, string statusFilter, string? modelFilter, @@ -460,6 +480,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps var summaries = allSummaries .Where(s => string.Equals(s.AppName, appName, StringComparison.OrdinalIgnoreCase) + && AppInstallSources.Normalize(s.Source) == source && s.StartedAt >= cutoff) .ToList(); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetAppAnalyticsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetAppAnalyticsFunction.cs index 8ef4b594..d1c66beb 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetAppAnalyticsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetAppAnalyticsFunction.cs @@ -1,6 +1,7 @@ using System.Net; using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; @@ -48,14 +49,19 @@ namespace AutopilotMonitor.Functions.Functions.Apps var query = req.Query; var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365); + if (!AppsAnalyticsHelper.TryParseSourceQueryParam(query["source"], out var source)) + { + return await req.BadRequestAsync($"source must be one of: {string.Join(", ", AppInstallSources.All)}"); + } var summaries = await AppsAnalyticsHelper.LoadSummariesAsync(_metricsRepo, tenantId, days); // Active duration-regression episodes for this app (fail-soft: empty on error). var versionRegressions = (await _notificationTracker.GetAppVersionRegressionsAsync(tenantId)) - .Where(a => string.Equals(a.AppName, decodedAppName, StringComparison.OrdinalIgnoreCase)) + .Where(a => string.Equals(a.AppName, decodedAppName, StringComparison.OrdinalIgnoreCase) + && AppInstallSources.Normalize(a.Source) == source) .ToList(); var body = await AppsAnalyticsHelper.BuildAnalyticsResponseAsync( - summaries, _sessionRepo, decodedAppName, days, versionRegressions); + summaries, _sessionRepo, decodedAppName, source, days, versionRegressions); var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(body); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetAppSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetAppSessionsFunction.cs index 4c693da8..e8e0df4d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetAppSessionsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetAppSessionsFunction.cs @@ -1,6 +1,7 @@ using System.Net; using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; @@ -47,6 +48,10 @@ namespace AutopilotMonitor.Functions.Functions.Apps var query = req.Query; var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365); + if (!AppsAnalyticsHelper.TryParseSourceQueryParam(query["source"], out var source)) + { + return await req.BadRequestAsync($"source must be one of: {string.Join(", ", AppInstallSources.All)}"); + } var statusFilter = (query["status"] ?? "all").Trim().ToLowerInvariant(); var modelFilter = query["model"]; @@ -58,7 +63,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps var summaries = await AppsAnalyticsHelper.LoadSummariesAsync(_metricsRepo, tenantId, days); var body = await AppsAnalyticsHelper.BuildSessionsResponseAsync( - summaries, _sessionRepo, decodedAppName, days, + summaries, _sessionRepo, decodedAppName, source, days, statusFilter, modelFilter, versionFilter, offset, limit); var response = req.CreateResponse(HttpStatusCode.OK); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppAnalyticsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppAnalyticsFunction.cs index 612b35d2..60538d88 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppAnalyticsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppAnalyticsFunction.cs @@ -53,6 +53,10 @@ namespace AutopilotMonitor.Functions.Functions.Apps return await req.BadRequestAsync("tenantId must be a valid GUID"); } var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365); + if (!AppsAnalyticsHelper.TryParseSourceQueryParam(query["source"], out var source)) + { + return await req.BadRequestAsync($"source must be one of: {string.Join(", ", AppInstallSources.All)}"); + } _logger.LogInformation( "Global apps/{App}/analytics requested (user: {User}, tenantId: {TenantId}, days: {Days})", @@ -64,10 +68,11 @@ namespace AutopilotMonitor.Functions.Functions.Apps var versionRegressions = string.IsNullOrEmpty(scopedTenantId) ? new List() : (await _notificationTracker.GetAppVersionRegressionsAsync(scopedTenantId!)) - .Where(a => string.Equals(a.AppName, decodedAppName, StringComparison.OrdinalIgnoreCase)) + .Where(a => string.Equals(a.AppName, decodedAppName, StringComparison.OrdinalIgnoreCase) + && AppInstallSources.Normalize(a.Source) == source) .ToList(); var body = await AppsAnalyticsHelper.BuildAnalyticsResponseAsync( - summaries, _sessionRepo, decodedAppName, days, versionRegressions); + summaries, _sessionRepo, decodedAppName, source, days, versionRegressions); var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(body); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppSessionsFunction.cs index 3ecd1681..92b8218a 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppSessionsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppSessionsFunction.cs @@ -1,6 +1,7 @@ using System.Net; using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; @@ -52,6 +53,10 @@ namespace AutopilotMonitor.Functions.Functions.Apps return await req.BadRequestAsync("tenantId must be a valid GUID"); } var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365); + if (!AppsAnalyticsHelper.TryParseSourceQueryParam(query["source"], out var source)) + { + return await req.BadRequestAsync($"source must be one of: {string.Join(", ", AppInstallSources.All)}"); + } var statusFilter = (query["status"] ?? "all").Trim().ToLowerInvariant(); var modelFilter = query["model"]; @@ -67,7 +72,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps var summaries = await AppsAnalyticsHelper.LoadSummariesAsync(_metricsRepo, scopedTenantId, days); var body = await AppsAnalyticsHelper.BuildSessionsResponseAsync( - summaries, _sessionRepo, decodedAppName, days, + summaries, _sessionRepo, decodedAppName, source, days, statusFilter, modelFilter, versionFilter, offset, limit); var response = req.CreateResponse(HttpStatusCode.OK); diff --git a/src/Backend/AutopilotMonitor.Functions/Helpers/AppVersionRegressionRadar.cs b/src/Backend/AutopilotMonitor.Functions/Helpers/AppVersionRegressionRadar.cs index 3690cb9c..fb82210b 100644 --- a/src/Backend/AutopilotMonitor.Functions/Helpers/AppVersionRegressionRadar.cs +++ b/src/Backend/AutopilotMonitor.Functions/Helpers/AppVersionRegressionRadar.cs @@ -8,6 +8,8 @@ public sealed class AppVersionDurationRegressionFinding { public string TenantId { get; init; } = string.Empty; public string AppName { get; init; } = string.Empty; + /// Install channel (); part of the app identity. + public string Source { get; init; } = AppInstallSources.Ime; public string CurrentVersion { get; init; } = string.Empty; public string PreviousVersion { get; init; } = string.Empty; @@ -23,7 +25,7 @@ public sealed class AppVersionDurationRegressionFinding /// /// Deterministic, I/O-free core of the app-version duration regression radar. Detects /// "version X made this app's installs much slower" from the per-(session, app) install -/// summaries: per (tenant, app), the newest version's MEDIAN measured install duration is +/// summaries: per (tenant, channel, app), the newest version's MEDIAN measured install duration is /// compared against the previous version's median. Medians (nearest-rank, shared with the /// apps dashboard via ) rather than means — /// a single back-stamped straggler must not fire a fleet alert. A regression fires only @@ -90,7 +92,8 @@ public static class AppVersionRegressionRadar findings.Add(new AppVersionDurationRegressionFinding { TenantId = app.Value[0].TenantId, - AppName = app.Key, + AppName = app.Key.AppName, + Source = app.Key.Source, CurrentVersion = current.Version, PreviousVersion = previous.Version, CurrentMedianSeconds = current.MedianSeconds, @@ -103,6 +106,7 @@ public static class AppVersionRegressionRadar return findings .OrderByDescending(f => f.Lift) .ThenBy(f => f.AppName, StringComparer.Ordinal) + .ThenBy(f => f.Source, StringComparer.Ordinal) .ToList(); } @@ -116,7 +120,7 @@ public static class AppVersionRegressionRadar /// public static bool ShouldReArm(IReadOnlyList tenantSummaries, AppVersionRegressionAlert alert) { - var stats = ComputeVersionStatsForApp(tenantSummaries, alert.AppName); + var stats = ComputeVersionStatsForApp(tenantSummaries, AppInstallSources.Normalize(alert.Source), alert.AppName); var current = stats.FirstOrDefault(s => string.Equals(s.Version, alert.CurrentVersion, StringComparison.Ordinal)); if (current == null || current.MeasuredCount < MinMeasuredInstalls) return true; @@ -126,26 +130,23 @@ public static class AppVersionRegressionRadar return current.MedianSeconds < ReArmLiftFactor * previous.MedianSeconds; } - /// Per-version stats for one app (measured rows only), or empty when the app has none. - public static List ComputeVersionStatsForApp(IReadOnlyList tenantSummaries, string appName) + /// Per-version stats for one app of one channel (measured rows only), or empty when the app has none. + public static List ComputeVersionStatsForApp(IReadOnlyList tenantSummaries, string source, string appName) { - foreach (var app in MeasuredAppGroups(tenantSummaries)) - { - if (string.Equals(app.Key, appName, StringComparison.OrdinalIgnoreCase)) - return ComputeVersionStats(app.Value); - } - return new List(); + return MeasuredAppGroups(tenantSummaries).TryGetValue((source, appName), out var rows) + ? ComputeVersionStats(rows) + : new List(); } /// - /// Groups the MEASURED rows by app name: succeeded, non-skip, plausible duration, + /// Groups the MEASURED rows by (channel, app name): succeeded, non-skip, plausible duration, /// no AppId collision (a second app's outcomes must not fire this app's alert), /// non-empty version. Rate/failure signals are deliberately out of scope — this /// radar alerts on duration only. /// - internal static Dictionary> MeasuredAppGroups(IReadOnlyList summaries) + internal static Dictionary<(string Source, string AppName), List> MeasuredAppGroups(IReadOnlyList summaries) { - var groups = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var groups = new Dictionary<(string Source, string AppName), List>(AppKeyComparer.Instance); foreach (var summary in summaries) { if (string.IsNullOrEmpty(summary.AppName)) continue; @@ -158,16 +159,30 @@ public static class AppVersionRegressionRadar // enter the duration population, on both sides of every comparison. if (!summary.LastAttemptStartedAt.HasValue) continue; - if (!groups.TryGetValue(summary.AppName, out var list)) + var key = (AppInstallSources.Normalize(summary.Source), summary.AppName); + if (!groups.TryGetValue(key, out var list)) { list = new List(); - groups[summary.AppName] = list; + groups[key] = list; } list.Add(summary); } return groups; } + /// (channel, app) key: channel ordinal, app name case-insensitive like every per-app lookup. + private sealed class AppKeyComparer : IEqualityComparer<(string Source, string AppName)> + { + public static readonly AppKeyComparer Instance = new(); + + public bool Equals((string Source, string AppName) x, (string Source, string AppName) y) => + string.Equals(x.Source, y.Source, StringComparison.Ordinal) && + string.Equals(x.AppName, y.AppName, StringComparison.OrdinalIgnoreCase); + + public int GetHashCode((string Source, string AppName) key) => + HashCode.Combine(StringComparer.Ordinal.GetHashCode(key.Source), StringComparer.OrdinalIgnoreCase.GetHashCode(key.AppName)); + } + /// Per-version first/last-seen and nearest-rank median over one app's measured rows. internal static List ComputeVersionStats(IReadOnlyList measuredAppRows) { diff --git a/src/Backend/AutopilotMonitor.Functions/Helpers/MetricsMath.cs b/src/Backend/AutopilotMonitor.Functions/Helpers/MetricsMath.cs index 5fdb274b..ca3b681a 100644 --- a/src/Backend/AutopilotMonitor.Functions/Helpers/MetricsMath.cs +++ b/src/Backend/AutopilotMonitor.Functions/Helpers/MetricsMath.cs @@ -81,7 +81,8 @@ public static class MetricsMath // keeps every row, since transferred bytes are real regardless of identity mixing. var totalCollisionExcluded = summaryList.Count(s => s.AppIdCollision); - var appGroups = summaryList.Where(s => !s.AppIdCollision).GroupBy(s => s.AppName).Select(g => + // One group per (channel, name) — see AppsAnalyticsHelper.BuildAppsListResponse. + var appGroups = summaryList.Where(s => !s.AppIdCollision).GroupBy(s => (Source: AppInstallSources.Normalize(s.Source), s.AppName)).Select(g => { // PR0 (2026-07-26) classification — see IsSkipTerminalState / HasMeasuredDuration: // skipped = no real install attempt (TerminalState Skipped/Postponed) @@ -105,7 +106,8 @@ public static class MetricsMath return new AppMetricsAppGroup { - AppName = g.Key, + AppName = g.Key.AppName, + Source = g.Key.Source, TotalInstalls = total, Succeeded = installed.Count, Skipped = skipped.Count, diff --git a/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.AppInstall.cs b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.AppInstall.cs index 8f82deb7..c5003a58 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.AppInstall.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.AppInstall.cs @@ -5,7 +5,8 @@ namespace AutopilotMonitor.Functions.Services /// /// Per-app install summary aggregation — folds a batch of app_install_* + /// download_progress + do_telemetry events into an - /// keyed by app name. + /// keyed by app name. RealmJoin package events fold into + /// their own rows (.RealmJoinPackages.cs). /// public sealed partial class EventIngestProcessor { @@ -13,6 +14,12 @@ namespace AutopilotMonitor.Functions.Services // test seam so the TerminalState / status-fold contract is pinned by unit tests (PR0). internal static void AggregateAppInstallEvent(EnrollmentEvent evt, string tenantId, string sessionId, Dictionary summaries) { + if (IsRealmJoinPackageEvent(evt.EventType)) + { + AggregateRealmJoinPackageEvent(evt, tenantId, sessionId, summaries); + return; + } + bool isRelevant = evt.EventType == "app_install_started" || evt.EventType == "app_install_start" || evt.EventType == "app_install_completed" || evt.EventType == "app_install_complete" || diff --git a/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.RealmJoinPackages.cs b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.RealmJoinPackages.cs new file mode 100644 index 00000000..0c0d4df8 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.RealmJoinPackages.cs @@ -0,0 +1,93 @@ +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.State; +using AutopilotMonitor.Shared.Models; +using SharedConstants = AutopilotMonitor.Shared.Constants; + +namespace AutopilotMonitor.Functions.Services +{ + /// + /// RealmJoin package rows of AppInstallSummaries (), + /// folded from the watcher's realmjoin_package_started / _completed events and + /// keyed by scope + package id. The watcher reports only packages that appear once RealmJoin + /// owns its deployment (phase 100+); packages written to the RealmJoin registry earlier are + /// Intune (IME) installs of RealmJoin-wrapped apps and already have their IME row — they must + /// not get a second one. + /// + public sealed partial class EventIngestProcessor + { + internal static bool IsRealmJoinPackageEvent(string? eventType) => + eventType == SharedConstants.EventTypes.RealmJoinPackageStarted || + eventType == SharedConstants.EventTypes.RealmJoinPackageCompleted; + + /// In-batch key of a RealmJoin row; never collides with an IME row's app-name key. + internal static string RealmJoinAggregationKey(string scope, string packageId) => + $"{AppInstallSources.RealmJoin}|{scope}|{packageId}"; + + internal static void AggregateRealmJoinPackageEvent(EnrollmentEvent evt, string tenantId, string sessionId, Dictionary summaries) + { + if (evt.Data == null) return; + var packageId = ReadRealmJoinString(evt.Data, DecisionEngine.RealmJoinPayloadKeys.PackageId); + if (string.IsNullOrEmpty(packageId)) return; + var scope = RealmJoinPackageFact.NormalizeScope(ReadRealmJoinString(evt.Data, DecisionEngine.RealmJoinPayloadKeys.Scope)); + + var key = RealmJoinAggregationKey(scope, packageId!); + if (!summaries.TryGetValue(key, out var state)) + { + state = new AppInstallAggregationState + { + Summary = new AppInstallSummary + { + Source = AppInstallSources.RealmJoin, + InstallScope = scope, + AppId = packageId!, + AppName = packageId!, + SessionId = sessionId, + TenantId = tenantId, + StartedAt = evt.Timestamp + } + }; + summaries[key] = state; + } + var summary = state.Summary; + + // RealmJoin does not always write DisplayName; the package id stands in until it does. + var displayName = ReadRealmJoinString(evt.Data, DecisionEngine.RealmJoinPayloadKeys.DisplayName); + if (!string.IsNullOrEmpty(displayName)) + summary.AppName = displayName!; + var version = ReadRealmJoinString(evt.Data, DecisionEngine.RealmJoinPayloadKeys.Version); + if (!string.IsNullOrEmpty(version)) + summary.AppVersion = version!; + + if (evt.EventType == SharedConstants.EventTypes.RealmJoinPackageStarted) + { + if (!state.InstallStartedAt.HasValue || evt.Timestamp < state.InstallStartedAt.Value) + state.InstallStartedAt = evt.Timestamp; + if (!summary.LastAttemptStartedAt.HasValue || evt.Timestamp > summary.LastAttemptStartedAt.Value) + summary.LastAttemptStartedAt = evt.Timestamp; + summary.InstallPassCount++; + if (summary.Status == string.Empty || summary.Status == "InProgress") + summary.Status = "InProgress"; + } + else + { + if (!bool.TryParse(ReadRealmJoinString(evt.Data, DecisionEngine.RealmJoinPayloadKeys.Success), out var success)) + return; + var exitCode = int.TryParse(ReadRealmJoinString(evt.Data, DecisionEngine.RealmJoinPayloadKeys.LastExitCode), out var code) + ? code : (int?)null; + summary.Status = success ? "Succeeded" : "Failed"; + summary.TerminalState = success ? "Installed" : "Error"; + summary.ExitCode = exitCode; + // RealmJoin reports no error text; the exit code is the failure code. + summary.FailureCode = success ? string.Empty : (exitCode?.ToString() ?? string.Empty); + summary.CompletedAt = evt.Timestamp; + } + + // Duration = completion minus the observed start. A package the watcher first saw + // already finished yields 0 s (same registry pass), which the metrics read as unmeasured. + RecalculateAppDurations(state); + } + + private static string? ReadRealmJoinString(Dictionary data, string key) => + data.TryGetValue(key, out var value) ? value?.ToString()?.Trim() : null; + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.AppVersionRegression.cs b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.AppVersionRegression.cs index b9fa26e1..31844221 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.AppVersionRegression.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.AppVersionRegression.cs @@ -84,14 +84,14 @@ namespace AutopilotMonitor.Functions.Services var findings = AppVersionRegressionRadar.Evaluate(tenantSummaries); var active = await _hardwareRejectionTracker.GetAppVersionRegressionsAsync(tenantId); var activeByKey = active.ToDictionary( - a => AppVersionEpisodeKey(a.AppName, a.CurrentVersion), StringComparer.OrdinalIgnoreCase); + a => AppVersionEpisodeKey(a.Source, a.AppName, a.CurrentVersion), StringComparer.OrdinalIgnoreCase); int fired = 0, refreshed = 0, rearmed = 0; var firedKeys = new System.Collections.Generic.HashSet(StringComparer.OrdinalIgnoreCase); foreach (var finding in findings) { - var key = AppVersionEpisodeKey(finding.AppName, finding.CurrentVersion); + var key = AppVersionEpisodeKey(finding.Source, finding.AppName, finding.CurrentVersion); firedKeys.Add(key); if (activeByKey.TryGetValue(key, out var existing)) @@ -109,27 +109,30 @@ namespace AutopilotMonitor.Functions.Services continue; // a concurrent pass won the episode — its bell suffices fired++; + var isIme = finding.Source == AppInstallSources.Ime; await _tenantNotificationService.CreateNotificationAsync( tenantId, type: "app_version_duration_regression", - title: $"Install duration regressed: {finding.AppName}", + title: $"Install duration regressed: {finding.AppName}" + (isIme ? string.Empty : " (RealmJoin)"), message: BuildAppVersionRegressionMessage(finding), - // Canonical appDetailUrl shape (lib/routes.ts) with the radar's horizon window. - href: $"/apps/detail?name={Uri.EscapeDataString(finding.AppName)}&days={AppVersionRegressionRadar.HorizonDays}"); + // Canonical appDetailUrl shape (lib/routes.ts) with the radar's horizon window; + // source is omitted for IME, its default. + href: $"/apps/detail?name={Uri.EscapeDataString(finding.AppName)}&days={AppVersionRegressionRadar.HorizonDays}" + + (isIme ? string.Empty : $"&source={Uri.EscapeDataString(finding.Source)}")); await _opsEventService.RecordAppVersionDurationRegressionAsync( - tenantId, finding.AppName, finding.CurrentVersion, finding.PreviousVersion, + tenantId, finding.AppName, finding.Source, finding.CurrentVersion, finding.PreviousVersion, finding.CurrentMedianSeconds, finding.PreviousMedianSeconds, finding.CurrentMeasuredCount, finding.PreviousMeasuredCount, finding.Lift); } foreach (var alert in active) { - if (firedKeys.Contains(AppVersionEpisodeKey(alert.AppName, alert.CurrentVersion))) + if (firedKeys.Contains(AppVersionEpisodeKey(alert.Source, alert.AppName, alert.CurrentVersion))) continue; if (AppVersionRegressionRadar.ShouldReArm(tenantSummaries, alert)) { - await _hardwareRejectionTracker.DeleteAppVersionRegressionAsync(tenantId, alert.AppName, alert.CurrentVersion); + await _hardwareRejectionTracker.DeleteAppVersionRegressionAsync(tenantId, alert.Source, alert.AppName, alert.CurrentVersion); rearmed++; } else @@ -149,8 +152,8 @@ namespace AutopilotMonitor.Functions.Services return (fired, refreshed, rearmed); } - private static string AppVersionEpisodeKey(string appName, string version) - => $"{appName}\n{version}"; + private static string AppVersionEpisodeKey(string source, string appName, string version) + => $"{AppInstallSources.Normalize(source)}\n{appName}\n{version}"; private static AppVersionRegressionAlert BuildAppVersionAlert( AppVersionDurationRegressionFinding finding, DateTime firstNotifiedAt) @@ -158,6 +161,7 @@ namespace AutopilotMonitor.Functions.Services { TenantId = finding.TenantId, AppName = finding.AppName, + Source = finding.Source, CurrentVersion = finding.CurrentVersion, PreviousVersion = finding.PreviousVersion, CurrentMedianSeconds = finding.CurrentMedianSeconds, @@ -177,7 +181,8 @@ namespace AutopilotMonitor.Functions.Services private static AppVersionRegressionAlert? TryBuildOngoingAppVersionAlert( System.Collections.Generic.IReadOnlyList tenantSummaries, AppVersionRegressionAlert existing) { - var stats = AppVersionRegressionRadar.ComputeVersionStatsForApp(tenantSummaries, existing.AppName); + var source = AppInstallSources.Normalize(existing.Source); + var stats = AppVersionRegressionRadar.ComputeVersionStatsForApp(tenantSummaries, source, existing.AppName); var current = stats.FirstOrDefault(s => string.Equals(s.Version, existing.CurrentVersion, StringComparison.Ordinal)); var previous = stats.FirstOrDefault(s => string.Equals(s.Version, existing.PreviousVersion, StringComparison.Ordinal)); if (current == null || previous == null || previous.MedianSeconds <= 0) @@ -187,6 +192,7 @@ namespace AutopilotMonitor.Functions.Services { TenantId = existing.TenantId, AppName = existing.AppName, + Source = source, CurrentVersion = existing.CurrentVersion, PreviousVersion = existing.PreviousVersion, CurrentMedianSeconds = current.MedianSeconds, diff --git a/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs b/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs index 164feb16..94768362 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs @@ -846,7 +846,7 @@ namespace AutopilotMonitor.Functions.Services /// (memory: feedback_ops_event_types_dual_register). /// public Task RecordAppVersionDurationRegressionAsync( - string tenantId, string appName, string currentVersion, string previousVersion, + string tenantId, string appName, string source, string currentVersion, string previousVersion, int currentMedianSeconds, int previousMedianSeconds, int currentMeasuredCount, int previousMeasuredCount, double lift) => WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.AppVersionDurationRegression, OpsEventSeverity.Warning, @@ -857,6 +857,7 @@ namespace AutopilotMonitor.Functions.Services new { appName, + source, currentVersion, previousVersion, currentMedianSeconds, diff --git a/src/Backend/AutopilotMonitor.Functions/Services/SlaBreachEvaluationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/SlaBreachEvaluationService.cs index 0118b123..5957ee59 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/SlaBreachEvaluationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/SlaBreachEvaluationService.cs @@ -265,11 +265,13 @@ namespace AutopilotMonitor.Functions.Services var failed = appInstalls.Count(a => a.Status == "Failed"); var rate = SlaEvaluationWindow.AppInstallSuccessRate(appInstalls); var target = (double)config.SlaTargetAppInstallSuccessRate.Value; + // Grouped per (channel, name) like the SLA page; a RealmJoin package is + // named as such in the notification text. var topFailing = appInstalls .Where(a => a.Status == "Failed") - .GroupBy(a => a.AppName) + .GroupBy(a => (Source: AppInstallSources.Normalize(a.Source), a.AppName)) .OrderByDescending(g => g.Count()) - .Select(g => g.Key) + .Select(g => g.Key.Source == AppInstallSources.RealmJoin ? $"{g.Key.AppName} (RealmJoin)" : g.Key.AppName) .FirstOrDefault(); var isBreaching = rate < target; if (isBreaching) breaches++; diff --git a/src/Backend/AutopilotMonitor.Functions/Services/SlaMetricsService.cs b/src/Backend/AutopilotMonitor.Functions/Services/SlaMetricsService.cs index 8e7dc59b..a13b38e6 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/SlaMetricsService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/SlaMetricsService.cs @@ -194,15 +194,18 @@ namespace AutopilotMonitor.Functions.Services var appFailed = currentWeekApps.Count(a => a.Status == "Failed"); var appRate = SlaEvaluationWindow.AppInstallSuccessRate(currentWeekApps); + // Per (channel, name): the rate covers every channel, an app group never mixes two. var topFailing = currentWeekApps .Where(a => a.Status == "Failed") - .GroupBy(a => a.AppName) + .GroupBy(a => (Source: AppInstallSources.Normalize(a.Source), a.AppName)) .Select(g => { - var totalForApp = currentWeekApps.Count(a => a.AppName == g.Key); + var totalForApp = currentWeekApps.Count(a => + a.AppName == g.Key.AppName && AppInstallSources.Normalize(a.Source) == g.Key.Source); return new TopFailingApp { - AppName = g.Key, + AppName = g.Key.AppName, + Source = g.Key.Source, FailCount = g.Count(), TotalCount = totalForApp, SuccessRate = totalForApp > 0 diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Metrics.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Metrics.cs index 331ecf15..a9b1bd02 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Metrics.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Metrics.cs @@ -208,14 +208,14 @@ namespace AutopilotMonitor.Functions.Services /// /// Stores or updates an app install summary. /// Merges with any existing record so StartedAt is never overwritten with a later timestamp. - /// PartitionKey: TenantId, RowKey: {SessionId}_{AppName} + /// PartitionKey: TenantId, RowKey: . /// public async Task StoreAppInstallSummaryAsync(AppInstallSummary summary) { try { var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AppInstallSummaries); - var rowKey = SanitizeTableKey($"{summary.SessionId}_{summary.AppName}"); + var rowKey = BuildAppInstallSummaryRowKey(summary); // Merge with existing record to preserve StartedAt from a prior batch var existingResult = await tableClient.GetEntityIfExistsAsync(summary.TenantId, rowKey); @@ -240,6 +240,20 @@ namespace AutopilotMonitor.Functions.Services } } + /// + /// RowKey of an AppInstallSummaries row; every key starts with "{SessionId}_", which the + /// per-session range scans (ESP stamp, close step, deletion) rely on. IME rows stay + /// name-keyed (no key migration); RealmJoin rows are keyed by scope + package id, the + /// identity the watcher reports — their display name can appear late. + /// + internal static string BuildAppInstallSummaryRowKey(AppInstallSummary summary) + { + var source = AppInstallSources.Normalize(summary.Source); + return SanitizeTableKey(source == AppInstallSources.Ime + ? $"{summary.SessionId}_{summary.AppName}" + : $"{summary.SessionId}_{source}|{summary.InstallScope}|{summary.AppId}"); + } + /// /// Reconciles an incoming per-batch against the stored row /// before the Merge-mode upsert. Extracted as an internal static seam so the cross-batch @@ -376,6 +390,16 @@ namespace AutopilotMonitor.Functions.Services } } + // A RealmJoin row carries its package id as the name until RealmJoin writes + // DisplayName; a later batch without the name must not replace a stored one. + if (AppInstallSources.Normalize(summary.Source) == AppInstallSources.RealmJoin && + summary.AppName == summary.AppId) + { + var existingAppName = existing.GetString("AppName"); + if (!string.IsNullOrEmpty(existingAppName)) + summary.AppName = existingAppName!; + } + // Preserve app metadata fields: AppVersion, AppType, AttemptNumber come from app_install_started // and must not be wiped by a later _completed/_failed batch that doesn't re-emit them. if (string.IsNullOrEmpty(summary.AppVersion)) @@ -484,7 +508,7 @@ namespace AutopilotMonitor.Functions.Services var filter = $"PartitionKey eq '{tenantId}' and RowKey ge '{sessionId}_' and RowKey lt '{sessionId}`'"; var query = tableClient.QueryAsync( filter: filter, - select: new[] { "PartitionKey", "RowKey", "AppId", "EspBlocking", "AppIdCollision" }); + select: new[] { "PartitionKey", "RowKey", "Source", "AppId", "EspBlocking", "AppIdCollision" }); var stamped = 0; await foreach (var row in query) @@ -520,6 +544,8 @@ namespace AutopilotMonitor.Functions.Services /// internal static bool ShouldStampEspBlocking(TableEntity row, EspBlockingSets sets) { + // The ESP lists hold Intune identities only; other channels' ids are never members. + if (AppInstallSources.Normalize(row.GetString("Source")) != AppInstallSources.Ime) return false; var appId = row.GetString("AppId"); if (string.IsNullOrEmpty(appId)) return false; if (row.GetBoolean("AppIdCollision") ?? false) return false; @@ -712,7 +738,7 @@ namespace AutopilotMonitor.Functions.Services /// internal static readonly string[] AppMetricsProjection = { - "PartitionKey", "RowKey", "AppName", "Status", "TerminalState", "StartedAt", + "PartitionKey", "RowKey", "AppName", "Source", "Status", "TerminalState", "StartedAt", "DurationSeconds", "DownloadBytes", "FailureCode", "DoDownloadMode", "DoTotalBytesDownloaded", "DoBytesFromPeers", "DoBytesFromHttp", "DoBytesFromLanPeers", "DoBytesFromGroupPeers", "DoBytesFromInternetPeers", @@ -735,7 +761,7 @@ namespace AutopilotMonitor.Functions.Services /// internal static readonly string[] AppsDashboardProjection = { - "PartitionKey", "RowKey", "TenantId", "SessionId", "AppName", "AppType", "AppVersion", + "PartitionKey", "RowKey", "TenantId", "SessionId", "AppName", "Source", "AppType", "AppVersion", "Status", "TerminalState", "StartedAt", "CompletedAt", "DurationSeconds", "DownloadBytes", "AttemptNumber", "InstallerPhase", "FailureCode", "FailureMessage", "ExitCode", "DetectionResult", "AppId", "EspBlocking", "AppIdCollision", @@ -852,6 +878,8 @@ namespace AutopilotMonitor.Functions.Services AppName = entity.GetString("AppName") ?? string.Empty, SessionId = entity.GetString("SessionId") ?? string.Empty, TenantId = entity.GetString("TenantId") ?? entity.PartitionKey, + Source = entity.GetString("Source") ?? string.Empty, + InstallScope = entity.GetString("InstallScope") ?? string.Empty, Status = entity.GetString("Status") ?? "InProgress", TerminalState = entity.GetString("TerminalState") ?? string.Empty, DurationSeconds = entity.GetInt32("DurationSeconds") ?? 0, @@ -2111,6 +2139,12 @@ namespace AutopilotMonitor.Functions.Services // is persisted (Merge cannot un-set an absent column). if (!string.IsNullOrEmpty(summary.AppId)) entity["AppId"] = summary.AppId; + // Channel columns: absent on IME rows (empty Source reads as IME), so IME entities + // keep their pre-channel shape. + if (!string.IsNullOrEmpty(summary.Source)) + entity["Source"] = summary.Source; + if (!string.IsNullOrEmpty(summary.InstallScope)) + entity["InstallScope"] = summary.InstallScope; if (summary.EspBlocking.HasValue) entity["EspBlocking"] = summary.EspBlocking.Value; if (summary.AppIdCollision) 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 0f2cdc8f..1b67d6d6 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 @@ -450,6 +450,8 @@ export interface AppAnalyticsFailureCode { export interface AppAnalyticsResponse { success: boolean; appName: string; + /** Install channel (AppInstallSources): "ime" or "realmjoin". */ + source: string; appType: string; windowDays: number; collisionExcluded: number; @@ -559,6 +561,8 @@ export interface AppInstallerPhaseCount { /** One app's aggregate across its install rows in the window. */ export interface AppMetricsAppGroup { appName: string; + /** Install channel (AppInstallSources): "ime" or "realmjoin". */ + source: string; totalInstalls: number; succeeded: number; skipped: number; @@ -661,6 +665,8 @@ export interface AppVersionBreakdownItem { export interface AppVersionRegressionAlert { tenantId: string; appName: string; + /** Install channel (AppInstallSources): "ime" or "realmjoin". */ + source: string; /** The regressed (newer) version — episode key together with the app. */ currentVersion: string; /** The comparison version: latest first-seen strictly before the current version's first-seen. */ @@ -681,6 +687,8 @@ export interface AppVersionRegressionAlert { /** One app row of the apps list (failed desc, then failure rate, then name). */ export interface AppsListItem { appName: string; + /** Install channel (AppInstallSources): "ime" or "realmjoin". */ + source: string; appType: string; totalInstalls: number; succeeded: number; @@ -5197,6 +5205,8 @@ export interface TopCve { /** An app with a high failure rate. */ export interface TopFailingApp { appName: string; + /** Install channel (AppInstallSources): "ime" or "realmjoin". */ + source: string; failCount: number; totalCount: number; successRate: number; diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts index 85ca655d..6a981849 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts @@ -2015,7 +2015,9 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo 'before 2026-08 carry the old first-observation-to-last-terminal span), and a fleet "deliveryOptimization" ' + 'rollup — total bytes downloaded and how much came ' + 'from peers / Microsoft Connected Cache (MCC) vs. the CDN, plus a peerOffloadPercent (bandwidth saved by ' + - 'not pulling from the internet). Use this to answer "which app breaks or slows down my enrollments?" and ' + + 'not pulling from the internet). Every app group carries its install channel in "source": "ime" for Intune ' + + 'apps, "realmjoin" for packages the RealmJoin agent installed (their version is the package version). ' + + 'Use this to answer "which app breaks or slows down my enrollments?" and ' + '"how much install bandwidth is served locally?".', inputSchema: { tenantId: z.string().optional().describe(tenantIdDescription(ga, delegated, undefined, 'Ignored — metrics are scoped to your tenant.')), diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IHardwareRejectionNotificationTracker.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IHardwareRejectionNotificationTracker.cs index 318f7ad8..33a90513 100644 --- a/src/Shared/AutopilotMonitor.Shared/DataAccess/IHardwareRejectionNotificationTracker.cs +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IHardwareRejectionNotificationTracker.cs @@ -88,7 +88,7 @@ namespace AutopilotMonitor.Shared.DataAccess Task RefreshAppVersionRegressionAsync(string tenantId, AppVersionRegressionAlert alert); /// Closes an episode (median re-armed or the version drained out of the horizon). 404-tolerant. - Task DeleteAppVersionRegressionAsync(string tenantId, string appName, string currentVersion); + Task DeleteAppVersionRegressionAsync(string tenantId, string source, string appName, string currentVersion); /// Active app-version regression episodes of one tenant (RowKey-prefix scan; empty on failure — fail-soft reads). Task> GetAppVersionRegressionsAsync(string tenantId); diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Metrics/AppInstallSources.cs b/src/Shared/AutopilotMonitor.Shared/Models/Metrics/AppInstallSources.cs new file mode 100644 index 00000000..63a9d72b --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Models/Metrics/AppInstallSources.cs @@ -0,0 +1,28 @@ +using System; + +namespace AutopilotMonitor.Shared.Models +{ + /// + /// Install channels an row can come from. The value is part of + /// the row identity and of every per-app aggregation key, so an Intune app and a RealmJoin + /// package with the same display name never merge into one row or one app group. + /// + public static class AppInstallSources + { + /// Intune Management Extension apps, parsed from the IME logs. + public const string Ime = "ime"; + + /// RealmJoin agent packages, observed in the RealmJoin package registry. + public const string RealmJoin = "realmjoin"; + + public static readonly string[] All = { Ime, RealmJoin }; + + /// + /// The stored column read as a source: empty is (rows written before the + /// column existed were all IME rows). + /// + public static string Normalize(string? source) => string.IsNullOrEmpty(source) ? Ime : source!; + + public static bool IsKnown(string? source) => Array.IndexOf(All, source) >= 0; + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Metrics/AppInstallSummary.cs b/src/Shared/AutopilotMonitor.Shared/Models/Metrics/AppInstallSummary.cs index 471515ba..6b29b1cc 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Metrics/AppInstallSummary.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Metrics/AppInstallSummary.cs @@ -13,14 +13,28 @@ namespace AutopilotMonitor.Shared.Models public string TenantId { get; set; } = string.Empty; /// - /// Intune app identity from the agent's app events (appId payload field) — - /// Win32/IME apps carry the Intune app GUID in lowercase dashed form - /// (AppPackageState.Id). Empty = sentinel: written before this column existed - /// (2026-07 F1 PR1) or the events carried no appId. The row stays name-keyed - /// (RowKey = {SessionId}_{AppName}); this column adds identity without a key migration. + /// Install channel of this row (). Empty = sentinel for rows + /// written before the column existed, all of them IME rows — read it through + /// . Part of the row identity and of every per-app + /// aggregation key. + /// + public string Source { get; set; } = string.Empty; + + /// + /// Channel-local app identity. IME rows: the Intune app GUID from the agent's app events + /// (appId payload field, lowercase dashed, AppPackageState.Id); empty = + /// sentinel (written before 2026-07 F1 PR1, or no appId in the events). IME rows stay + /// name-keyed (RowKey = {SessionId}_{AppName}); this column adds identity without a key + /// migration. RealmJoin rows: the RealmJoin package id, which keys the row together with + /// . /// public string AppId { get; set; } = string.Empty; + /// + /// RealmJoin registration scope of the package: "machine" or "user". Empty for IME rows. + /// + public string InstallScope { get; set; } = string.Empty; + /// /// Whether this app is in the ESP's own blocking/tracking set /// (esp_config_detected lists, joined by at session-terminal diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Metrics/AppMetricsApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/Metrics/AppMetricsApiModels.cs index c36322ea..6500296b 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Metrics/AppMetricsApiModels.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Metrics/AppMetricsApiModels.cs @@ -29,6 +29,8 @@ namespace AutopilotMonitor.Shared.Models public class AppMetricsAppGroup { public string AppName { get; set; } = string.Empty; + /// Install channel (): "ime" or "realmjoin". + public string Source { get; set; } = string.Empty; public int TotalInstalls { get; set; } public int Succeeded { get; set; } public int Skipped { get; set; } diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Metrics/AppVersionRegressionAlert.cs b/src/Shared/AutopilotMonitor.Shared/Models/Metrics/AppVersionRegressionAlert.cs index 9cd30762..a10ba974 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Metrics/AppVersionRegressionAlert.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Metrics/AppVersionRegressionAlert.cs @@ -17,6 +17,8 @@ namespace AutopilotMonitor.Shared.Models { public string TenantId { get; set; } = string.Empty; public string AppName { get; set; } = string.Empty; + /// Install channel (): "ime" or "realmjoin". + public string Source { get; set; } = string.Empty; /// The regressed (newer) version — episode key together with the app. public string CurrentVersion { get; set; } = string.Empty; diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Metrics/AppsDashboardApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/Metrics/AppsDashboardApiModels.cs index 2afa3770..f33845fe 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Metrics/AppsDashboardApiModels.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Metrics/AppsDashboardApiModels.cs @@ -35,6 +35,8 @@ namespace AutopilotMonitor.Shared.Models public class AppsListItem { public string AppName { get; set; } = string.Empty; + /// Install channel (): "ime" or "realmjoin". + public string Source { get; set; } = string.Empty; public string AppType { get; set; } = string.Empty; public int TotalInstalls { get; set; } public int Succeeded { get; set; } @@ -57,6 +59,8 @@ namespace AutopilotMonitor.Shared.Models { public bool Success { get; set; } public string AppName { get; set; } = string.Empty; + /// Install channel (): "ime" or "realmjoin". + public string Source { get; set; } = string.Empty; public string AppType { get; set; } = string.Empty; public int WindowDays { get; set; } public int CollisionExcluded { get; set; } diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Metrics/SlaMetrics.cs b/src/Shared/AutopilotMonitor.Shared/Models/Metrics/SlaMetrics.cs index 81e8ce8b..b02ffcc5 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Metrics/SlaMetrics.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Metrics/SlaMetrics.cs @@ -133,6 +133,8 @@ namespace AutopilotMonitor.Shared.Models.Metrics public class TopFailingApp { public string AppName { get; set; } = default!; + /// Install channel (): "ime" or "realmjoin". + public string Source { get; set; } = string.Empty; public int FailCount { get; set; } public int TotalCount { get; set; } public double SuccessRate { get; set; } diff --git a/src/Web/autopilot-monitor-web/app/apps/components/InstallsTab.tsx b/src/Web/autopilot-monitor-web/app/apps/components/InstallsTab.tsx index 9ef6cabf..521a5564 100644 --- a/src/Web/autopilot-monitor-web/app/apps/components/InstallsTab.tsx +++ b/src/Web/autopilot-monitor-web/app/apps/components/InstallsTab.tsx @@ -11,6 +11,8 @@ import { fetchJson } from "@/lib/apiClient"; import { formatBytes, formatDuration } from "@/lib/formatting"; import DoBreakdownBar from "@/components/DoBreakdownBar"; import { CalculatingInline } from "@/components/CalculatingCard"; +import { InstallSourcePill, installSourceLabel } from "@/components/InstallSourcePill"; +import { SegmentedControl } from "@/components/SegmentedControl"; import { useFetchProgress } from "@/hooks/useFetchProgress"; import type { SoftwareTabScope, TimeRange } from "./types"; import { rangeToDays } from "./types"; @@ -21,6 +23,8 @@ const APPS_FETCH_TIMEOUT_MS = 180_000; interface AppRow { appName: string; + /** Install channel: "ime" (Intune) or "realmjoin". Part of the app identity. */ + source: string; appType: string; totalInstalls: number; succeeded: number; @@ -88,6 +92,7 @@ export default function InstallsTab({ scope, timeRange }: InstallsTabProps) { const [doRollup, setDoRollup] = useState(null); const [loading, setLoading] = useState(true); const [search, setSearch] = useState(""); + const [sourceFilter, setSourceFilter] = useState("all"); const [sortKey, setSortKey] = useState("failureRate"); const [sortDir, setSortDir] = useState("desc"); const [page, setPage] = useState(0); @@ -140,10 +145,21 @@ export default function InstallsTab({ scope, timeRange }: InstallsTabProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [scopeInitialized, timeRange, scopeKey]); + // Channels present in the window; the filter only appears when there is a choice. + const sources = useMemo( + () => [...new Set((data?.apps ?? []).map((a) => a.source))].sort(), + [data], + ); + // A selected channel that left the window falls back to "all" rather than hiding every row + // behind a filter that is no longer shown. + const activeSource = sources.includes(sourceFilter) ? sourceFilter : "all"; + const filteredAndSorted = useMemo(() => { if (!data?.apps) return []; const q = search.trim().toLowerCase(); - const rows = q ? data.apps.filter((a) => a.appName.toLowerCase().includes(q)) : [...data.apps]; + const rows = data.apps.filter((a) => + (activeSource === "all" || a.source === activeSource) && + (!q || a.appName.toLowerCase().includes(q))); const dir = sortDir === "asc" ? 1 : -1; rows.sort((a, b) => { @@ -155,15 +171,15 @@ export default function InstallsTab({ scope, timeRange }: InstallsTabProps) { return ((a[sortKey] as number) - (b[sortKey] as number)) * dir; }); return rows; - }, [data, search, sortKey, sortDir]); + }, [data, search, activeSource, sortKey, sortDir]); // Reset to the first page whenever the filtered/sorted set or the loaded data // changes (adjust-during-render pattern, see react.dev "storing information // from previous renders"). `data` compares by reference, exactly like the // dependency array of the effect this replaces did. - const [prevPageResetKey, setPrevPageResetKey] = useState<[string, SortKey, SortDir, AppsListResponse | null]>([search, sortKey, sortDir, data]); - if (prevPageResetKey[0] !== search || prevPageResetKey[1] !== sortKey || prevPageResetKey[2] !== sortDir || prevPageResetKey[3] !== data) { - setPrevPageResetKey([search, sortKey, sortDir, data]); + const [prevPageResetKey, setPrevPageResetKey] = useState<[string, string, SortKey, SortDir, AppsListResponse | null]>([search, activeSource, sortKey, sortDir, data]); + if (prevPageResetKey[0] !== search || prevPageResetKey[1] !== activeSource || prevPageResetKey[2] !== sortKey || prevPageResetKey[3] !== sortDir || prevPageResetKey[4] !== data) { + setPrevPageResetKey([search, activeSource, sortKey, sortDir, data]); setPage(0); } @@ -219,10 +235,10 @@ export default function InstallsTab({ scope, timeRange }: InstallsTabProps) { return {type}; } - function openApp(appName: string) { + function openApp(row: AppRow) { const days = rangeToDays(timeRange); // Tenant scope is carried in sessionStorage (see useAggregatedAdminScope), so no scope params needed. - router.push(appDetailUrl(appName, { days: String(days) })); + router.push(appDetailUrl(row.appName, { days: String(days), source: row.source })); } return ( @@ -246,8 +262,8 @@ export default function InstallsTab({ scope, timeRange }: InstallsTabProps) { {/* Delivery Optimization rollup */} - {/* Search */} -
+ {/* Search + channel filter */} +
setSearch(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent text-sm" /> + {sources.length > 1 && ( + ({ value: s, label: installSourceLabel(s) }))]} + value={activeSource} + onChange={setSourceFilter} + className="self-start sm:self-auto" + /> + )}
{/* Table */} @@ -292,9 +316,10 @@ export default function InstallsTab({ scope, timeRange }: InstallsTabProps) { {pageRows.map((row) => ( - openApp(row.appName)} className="hover:bg-gray-50 cursor-pointer text-sm"> + openApp(row)} className="hover:bg-gray-50 cursor-pointer text-sm"> {row.appName} + {appTypeBadge(row.appType)} {row.totalInstalls} diff --git a/src/Web/autopilot-monitor-web/app/apps/components/InventoryTab.tsx b/src/Web/autopilot-monitor-web/app/apps/components/InventoryTab.tsx index 6333ac59..8b312d3a 100644 --- a/src/Web/autopilot-monitor-web/app/apps/components/InventoryTab.tsx +++ b/src/Web/autopilot-monitor-web/app/apps/components/InventoryTab.tsx @@ -144,7 +144,9 @@ export default function InventoryTab({ scope }: { scope: SoftwareTabScope }) {
Loading…
) : filtered.length === 0 ? (
- {search ? "No software matches your search." : "No software inventory collected for this tenant yet."} + {search + ? "No software matches your search." + : "No software inventory collected for this tenant yet. It fills once the Software Inventory & Vulnerability Analyzer is on in the agent settings."}
) : ( <> diff --git a/src/Web/autopilot-monitor-web/app/apps/detail/page.tsx b/src/Web/autopilot-monitor-web/app/apps/detail/page.tsx index c2a5af5b..8e17f9d6 100644 --- a/src/Web/autopilot-monitor-web/app/apps/detail/page.tsx +++ b/src/Web/autopilot-monitor-web/app/apps/detail/page.tsx @@ -21,6 +21,8 @@ import { CalculatingInline } from "@/components/CalculatingCard"; import { useFetchProgress } from "@/hooks/useFetchProgress"; import { chartColors } from "../../../components/charts/chartTheme"; import { fetchJson } from "@/lib/apiClient"; +import { parseAppInstallSource } from "@/lib/appInstallSources"; +import { InstallSourcePill } from "@/components/InstallSourcePill"; // A cross-tenant analytics aggregation can take tens of seconds server-side; the default 30s // fetch timeout would abort it client-side while the server keeps computing. @@ -100,6 +102,7 @@ interface DeviceModelRow { interface AnalyticsResponse { success: boolean; appName: string; + source?: string; appType: string; windowDays: number; bucket: "day" | "week"; @@ -208,6 +211,8 @@ function AppDetailContent() { // `?name=` carries the app name verbatim (searchParams already decodes the // percent-encoding applied by appDetailUrl). const appName = searchParams?.get("name") ?? ""; + // `?source=` = install channel; absent (every Intune link) means the Intune channel. + const source = parseAppInstallSource(searchParams?.get("source")); const initialDays = (() => { const d = parseInt(searchParams?.get("days") ?? "30", 10); @@ -323,7 +328,7 @@ function AppDetailContent() { try { setLoading(true); progressBegin(); - const url = scopedApi.appAnalytics(scope, appName, days); + const url = scopedApi.appAnalytics(scope, appName, days, { source }); setAnalytics(await fetchJson(url, getAccessToken, { signal: AbortSignal.timeout(APPS_FETCH_TIMEOUT_MS) })); succeeded = true; } catch (err) { @@ -345,7 +350,7 @@ function AppDetailContent() { if (!isGlobalAdmin && !tenantId) return; try { setSessionsLoading(true); - const url = scopedApi.appSessions(scope, appName, days, status, offset, SESSIONS_PAGE_SIZE); + const url = scopedApi.appSessions(scope, appName, days, status, offset, SESSIONS_PAGE_SIZE, { source }); setSessions(await fetchJson(url, getAccessToken, { signal: AbortSignal.timeout(APPS_FETCH_TIMEOUT_MS) })); } catch (err) { console.error("Failed to fetch sessions", err); @@ -360,6 +365,7 @@ function AppDetailContent() { const t0 = performance.now(); const baseProps = { appName, + source, appType: analytics.appType, windowDays: days, scope: scope.isAggregatedGlobalView ? "aggregated" : "tenant", @@ -440,6 +446,7 @@ function AppDetailContent() {

{appName} + {analytics?.appType && ( {analytics.appType} diff --git a/src/Web/autopilot-monitor-web/app/fleet-health/page.tsx b/src/Web/autopilot-monitor-web/app/fleet-health/page.tsx index 3f95993b..14db7067 100644 --- a/src/Web/autopilot-monitor-web/app/fleet-health/page.tsx +++ b/src/Web/autopilot-monitor-web/app/fleet-health/page.tsx @@ -17,6 +17,7 @@ import FleetStatCard from "./components/FleetStatCard"; import TimeAttributionSection, { TimeAttributionResponseDto } from "./components/TimeAttributionSection"; import DeviceJourneySection, { DeviceJourneyResponseDto } from "./components/DeviceJourneySection"; import TruncatedLabel from "../../components/TruncatedLabel"; +import { InstallSourcePill } from "../../components/InstallSourcePill"; import { useFleetHealth } from "./hooks/useFleetHealth"; import { useAggregatedAdminScope } from "@/hooks"; import { GlobalAdminBanner, globalAdminSubtitle } from "@/components/GlobalAdminBanner"; @@ -31,6 +32,8 @@ import { notifyApiError } from "@/contexts/NotificationContext"; interface AppMetric { appName: string; + /** Install channel: "ime" (Intune) or "realmjoin". */ + source: string; totalInstalls: number; succeeded: number; failed: number; @@ -599,7 +602,7 @@ export default function FleetHealthPage() { ); return (
@@ -607,10 +610,13 @@ export default function FleetHealthPage() {
- +
+ + +
{avgLabel} avg @@ -650,7 +656,7 @@ export default function FleetHealthPage() {
{appMetrics.topFailingApps.map((app, i) => (
@@ -658,10 +664,13 @@ export default function FleetHealthPage() {
- +
+ + +
{app.failed} failed diff --git a/src/Web/autopilot-monitor-web/app/sla/page.tsx b/src/Web/autopilot-monitor-web/app/sla/page.tsx index c83908ce..abd39112 100644 --- a/src/Web/autopilot-monitor-web/app/sla/page.tsx +++ b/src/Web/autopilot-monitor-web/app/sla/page.tsx @@ -6,6 +6,7 @@ import { useTenant } from '../../contexts/TenantContext'; import { useAuth } from '../../contexts/AuthContext'; import { useNotifications } from '../../contexts/NotificationContext'; import TruncatedLabel from '@/components/TruncatedLabel'; +import { InstallSourcePill } from '@/components/InstallSourcePill'; import { ProtectedRoute } from '../../components/ProtectedRoute'; import { api } from "@/lib/api"; import dynamic from "next/dynamic"; @@ -295,11 +296,14 @@ export default function SlaPage() {

Top Failing Apps

{metrics.appInstallSla.topFailingApps.map((app, i) => ( -
+
{i + 1}
- +
+ + +
{app.failCount} failed
diff --git a/src/Web/autopilot-monitor-web/components/InstallProgress.tsx b/src/Web/autopilot-monitor-web/components/InstallProgress.tsx index a6d6eb88..30eda966 100644 --- a/src/Web/autopilot-monitor-web/components/InstallProgress.tsx +++ b/src/Web/autopilot-monitor-web/components/InstallProgress.tsx @@ -3,8 +3,9 @@ import { Fragment, useEffect, useMemo, useState } from "react"; import { getErrorCodeEntry, formatErrorCode, errorCodeTooltip } from "@/utils/errorCodeMap"; import { partitionHistoricReplayEvents } from "@/lib/historicReplay"; -import { applyObservationEnd, buildInstallItems, isRebootOrRetryClass, type InstallEvent, type InstallItem, type InstallSource } from "@/lib/installProgress"; +import { applyObservationEnd, buildInstallItems, isRebootOrRetryClass, type InstallEvent, type InstallItem } from "@/lib/installProgress"; import TruncatedLabel from "@/components/TruncatedLabel"; +import { InstallSourcePill } from "@/components/InstallSourcePill"; import PendingAppRow from "@/components/PendingAppRow"; import PhaseDivider from "@/components/PhaseDivider"; import AssignmentPill from "@/components/AssignmentPill"; @@ -35,24 +36,6 @@ interface InstallProgressProps { // is not inflated by started/progress events of the same app. const APP_FINAL_TYPES: ReadonlySet = new Set(["app_install_completed", "app_install_failed"]); -// Source pills. Intune/IME is the default and deliberately carries no pill — labelling -// every ordinary app row would be noise. Only rows the customer would otherwise mistake -// for a duplicate Intune app get one, with a tooltip explaining what they are looking at. -const SOURCE_PILLS: Partial> = { - "office-c2r": { - label: "Click-to-Run", - title: - "Observed directly from the Office Click-to-Run installer, not from Intune. " + - "If you also deploy Microsoft 365 Apps as your own Win32 app, that row shows Intune's view of " + - "the deployment — this row shows when Office actually finished laying itself down on disk, " + - "which is usually much later.", - }, - realmjoin: { - label: "RealmJoin", - title: "Package installed by the RealmJoin agent, not by Intune's management extension.", - }, -}; - function formatDuration(ms: number): string { const seconds = Math.floor(ms / 1000); if (seconds < 60) return `${seconds}s`; @@ -280,7 +263,6 @@ function InstallItemRow({ item }: { item: InstallItem }) { ? liveElapsed.elapsedMs : null; - const sourcePill = SOURCE_PILLS[item.source]; // Session 080edee9 follow-up — only the genuine "no HRESULT available" timeout // case wears the hedged orange treatment. Detection-failures and install-failures @@ -336,16 +318,7 @@ function InstallItemRow({ item }: { item: InstallItem }) { )} - {sourcePill && ( - // Neutral outline so the source never competes with the coloured state badge - // next to it. Dark mode is covered by the global gray-family overrides. - - {sourcePill.label} - - )} + {item.state === "Skipped" && ( Skipped diff --git a/src/Web/autopilot-monitor-web/components/InstallSourcePill.tsx b/src/Web/autopilot-monitor-web/components/InstallSourcePill.tsx new file mode 100644 index 00000000..57065246 --- /dev/null +++ b/src/Web/autopilot-monitor-web/components/InstallSourcePill.tsx @@ -0,0 +1,41 @@ +import type { InstallSource } from "@/lib/installProgress"; + +// Source pills. Intune/IME is the default and deliberately carries no pill — labelling +// every ordinary app row would be noise. Only rows the customer would otherwise mistake +// for a duplicate Intune app get one, with a tooltip explaining what they are looking at. +const SOURCE_PILLS: Partial> = { + "office-c2r": { + label: "Click-to-Run", + title: + "Observed directly from the Office Click-to-Run installer, not from Intune. " + + "If you also deploy Microsoft 365 Apps as your own Win32 app, that row shows Intune's view of " + + "the deployment — this row shows when Office actually finished laying itself down on disk, " + + "which is usually much later.", + }, + realmjoin: { + label: "RealmJoin", + title: "Package installed by the RealmJoin agent, not by Intune's management extension.", + }, +}; + +/** Label of an install channel for filters and headings; the IME channel reads as Intune. */ +export function installSourceLabel(source: string): string { + return source === "realmjoin" ? "RealmJoin" : source === "office-c2r" ? "Click-to-Run" : "Intune"; +} + +export function InstallSourcePill({ source, className = "" }: { source: string | undefined; className?: string }) { + const pill = source && Object.prototype.hasOwnProperty.call(SOURCE_PILLS, source) + ? SOURCE_PILLS[source as InstallSource] + : undefined; + if (!pill) return null; + return ( + // Neutral outline so the source never competes with the coloured state badge + // next to it. Dark mode is covered by the global gray-family overrides. + + {pill.label} + + ); +} diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/appInstallSources.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/appInstallSources.test.ts new file mode 100644 index 00000000..3936895f --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/__tests__/appInstallSources.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { APP_INSTALL_SOURCES, appInstallSourceParam, parseAppInstallSource } from "@/lib/appInstallSources"; +import { appDetailUrl } from "@/lib/routes"; + +describe("app install sources", () => { + it("comes from the shared manifest", () => { + expect(APP_INSTALL_SOURCES).toEqual(["ime", "realmjoin"]); + }); + + it("parses ?source= and falls back to the Intune channel", () => { + expect(parseAppInstallSource("realmjoin")).toBe("realmjoin"); + expect(parseAppInstallSource(null)).toBe("ime"); + expect(parseAppInstallSource("")).toBe("ime"); + expect(parseAppInstallSource("RealmJoin")).toBe("ime"); + expect(parseAppInstallSource("office-c2r")).toBe("ime"); + }); + + it("leaves the Intune default out of query strings", () => { + expect(appInstallSourceParam("ime")).toBeUndefined(); + expect(appInstallSourceParam(undefined)).toBeUndefined(); + expect(appInstallSourceParam("realmjoin")).toBe("realmjoin"); + }); + + it("app detail links keep the Intune shape and add the channel otherwise", () => { + expect(appDetailUrl("7-Zip", { days: "30", source: "ime" })).toBe("/apps/detail?name=7-Zip&days=30"); + expect(appDetailUrl("7-Zip", { days: "30", source: "realmjoin" })).toBe("/apps/detail?name=7-Zip&days=30&source=realmjoin"); + }); +}); diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/scopedApi.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/scopedApi.test.ts index 774a856d..c8d72a61 100644 --- a/src/Web/autopilot-monitor-web/lib/__tests__/scopedApi.test.ts +++ b/src/Web/autopilot-monitor-web/lib/__tests__/scopedApi.test.ts @@ -92,4 +92,13 @@ describe("scopedApi routing", () => { } expect(g).toContain(`tenantId=${OTHER}`); }); + + it("per-app URLs carry a non-Intune install channel and leave the Intune default out", () => { + for (const sel of [tenantMode, override]) { + expect(scopedApi.appAnalytics(sel, "7-Zip", 30, { source: "realmjoin" })).toContain("source=realmjoin"); + expect(scopedApi.appSessions(sel, "7-Zip", 30, "all", 0, 10, { source: "realmjoin" })).toContain("source=realmjoin"); + expect(scopedApi.appAnalytics(sel, "7-Zip", 30, { source: "ime" })).not.toContain("source="); + expect(scopedApi.appSessions(sel, "7-Zip", 30, "all", 0, 10)).not.toContain("source="); + } + }); }); diff --git a/src/Web/autopilot-monitor-web/lib/api.ts b/src/Web/autopilot-monitor-web/lib/api.ts index 2f9ad5aa..891783ac 100644 --- a/src/Web/autopilot-monitor-web/lib/api.ts +++ b/src/Web/autopilot-monitor-web/lib/api.ts @@ -4,6 +4,7 @@ */ import { API_BASE_URL } from "@/utils/config"; import type { BackupOutcome } from "@/utils/wire-types.generated"; +import { appInstallSourceParam } from "@/lib/appInstallSources"; function qs(params: Record): string { const p = new URLSearchParams(); @@ -357,8 +358,9 @@ export const api = { apps: { list: (tenantId: string, days: number) => `${API_BASE_URL}/api/apps/list${qs({ tenantId, days: String(days) })}`, - analytics: (tenantId: string, appName: string, days: number) => - `${API_BASE_URL}/api/apps/${encodeURIComponent(appName)}/analytics${qs({ tenantId, days: String(days) })}`, + // source = install channel ("realmjoin"); the Intune default is left out of the URL. + analytics: (tenantId: string, appName: string, days: number, opts?: { source?: string }) => + `${API_BASE_URL}/api/apps/${encodeURIComponent(appName)}/analytics${qs({ tenantId, days: String(days), source: appInstallSourceParam(opts?.source) })}`, // model/version live in an options object: as trailing positional strings they sat // adjacent to other optional strings, so a forgotten argument silently shifted the // next one into its place (compiles fine, queries the wrong thing). @@ -369,7 +371,7 @@ export const api = { status: "all" | "failed" | "succeeded" = "all", offset = 0, limit = 50, - opts?: { model?: string; version?: string } + opts?: { model?: string; version?: string; source?: string } ) => `${API_BASE_URL}/api/apps/${encodeURIComponent(appName)}/sessions${qs({ tenantId, @@ -379,6 +381,7 @@ export const api = { limit: String(limit), model: opts?.model, version: opts?.version, + source: appInstallSourceParam(opts?.source), })}`, // Global Admin variants — tenantId is optional: @@ -386,8 +389,8 @@ export const api = { // - provided → returns per-tenant view for any tenant (GA override) globalList: (days: number, tenantId?: string) => `${API_BASE_URL}/api/global/apps/list${qs({ days: String(days), tenantId })}`, - globalAnalytics: (appName: string, days: number, tenantId?: string) => - `${API_BASE_URL}/api/global/apps/${encodeURIComponent(appName)}/analytics${qs({ days: String(days), tenantId })}`, + globalAnalytics: (appName: string, days: number, tenantId?: string, opts?: { source?: string }) => + `${API_BASE_URL}/api/global/apps/${encodeURIComponent(appName)}/analytics${qs({ days: String(days), tenantId, source: appInstallSourceParam(opts?.source) })}`, globalSessions: ( appName: string, days: number, @@ -395,7 +398,7 @@ export const api = { offset = 0, limit = 50, tenantId?: string, - opts?: { model?: string; version?: string } + opts?: { model?: string; version?: string; source?: string } ) => `${API_BASE_URL}/api/global/apps/${encodeURIComponent(appName)}/sessions${qs({ days: String(days), @@ -405,6 +408,7 @@ export const api = { tenantId, model: opts?.model, version: opts?.version, + source: appInstallSourceParam(opts?.source), })}`, }, diff --git a/src/Web/autopilot-monitor-web/lib/appInstallSources.ts b/src/Web/autopilot-monitor-web/lib/appInstallSources.ts new file mode 100644 index 00000000..73090432 --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/appInstallSources.ts @@ -0,0 +1,20 @@ +import { SHARED_MANIFEST } from "@/utils/shared-manifests.generated"; + +/** Install channels of the app rows (backend `AppInstallSources`). */ +export const APP_INSTALL_SOURCES = SHARED_MANIFEST.appInstallSources; +export type AppInstallSource = (typeof APP_INSTALL_SOURCES)[number]; + +/** The Intune channel: the default of every per-app endpoint, left out of URLs. */ +export const DEFAULT_APP_INSTALL_SOURCE: AppInstallSource = "ime"; + +/** Reads a `?source=` value; anything unknown falls back to the Intune channel. */ +export function parseAppInstallSource(raw: string | null | undefined): AppInstallSource { + return (APP_INSTALL_SOURCES as readonly string[]).includes(raw ?? "") + ? (raw as AppInstallSource) + : DEFAULT_APP_INSTALL_SOURCE; +} + +/** The query value for a channel: undefined for the default, so Intune URLs stay unchanged. */ +export function appInstallSourceParam(source: string | undefined): string | undefined { + return source && source !== DEFAULT_APP_INSTALL_SOURCE ? source : undefined; +} diff --git a/src/Web/autopilot-monitor-web/lib/routes.ts b/src/Web/autopilot-monitor-web/lib/routes.ts index 3ace4a31..0088d891 100644 --- a/src/Web/autopilot-monitor-web/lib/routes.ts +++ b/src/Web/autopilot-monitor-web/lib/routes.ts @@ -13,6 +13,7 @@ */ import type { Route } from "next"; +import { appInstallSourceParam } from "@/lib/appInstallSources"; function withQuery( path: Route, @@ -46,14 +47,16 @@ export function diagnosisUrl(sessionId: string): Route { return withQuery("/diagnosis", { id: sessionId }); } +/** `source` is the install channel; the Intune default ("ime") is left out of the URL. */ export function appDetailUrl( appName: string, - opts?: { days?: string; tenantId?: string }, + opts?: { days?: string; tenantId?: string; source?: string }, ): Route { return withQuery("/apps/detail", { name: appName, days: opts?.days, tenantId: opts?.tenantId, + source: appInstallSourceParam(opts?.source), }); } diff --git a/src/Web/autopilot-monitor-web/lib/scopedApi.ts b/src/Web/autopilot-monitor-web/lib/scopedApi.ts index f81c3234..fee09cd9 100644 --- a/src/Web/autopilot-monitor-web/lib/scopedApi.ts +++ b/src/Web/autopilot-monitor-web/lib/scopedApi.ts @@ -59,10 +59,10 @@ export const scopedApi = { appsList: (sel: TenantScopeSelection, days: number) => sel.routeGlobal ? api.apps.globalList(days, globalTenantParam(sel)) : api.apps.list(sel.effectiveTenantId, days), - appAnalytics: (sel: TenantScopeSelection, appName: string, days: number) => + appAnalytics: (sel: TenantScopeSelection, appName: string, days: number, opts?: { source?: string }) => sel.routeGlobal - ? api.apps.globalAnalytics(appName, days, globalTenantParam(sel)) - : api.apps.analytics(sel.effectiveTenantId, appName, days), + ? api.apps.globalAnalytics(appName, days, globalTenantParam(sel), opts) + : api.apps.analytics(sel.effectiveTenantId, appName, days, opts), appSessions: ( sel: TenantScopeSelection, @@ -71,7 +71,7 @@ export const scopedApi = { status: "all" | "failed" | "succeeded", offset: number, limit: number, - opts?: { model?: string; version?: string }, + opts?: { model?: string; version?: string; source?: string }, ) => sel.routeGlobal ? api.apps.globalSessions(appName, days, status, offset, limit, globalTenantParam(sel), opts) diff --git a/src/Web/autopilot-monitor-web/utils/pdf/appReportData.ts b/src/Web/autopilot-monitor-web/utils/pdf/appReportData.ts index 97969787..eb19b547 100644 --- a/src/Web/autopilot-monitor-web/utils/pdf/appReportData.ts +++ b/src/Web/autopilot-monitor-web/utils/pdf/appReportData.ts @@ -18,6 +18,8 @@ export interface ReportTimeSeriesPoint { export interface AppReportAnalytics { appType: string; + /** Install channel; absent on responses from before channels existed (Intune). */ + source?: string; windowDays: number; bucket: "day" | "week"; summary: { @@ -276,6 +278,7 @@ export function prepareReportModel(input: AppReportInput): AppReportModel { appName, appType: analytics.appType, metaLine: + (analytics.source === "realmjoin" ? "RealmJoin package · " : "") + `${analytics.windowDays} day window · ${analytics.bucket === "day" ? "daily" : "weekly"} buckets · ` + `${input.scopeLabel} · Generated ${formatReportDate(generatedAt)}`, fileName: appReportFileName(appName, days, generatedAt), diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts b/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts index 70ee514e..a059495c 100644 --- a/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts +++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts @@ -491,6 +491,10 @@ export const SHARED_MANIFEST = { "different_problem", "inconclusive" ], + "appInstallSources": [ + "ime", + "realmjoin" + ], "ruleSubmissionKinds": [ "gather", "analyze" diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.json b/src/Web/autopilot-monitor-web/utils/shared-manifests.json index 0319cddb..020c4b82 100644 --- a/src/Web/autopilot-monitor-web/utils/shared-manifests.json +++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.json @@ -487,6 +487,10 @@ "different_problem", "inconclusive" ], + "appInstallSources": [ + "ime", + "realmjoin" + ], "ruleSubmissionKinds": [ "gather", "analyze" @@ -2751,6 +2755,14 @@ "name": "string" } }, + { + "name": "source", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "Install channel (AppInstallSources): \"ime\" or \"realmjoin\"." + }, { "name": "appType", "type": { @@ -3257,6 +3269,14 @@ "name": "string" } }, + { + "name": "source", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "Install channel (AppInstallSources): \"ime\" or \"realmjoin\"." + }, { "name": "totalInstalls", "type": { @@ -3782,6 +3802,14 @@ "name": "string" } }, + { + "name": "source", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "Install channel (AppInstallSources): \"ime\" or \"realmjoin\"." + }, { "name": "currentVersion", "type": { @@ -3864,6 +3892,14 @@ "name": "string" } }, + { + "name": "source", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "Install channel (AppInstallSources): \"ime\" or \"realmjoin\"." + }, { "name": "appType", "type": { @@ -23683,6 +23719,14 @@ "name": "string" } }, + { + "name": "source", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "Install channel (AppInstallSources): \"ime\" or \"realmjoin\"." + }, { "name": "failCount", "type": { diff --git a/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts b/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts index d5e41594..ddd81206 100644 --- a/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts +++ b/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts @@ -450,6 +450,8 @@ export interface AppAnalyticsFailureCode { export interface AppAnalyticsResponse { success: boolean; appName: string; + /** Install channel (AppInstallSources): "ime" or "realmjoin". */ + source: string; appType: string; windowDays: number; collisionExcluded: number; @@ -559,6 +561,8 @@ export interface AppInstallerPhaseCount { /** One app's aggregate across its install rows in the window. */ export interface AppMetricsAppGroup { appName: string; + /** Install channel (AppInstallSources): "ime" or "realmjoin". */ + source: string; totalInstalls: number; succeeded: number; skipped: number; @@ -661,6 +665,8 @@ export interface AppVersionBreakdownItem { export interface AppVersionRegressionAlert { tenantId: string; appName: string; + /** Install channel (AppInstallSources): "ime" or "realmjoin". */ + source: string; /** The regressed (newer) version — episode key together with the app. */ currentVersion: string; /** The comparison version: latest first-seen strictly before the current version's first-seen. */ @@ -681,6 +687,8 @@ export interface AppVersionRegressionAlert { /** One app row of the apps list (failed desc, then failure rate, then name). */ export interface AppsListItem { appName: string; + /** Install channel (AppInstallSources): "ime" or "realmjoin". */ + source: string; appType: string; totalInstalls: number; succeeded: number; @@ -5197,6 +5205,8 @@ export interface TopCve { /** An app with a high failure rate. */ export interface TopFailingApp { appName: string; + /** Install channel (AppInstallSources): "ime" or "realmjoin". */ + source: string; failCount: number; totalCount: number; successRate: number;