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) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-09-28 10:19:18 +02:00
parent 64da9f850b
commit a0ad769231
47 changed files with 996 additions and 134 deletions
@@ -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<AppInstallSummary> { 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());
@@ -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<AppInstallSummary>();
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<AppInstallSummary>();
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<AppInstallSummary>(), sessionRepo.Object, App, days: 30,
new List<AppInstallSummary>(), sessionRepo.Object, App, AppInstallSources.Ime, days: 30,
new List<AppVersionRegressionAlert> { alert }));
Assert.Equal(1, emptyRoot.GetProperty("versionRegressions").GetArrayLength());
var root = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
new List<AppInstallSummary> { Install("1.0", 100, Recent) }, sessionRepo.Object, App, days: 30,
new List<AppInstallSummary> { Install("1.0", 100, Recent) }, sessionRepo.Object, App, AppInstallSources.Ime, days: 30,
new List<AppVersionRegressionAlert> { alert }));
var regression = root.GetProperty("versionRegressions")[0];
@@ -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);
}
@@ -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;
/// <summary>
/// RealmJoin packages as <c>AppInstallSummaries</c> rows (<see cref="AppInstallSources.RealmJoin"/>):
/// the ingest fold of <c>realmjoin_package_started/completed</c>, 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).
/// </summary>
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<string, AppInstallAggregationState> Aggregate(params EnrollmentEvent[] events)
{
// Same comparer as the production classification (EventIngestProcessor.Classification).
var summaries = new Dictionary<string, AppInstallAggregationState>(StringComparer.OrdinalIgnoreCase);
foreach (var evt in events)
EventIngestProcessor.AggregateAppInstallEvent(evt, "tenant", SessionId, summaries);
return summaries;
}
private static AppInstallSummary Row(Dictionary<string, AppInstallAggregationState> 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<string, object>
{
["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<string, object> { ["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<TableServiceClient>().Object, NullLogger<TableStorageService>.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<string, object>
{
["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<AppInstallSummary> 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<ISessionRepository>();
sessionRepo.Setup(r => r.GetSessionAsync(It.IsAny<string>(), It.IsAny<string>())).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);
}
}
@@ -88,6 +88,8 @@ public sealed class SharedManifestParityTests
["webhookProviderTypes"] = EnumMap<WebhookProviderType>(),
["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,
@@ -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,
@@ -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.
/// </summary>
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,
@@ -30,6 +30,17 @@ namespace AutopilotMonitor.Functions.Functions.Apps
return Guid.TryParse(raw, out _);
}
/// <summary>
/// Parses the optional <c>?source=</c> install channel of the per-app endpoints. Absent means
/// <see cref="AppInstallSources.Ime"/>, the channel every app link carried before channels
/// existed. Returns <c>false</c> for an unknown value (caller should emit a 400).
/// </summary>
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 ───────────────────────────────────────────────
/// <summary>Upper bound for a single <c>apps/list</c> page when the caller opts into pagination.</summary>
@@ -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<AppInstallSummary> allSummaries,
ISessionRepository sessionRepo,
string appName,
string source,
int days,
IReadOnlyList<AppVersionRegressionAlert>? 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<AppInstallSummary> 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();
@@ -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);
@@ -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);
@@ -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<AppVersionRegressionAlert>()
: (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);
@@ -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);
@@ -8,6 +8,8 @@ public sealed class AppVersionDurationRegressionFinding
{
public string TenantId { get; init; } = string.Empty;
public string AppName { get; init; } = string.Empty;
/// <summary>Install channel (<see cref="AppInstallSources"/>); part of the app identity.</summary>
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
/// <summary>
/// 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 <see cref="AppsAnalyticsHelper.Percentile"/>) 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
/// </summary>
public static bool ShouldReArm(IReadOnlyList<AppInstallSummary> 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;
}
/// <summary>Per-version stats for one app (measured rows only), or empty when the app has none.</summary>
public static List<VersionStats> ComputeVersionStatsForApp(IReadOnlyList<AppInstallSummary> tenantSummaries, string appName)
/// <summary>Per-version stats for one app of one channel (measured rows only), or empty when the app has none.</summary>
public static List<VersionStats> ComputeVersionStatsForApp(IReadOnlyList<AppInstallSummary> 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<VersionStats>();
return MeasuredAppGroups(tenantSummaries).TryGetValue((source, appName), out var rows)
? ComputeVersionStats(rows)
: new List<VersionStats>();
}
/// <summary>
/// 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.
/// </summary>
internal static Dictionary<string, List<AppInstallSummary>> MeasuredAppGroups(IReadOnlyList<AppInstallSummary> summaries)
internal static Dictionary<(string Source, string AppName), List<AppInstallSummary>> MeasuredAppGroups(IReadOnlyList<AppInstallSummary> summaries)
{
var groups = new Dictionary<string, List<AppInstallSummary>>(StringComparer.OrdinalIgnoreCase);
var groups = new Dictionary<(string Source, string AppName), List<AppInstallSummary>>(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<AppInstallSummary>();
groups[summary.AppName] = list;
groups[key] = list;
}
list.Add(summary);
}
return groups;
}
/// <summary>(channel, app) key: channel ordinal, app name case-insensitive like every per-app lookup.</summary>
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));
}
/// <summary>Per-version first/last-seen and nearest-rank median over one app's measured rows.</summary>
internal static List<VersionStats> ComputeVersionStats(IReadOnlyList<AppInstallSummary> measuredAppRows)
{
@@ -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,
@@ -5,7 +5,8 @@ namespace AutopilotMonitor.Functions.Services
/// <summary>
/// Per-app install summary aggregation — folds a batch of <c>app_install_*</c> +
/// <c>download_progress</c> + <c>do_telemetry</c> events into an
/// <see cref="AppInstallSummary"/> keyed by app name.
/// <see cref="AppInstallSummary"/> keyed by app name. RealmJoin package events fold into
/// their own rows (<c>.RealmJoinPackages.cs</c>).
/// </summary>
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<string, AppInstallAggregationState> 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" ||
@@ -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
{
/// <summary>
/// RealmJoin package rows of <c>AppInstallSummaries</c> (<see cref="AppInstallSources.RealmJoin"/>),
/// folded from the watcher's <c>realmjoin_package_started</c> / <c>_completed</c> 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.
/// </summary>
public sealed partial class EventIngestProcessor
{
internal static bool IsRealmJoinPackageEvent(string? eventType) =>
eventType == SharedConstants.EventTypes.RealmJoinPackageStarted ||
eventType == SharedConstants.EventTypes.RealmJoinPackageCompleted;
/// <summary>In-batch key of a RealmJoin row; never collides with an IME row's app-name key.</summary>
internal static string RealmJoinAggregationKey(string scope, string packageId) =>
$"{AppInstallSources.RealmJoin}|{scope}|{packageId}";
internal static void AggregateRealmJoinPackageEvent(EnrollmentEvent evt, string tenantId, string sessionId, Dictionary<string, AppInstallAggregationState> 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<string, object> data, string key) =>
data.TryGetValue(key, out var value) ? value?.ToString()?.Trim() : null;
}
}
@@ -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<string>(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<AppInstallSummary> 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,
@@ -846,7 +846,7 @@ namespace AutopilotMonitor.Functions.Services
/// (memory: feedback_ops_event_types_dual_register).
/// </summary>
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,
@@ -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++;
@@ -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
@@ -208,14 +208,14 @@ namespace AutopilotMonitor.Functions.Services
/// <summary>
/// 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: <see cref="BuildAppInstallSummaryRowKey"/>.
/// </summary>
public async Task<bool> 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<TableEntity>(summary.TenantId, rowKey);
@@ -240,6 +240,20 @@ namespace AutopilotMonitor.Functions.Services
}
}
/// <summary>
/// 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.
/// </summary>
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}");
}
/// <summary>
/// Reconciles an incoming per-batch <see cref="AppInstallSummary"/> 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<TableEntity>(
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
/// </summary>
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
/// </summary>
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
/// </summary>
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)
@@ -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;
@@ -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.')),
@@ -88,7 +88,7 @@ namespace AutopilotMonitor.Shared.DataAccess
Task RefreshAppVersionRegressionAsync(string tenantId, AppVersionRegressionAlert alert);
/// <summary>Closes an episode (median re-armed or the version drained out of the horizon). 404-tolerant.</summary>
Task DeleteAppVersionRegressionAsync(string tenantId, string appName, string currentVersion);
Task DeleteAppVersionRegressionAsync(string tenantId, string source, string appName, string currentVersion);
/// <summary>Active app-version regression episodes of one tenant (RowKey-prefix scan; empty on failure — fail-soft reads).</summary>
Task<List<AppVersionRegressionAlert>> GetAppVersionRegressionsAsync(string tenantId);
@@ -0,0 +1,28 @@
using System;
namespace AutopilotMonitor.Shared.Models
{
/// <summary>
/// Install channels an <see cref="AppInstallSummary"/> 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.
/// </summary>
public static class AppInstallSources
{
/// <summary>Intune Management Extension apps, parsed from the IME logs.</summary>
public const string Ime = "ime";
/// <summary>RealmJoin agent packages, observed in the RealmJoin package registry.</summary>
public const string RealmJoin = "realmjoin";
public static readonly string[] All = { Ime, RealmJoin };
/// <summary>
/// The stored column read as a source: empty is <see cref="Ime"/> (rows written before the
/// column existed were all IME rows).
/// </summary>
public static string Normalize(string? source) => string.IsNullOrEmpty(source) ? Ime : source!;
public static bool IsKnown(string? source) => Array.IndexOf(All, source) >= 0;
}
}
@@ -13,14 +13,28 @@ namespace AutopilotMonitor.Shared.Models
public string TenantId { get; set; } = string.Empty;
/// <summary>
/// Intune app identity from the agent's app events (<c>appId</c> payload field) —
/// Win32/IME apps carry the Intune app GUID in lowercase dashed form
/// (<c>AppPackageState.Id</c>). 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 (<see cref="AppInstallSources"/>). Empty = sentinel for rows
/// written before the column existed, all of them IME rows — read it through
/// <see cref="AppInstallSources.Normalize"/>. Part of the row identity and of every per-app
/// aggregation key.
/// </summary>
public string Source { get; set; } = string.Empty;
/// <summary>
/// Channel-local app identity. IME rows: the Intune app GUID from the agent's app events
/// (<c>appId</c> payload field, lowercase dashed, <c>AppPackageState.Id</c>); 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
/// <see cref="InstallScope"/>.
/// </summary>
public string AppId { get; set; } = string.Empty;
/// <summary>
/// RealmJoin registration scope of the package: "machine" or "user". Empty for IME rows.
/// </summary>
public string InstallScope { get; set; } = string.Empty;
/// <summary>
/// Whether this app is in the ESP's own blocking/tracking set
/// (<c>esp_config_detected</c> lists, joined by <see cref="AppId"/> at session-terminal
@@ -29,6 +29,8 @@ namespace AutopilotMonitor.Shared.Models
public class AppMetricsAppGroup
{
public string AppName { get; set; } = string.Empty;
/// <summary>Install channel (<see cref="AppInstallSources"/>): "ime" or "realmjoin".</summary>
public string Source { get; set; } = string.Empty;
public int TotalInstalls { get; set; }
public int Succeeded { get; set; }
public int Skipped { get; set; }
@@ -17,6 +17,8 @@ namespace AutopilotMonitor.Shared.Models
{
public string TenantId { get; set; } = string.Empty;
public string AppName { get; set; } = string.Empty;
/// <summary>Install channel (<see cref="AppInstallSources"/>): "ime" or "realmjoin".</summary>
public string Source { get; set; } = string.Empty;
/// <summary>The regressed (newer) version — episode key together with the app.</summary>
public string CurrentVersion { get; set; } = string.Empty;
@@ -35,6 +35,8 @@ namespace AutopilotMonitor.Shared.Models
public class AppsListItem
{
public string AppName { get; set; } = string.Empty;
/// <summary>Install channel (<see cref="AppInstallSources"/>): "ime" or "realmjoin".</summary>
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;
/// <summary>Install channel (<see cref="AppInstallSources"/>): "ime" or "realmjoin".</summary>
public string Source { get; set; } = string.Empty;
public string AppType { get; set; } = string.Empty;
public int WindowDays { get; set; }
public int CollisionExcluded { get; set; }
@@ -133,6 +133,8 @@ namespace AutopilotMonitor.Shared.Models.Metrics
public class TopFailingApp
{
public string AppName { get; set; } = default!;
/// <summary>Install channel (<see cref="AppInstallSources"/>): "ime" or "realmjoin".</summary>
public string Source { get; set; } = string.Empty;
public int FailCount { get; set; }
public int TotalCount { get; set; }
public double SuccessRate { get; set; }
@@ -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<DeliveryOptimizationRollup | null>(null);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [sourceFilter, setSourceFilter] = useState("all");
const [sortKey, setSortKey] = useState<SortKey>("failureRate");
const [sortDir, setSortDir] = useState<SortDir>("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<AppRow[]>(() => {
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 <span className={`ml-2 inline-block px-1.5 py-0.5 rounded text-xs ${color}`}>{type}</span>;
}
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 */}
<DoCard rollup={doRollup} days={rangeToDays(timeRange)} />
{/* Search */}
<div className="bg-white rounded-lg shadow mb-4 p-4">
{/* Search + channel filter */}
<div className="bg-white rounded-lg shadow mb-4 p-4 flex flex-col sm:flex-row gap-3 sm:items-center">
<input
type="text"
placeholder="Search apps by name…"
@@ -255,6 +271,14 @@ export default function InstallsTab({ scope, timeRange }: InstallsTabProps) {
onChange={(e) => 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 && (
<SegmentedControl
options={[{ value: "all", label: "All" }, ...sources.map((s) => ({ value: s, label: installSourceLabel(s) }))]}
value={activeSource}
onChange={setSourceFilter}
className="self-start sm:self-auto"
/>
)}
</div>
{/* Table */}
@@ -292,9 +316,10 @@ export default function InstallsTab({ scope, timeRange }: InstallsTabProps) {
</thead>
<tbody className="divide-y divide-gray-200">
{pageRows.map((row) => (
<tr key={row.appName} onClick={() => openApp(row.appName)} className="hover:bg-gray-50 cursor-pointer text-sm">
<tr key={`${row.source}|${row.appName}`} onClick={() => openApp(row)} className="hover:bg-gray-50 cursor-pointer text-sm">
<td className="px-4 py-3 text-gray-900">
<span className="font-medium">{row.appName}</span>
<InstallSourcePill source={row.source} className="ml-2" />
{appTypeBadge(row.appType)}
</td>
<td className="px-4 py-3 text-right text-gray-700">{row.totalInstalls}</td>
@@ -144,7 +144,9 @@ export default function InventoryTab({ scope }: { scope: SoftwareTabScope }) {
<div className="p-8 text-center text-gray-500">Loading…</div>
) : filtered.length === 0 ? (
<div className="p-8 text-center text-gray-500">
{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."}
</div>
) : (
<>
@@ -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<AnalyticsResponse>(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<SessionsResponse>(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() {
<div className="min-w-0">
<h1 className="text-2xl font-normal text-gray-900 flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="break-words">{appName}</span>
<InstallSourcePill source={source} />
{analytics?.appType && (
<span className="px-2 py-0.5 text-xs rounded bg-blue-100 text-blue-800">
{analytics.appType}
@@ -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 (
<div
key={app.appName}
key={`${app.source}|${app.appName}`}
className="flex items-center space-x-3"
>
<span className="text-xs text-gray-400 w-4">
@@ -607,10 +610,13 @@ export default function FleetHealthPage() {
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<TruncatedLabel
text={app.appName}
className="text-sm text-gray-700 pr-2"
/>
<div className="flex items-center gap-2 min-w-0">
<TruncatedLabel
text={app.appName}
className="text-sm text-gray-700 pr-2"
/>
<InstallSourcePill source={app.source} />
</div>
<span className="text-sm font-medium text-gray-900 flex-shrink-0">
{avgLabel} avg
</span>
@@ -650,7 +656,7 @@ export default function FleetHealthPage() {
<div className="space-y-3">
{appMetrics.topFailingApps.map((app, i) => (
<div
key={app.appName}
key={`${app.source}|${app.appName}`}
className="flex items-center space-x-3"
>
<span className="text-xs text-gray-400 w-4">
@@ -658,10 +664,13 @@ export default function FleetHealthPage() {
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<TruncatedLabel
text={app.appName}
className="text-sm text-gray-700 pr-2"
/>
<div className="flex items-center gap-2 min-w-0">
<TruncatedLabel
text={app.appName}
className="text-sm text-gray-700 pr-2"
/>
<InstallSourcePill source={app.source} />
</div>
<span className="text-sm font-medium text-red-600 flex-shrink-0">
{app.failed} failed
</span>
@@ -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() {
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Top Failing Apps</h2>
<div className="space-y-3">
{metrics.appInstallSla.topFailingApps.map((app, i) => (
<div key={app.appName} className="flex items-center space-x-3">
<div key={`${app.source}|${app.appName}`} className="flex items-center space-x-3">
<span className="text-xs text-gray-400 w-4">{i + 1}</span>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<TruncatedLabel text={app.appName} className="text-sm text-gray-700 dark:text-gray-300 pr-2" />
<div className="flex items-center gap-2 min-w-0">
<TruncatedLabel text={app.appName} className="text-sm text-gray-700 dark:text-gray-300 pr-2" />
<InstallSourcePill source={app.source} />
</div>
<span className="text-sm font-medium text-red-600 dark:text-red-400 flex-shrink-0">{app.failCount} failed</span>
</div>
<div className="w-full h-2 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
@@ -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<string> = 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<Record<InstallSource, { label: string; title: string }>> = {
"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 }) {
</svg>
)}
<TruncatedLabel text={item.appName} className={`text-sm font-medium ${item.state === "Skipped" ? "text-gray-500" : "text-gray-900"}`} />
{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.
<span
className="text-xs px-2 py-0.5 rounded-full bg-gray-100 border border-gray-300 text-gray-600 font-medium whitespace-nowrap"
title={sourcePill.title}
>
{sourcePill.label}
</span>
)}
<InstallSourcePill source={item.source} />
<AssignmentPill targeted={item.targeted} />
{item.state === "Skipped" && (
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-200 text-gray-600 font-medium">Skipped</span>
@@ -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<Record<InstallSource, { label: string; title: string }>> = {
"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.
<span
className={`text-xs px-2 py-0.5 rounded-full bg-gray-100 border border-gray-300 text-gray-600 font-medium whitespace-nowrap${className ? ` ${className}` : ""}`}
title={pill.title}
>
{pill.label}
</span>
);
}
@@ -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");
});
});
@@ -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=");
}
});
});
+10 -6
View File
@@ -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, string | undefined>): 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),
})}`,
},
@@ -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;
}
+4 -1
View File
@@ -13,6 +13,7 @@
*/
import type { Route } from "next";
import { appInstallSourceParam } from "@/lib/appInstallSources";
function withQuery<T extends string>(
path: Route<T>,
@@ -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),
});
}
@@ -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)
@@ -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),
@@ -491,6 +491,10 @@ export const SHARED_MANIFEST = {
"different_problem",
"inconclusive"
],
"appInstallSources": [
"ime",
"realmjoin"
],
"ruleSubmissionKinds": [
"gather",
"analyze"
@@ -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": {
@@ -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;