mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
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:
+2
-2
@@ -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,
|
||||
|
||||
+12
-4
@@ -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);
|
||||
|
||||
+7
-2
@@ -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);
|
||||
|
||||
+6
-1
@@ -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" ||
|
||||
|
||||
+93
@@ -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;
|
||||
}
|
||||
}
|
||||
+17
-11
@@ -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.')),
|
||||
|
||||
+1
-1
@@ -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=");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user