From dadb897e8e9c6421cc1f3e7a2eed75caeaa6174d Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Tue, 10 Feb 2026 16:14:19 +0100 Subject: [PATCH] fixed app install summaries --- .../Functions/GetAppMetricsFunction.cs | 14 +++- .../Functions/IngestEventsFunction.cs | 66 +++++++++++-------- .../Services/TableStorageService.cs | 26 +++++++- .../AutopilotMonitor.Shared/Constants.cs | 6 +- .../app/fleet-health/page.tsx | 44 +++++++++---- 5 files changed, 110 insertions(+), 46 deletions(-) diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/GetAppMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/GetAppMetricsFunction.cs index d9a6dcf3..4d723f5a 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/GetAppMetricsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/GetAppMetricsFunction.cs @@ -35,7 +35,17 @@ namespace AutopilotMonitor.Functions.Functions var tenantId = TenantHelper.GetTenantId(req); _logger.LogInformation($"Fetching app metrics for tenant {tenantId}"); - var summaries = await _storageService.GetAppInstallSummariesByTenantAsync(tenantId); + // Optional time filter: ?days=7 (default: 30) + var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query); + var daysParam = query["days"]; + int days = 30; + if (!string.IsNullOrEmpty(daysParam) && int.TryParse(daysParam, out var parsedDays) && parsedDays > 0) + days = parsedDays; + + var cutoff = DateTime.UtcNow.AddDays(-days); + + var allSummaries = await _storageService.GetAppInstallSummariesByTenantAsync(tenantId); + var summaries = allSummaries.Where(s => s.StartedAt >= cutoff).ToList(); // Aggregate: slowest apps (avg duration) and top failing apps var appGroups = summaries.GroupBy(s => s.AppName).Select(g => @@ -64,7 +74,7 @@ namespace AutopilotMonitor.Functions.Functions }).ToList(); var slowestApps = appGroups - .Where(a => a.avgDurationSeconds > 0) + .Where(a => a.succeeded > 0) .OrderByDescending(a => a.avgDurationSeconds) .Take(10) .ToList(); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs index 4c710666..170c99bc 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs @@ -406,18 +406,23 @@ namespace AutopilotMonitor.Functions.Functions /// private void AggregateAppInstallEvent(EnrollmentEvent evt, string tenantId, string sessionId, Dictionary summaries) { - string? appName = null; + // Agent sends: app_install_started, app_install_completed, app_install_failed, app_download_started, download_progress + // Support both legacy (app_install_start/complete) and current agent event types + bool isRelevant = + evt.EventType == "app_install_started" || evt.EventType == "app_install_start" || + evt.EventType == "app_install_completed" || evt.EventType == "app_install_complete" || + evt.EventType == "app_install_failed" || + evt.EventType == "app_download_started" || + evt.EventType == "app_install_skipped" || + evt.EventType == "download_progress"; - if (evt.EventType == "app_install_start" || evt.EventType == "app_install_complete" || - evt.EventType == "app_install_failed" || evt.EventType == "download_progress") - { - appName = evt.Data?.ContainsKey("appName") == true ? evt.Data["appName"]?.ToString() : null; - if (string.IsNullOrEmpty(appName)) return; - } - else - { - return; - } + if (!isRelevant) return; + + // Agent sends "appName" (and also "name") in the Data dictionary + var appName = evt.Data?.ContainsKey("appName") == true ? evt.Data["appName"]?.ToString() : null; + if (string.IsNullOrEmpty(appName)) + appName = evt.Data?.ContainsKey("name") == true ? evt.Data["name"]?.ToString() : null; + if (string.IsNullOrEmpty(appName)) return; if (!summaries.TryGetValue(appName, out var summary)) { @@ -433,44 +438,49 @@ namespace AutopilotMonitor.Functions.Functions switch (evt.EventType) { + case "app_install_started": case "app_install_start": - summary.Status = "InProgress"; - summary.StartedAt = evt.Timestamp; + case "app_download_started": + // Only set StartedAt on the very first start event + if (summary.StartedAt == DateTime.MinValue || summary.StartedAt > evt.Timestamp) + summary.StartedAt = evt.Timestamp; + if (summary.Status == "InProgress" || summary.Status == string.Empty) + summary.Status = "InProgress"; break; + case "app_install_completed": case "app_install_complete": summary.Status = "Succeeded"; summary.CompletedAt = evt.Timestamp; if (summary.StartedAt != DateTime.MinValue) - { - summary.DurationSeconds = (int)(evt.Timestamp - summary.StartedAt).TotalSeconds; - } + summary.DurationSeconds = Math.Max(1, (int)(evt.Timestamp - summary.StartedAt).TotalSeconds); break; case "app_install_failed": summary.Status = "Failed"; summary.CompletedAt = evt.Timestamp; if (summary.StartedAt != DateTime.MinValue) - { - summary.DurationSeconds = (int)(evt.Timestamp - summary.StartedAt).TotalSeconds; - } + summary.DurationSeconds = Math.Max(1, (int)(evt.Timestamp - summary.StartedAt).TotalSeconds); + // Agent does not send errorCode/errorMessage in Data — use the event message summary.FailureCode = evt.Data?.ContainsKey("errorCode") == true ? evt.Data["errorCode"]?.ToString() ?? string.Empty : string.Empty; summary.FailureMessage = evt.Data?.ContainsKey("errorMessage") == true ? evt.Data["errorMessage"]?.ToString() ?? string.Empty : evt.Message ?? string.Empty; break; + case "app_install_skipped": + // Mark as succeeded (skipped = already installed / not applicable) with 0 duration + if (summary.Status == "InProgress") + summary.Status = "Succeeded"; + break; + case "download_progress": - if (evt.Data?.ContainsKey("bytes_downloaded") == true && - long.TryParse(evt.Data["bytes_downloaded"]?.ToString(), out var bytes)) - { + // Agent sends "bytesDownloaded" (not "bytes_downloaded") + var bytesKey = evt.Data?.ContainsKey("bytesDownloaded") == true ? "bytesDownloaded" + : evt.Data?.ContainsKey("bytes_downloaded") == true ? "bytes_downloaded" : null; + if (bytesKey != null && long.TryParse(evt.Data![bytesKey]?.ToString(), out var bytes)) summary.DownloadBytes = Math.Max(summary.DownloadBytes, bytes); - } - if (evt.Data?.ContainsKey("download_duration_seconds") == true && - int.TryParse(evt.Data["download_duration_seconds"]?.ToString(), out var dlDuration)) - { - summary.DownloadDurationSeconds = Math.Max(summary.DownloadDurationSeconds, dlDuration); - } + // download_duration_seconds is not sent by the agent — skip break; } } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.cs index ed0d1756..5fb45599 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.cs @@ -1275,7 +1275,8 @@ namespace AutopilotMonitor.Functions.Services // ===== APP INSTALL SUMMARIES METHODS ===== /// - /// Stores or updates an app install 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} /// public async Task StoreAppInstallSummaryAsync(AppInstallSummary summary) @@ -1285,6 +1286,29 @@ namespace AutopilotMonitor.Functions.Services var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AppInstallSummaries); var rowKey = $"{summary.SessionId}_{summary.AppName}"; + // Merge with existing record to preserve StartedAt from a prior batch + try + { + var existing = await tableClient.GetEntityAsync(summary.TenantId, rowKey); + var existingStartedAt = existing.Value.GetDateTimeOffset("StartedAt")?.UtcDateTime; + if (existingStartedAt.HasValue && existingStartedAt.Value != DateTime.MinValue) + { + // Keep the earlier StartedAt; recalculate duration if CompletedAt is now known + if (summary.StartedAt == DateTime.MinValue || existingStartedAt.Value < summary.StartedAt) + { + summary.StartedAt = existingStartedAt.Value; + if (summary.CompletedAt.HasValue && summary.DurationSeconds == 0) + { + summary.DurationSeconds = (int)(summary.CompletedAt.Value - summary.StartedAt).TotalSeconds; + } + } + } + } + catch (Azure.RequestFailedException ex) when (ex.Status == 404) + { + // No existing record – nothing to merge + } + var entity = new TableEntity(summary.TenantId, rowKey) { ["AppName"] = summary.AppName ?? string.Empty, diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs index 401eafa7..21ea915d 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -59,9 +59,11 @@ namespace AutopilotMonitor.Shared public static class EventTypes { public const string PhaseTransition = "phase_transition"; - public const string AppInstallStart = "app_install_start"; - public const string AppInstallComplete = "app_install_complete"; + public const string AppInstallStart = "app_install_started"; + public const string AppInstallComplete = "app_install_completed"; public const string AppInstallFailed = "app_install_failed"; + public const string AppDownloadStarted = "app_download_started"; + public const string AppInstallSkipped = "app_install_skipped"; public const string NetworkStateChange = "network_state_change"; public const string ErrorDetected = "error_detected"; public const string PerformanceSnapshot = "performance_snapshot"; diff --git a/src/Web/autopilot-monitor-web/app/fleet-health/page.tsx b/src/Web/autopilot-monitor-web/app/fleet-health/page.tsx index 01b58be3..e2c33a97 100644 --- a/src/Web/autopilot-monitor-web/app/fleet-health/page.tsx +++ b/src/Web/autopilot-monitor-web/app/fleet-health/page.tsx @@ -52,6 +52,7 @@ export default function FleetHealthPage() { const hasInitialFetch = useRef(false); const hasJoinedGroup = useRef(false); + const isTimeRangeMount = useRef(true); const { on, off, isConnected, joinGroup, leaveGroup } = useSignalR(); const { tenantId } = useTenant(); @@ -68,9 +69,17 @@ export default function FleetHealthPage() { if (hasInitialFetch.current) return; hasInitialFetch.current = true; fetchSessions(); - fetchAppMetrics(); + fetchAppMetrics(timeRange); }, []); + useEffect(() => { + if (isTimeRangeMount.current) { + isTimeRangeMount.current = false; + return; + } + fetchAppMetrics(timeRange); + }, [timeRange]); + useEffect(() => { if (isConnected && !hasJoinedGroup.current) { joinGroup(`tenant-${tenantId}`); @@ -144,12 +153,13 @@ export default function FleetHealthPage() { } }; - const fetchAppMetrics = async () => { + const fetchAppMetrics = async (range: "7d" | "30d" | "90d" = timeRange) => { try { const token = await getAccessToken(); if (!token) return; + const days = range === "7d" ? 7 : range === "30d" ? 30 : 90; const response = await fetch( - `${API_BASE_URL}/api/app-metrics?tenantId=${tenantId}`, + `${API_BASE_URL}/api/app-metrics?tenantId=${tenantId}&days=${days}`, { headers: { Authorization: `Bearer ${token}` } } ); if (response.ok) { @@ -662,10 +672,21 @@ export default function FleetHealthPage() { ) : (
{appMetrics.slowestApps.map((app, i) => { - const avgMinutes = Math.round(app.avgDurationSeconds / 60); - const maxMinutes = Math.round(app.maxDurationSeconds / 60); + const avgSec = app.avgDurationSeconds; + const avgLabel = avgSec >= 60 + ? `${Math.round(avgSec / 60)} min` + : avgSec > 0 + ? `${Math.round(avgSec)}s` + : "< 1s"; + const maxSec = app.maxDurationSeconds; + const maxLabel = maxSec >= 60 + ? `${Math.round(maxSec / 60)}m` + : maxSec > 0 + ? `${Math.round(maxSec)}s` + : "< 1s"; const maxAvg = Math.max( - ...appMetrics.slowestApps.map((a) => a.avgDurationSeconds) + 1, + ...appMetrics!.slowestApps.map((a) => a.avgDurationSeconds) ); return (
- {avgMinutes} min avg + {avgLabel} avg
@@ -689,16 +710,13 @@ export default function FleetHealthPage() {
0 - ? (app.avgDurationSeconds / maxAvg) * 100 - : 0 - }%`, + width: `${(avgSec / maxAvg) * 100}%`, + minWidth: avgSec > 0 ? "2px" : "0", }} />
- max {maxMinutes}m | {app.succeeded} installs + max {maxLabel} | {app.succeeded} installs