From 119af503afb148c23b8024809a121f2681cc6554 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Mon, 2 Mar 2026 17:56:56 +0100 Subject: [PATCH] replace app_tracking_summary apps array with state-breakdown counters and add InstallProgress tracker Fixes Azure Table Storage 64KB truncation by removing the full apps array from app_tracking_summary (could be 150+ apps) and replacing it with lightweight state-breakdown counters (downloading, installing, installed, skipped, failed, pending). Summary emission is now event-driven with 30s timer as backstop. New InstallProgress UI component shows per-app install state with duration tracking. ConfigVersion 10 -> 11. Co-Authored-By: Claude Opus 4.6 --- .../Tracking/AppPackageStateList.cs | 9 +- .../Monitoring/Tracking/EnrollmentTracker.cs | 54 +++- .../Functions/GetAgentConfigFunction.cs | 2 +- .../[sessionId]/components/PhaseTimeline.tsx | 2 +- .../app/sessions/[sessionId]/page.tsx | 35 ++- .../components/DownloadProgress.tsx | 27 +- .../components/InstallProgress.tsx | 270 ++++++++++++++++++ 7 files changed, 387 insertions(+), 12 deletions(-) create mode 100644 src/Web/autopilot-monitor-web/components/InstallProgress.tsx diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/AppPackageStateList.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/AppPackageStateList.cs index a679f6ca..5a8c0815 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/AppPackageStateList.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/AppPackageStateList.cs @@ -287,7 +287,14 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking { "hasErrors", HasError }, { "isAllCompleted", IsAllCompleted() }, { "ignoredCount", IgnoreList.Count }, - { "apps", this.Select(x => x.ToEventData()).ToList() } + // State-breakdown for UI progress displays (replaces the full "apps" array + // which could exceed Azure Table Storage's 64KB property limit with 150+ apps) + { "downloading", this.Count(x => x.InstallationState == AppInstallationState.Downloading) }, + { "installing", this.Count(x => x.InstallationState == AppInstallationState.Installing) }, + { "installed", this.Count(x => x.InstallationState == AppInstallationState.Installed) }, + { "skipped", this.Count(x => x.InstallationState == AppInstallationState.Skipped) }, + { "failed", this.Count(x => x.InstallationState == AppInstallationState.Error || x.InstallationState == AppInstallationState.Postponed) }, + { "pending", this.Count(x => x.InstallationState < AppInstallationState.Downloading) } }; } diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.cs index 9413a2a4..48641baf 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Threading; using System.Threading.Tasks; using AutopilotMonitor.Agent.Core.Logging; @@ -32,6 +33,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking private string _enrollmentType = "v1"; // "v1" = Autopilot Classic/ESP, "v2" = Windows Device Preparation private bool _isWaitingForHello = false; // Track if we're waiting for Hello to complete before sending enrollment_complete private bool _finalDeviceInfoCollected = false; // Ensure final device info is emitted only once + private string _lastEmittedSummaryHash; // Track last emitted state-breakdown to avoid redundant summary events // Default IME log folder private const string DefaultImeLogFolder = @"%ProgramData%\Microsoft\IntuneManagementExtension\Logs"; @@ -399,6 +401,9 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking Message = message, Data = app.ToEventData() }); + + // Emit summary immediately if state-breakdown counters changed (instant UI updates) + EmitAppTrackingSummaryIfChanged(); } private void HandlePoliciesDiscovered(string policiesJson) @@ -652,17 +657,24 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking private void SummaryTimerCallback(object state) { - if (_imeLogTracker?.PackageStates?.CountAll > 0) - { - EmitAppTrackingSummary(); - } + // Only emit if state-breakdown counters changed since last emission (backstop for missed events) + EmitAppTrackingSummaryIfChanged(); } - private void EmitAppTrackingSummary() + /// + /// Emits app_tracking_summary only if state-breakdown counters changed since last emission. + /// Called both event-driven (from HandleAppStateChanged) and periodically (30s timer backstop). + /// + private void EmitAppTrackingSummaryIfChanged() { var states = _imeLogTracker?.PackageStates; if (states == null || states.CountAll == 0) return; + var hash = GetSummaryHash(states); + if (hash == _lastEmittedSummaryHash) return; + + _lastEmittedSummaryHash = hash; + _emitEvent(new EnrollmentEvent { SessionId = _sessionId, @@ -677,6 +689,38 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking }); } + /// + /// Emits app_tracking_summary unconditionally (for final summary on completion). + /// + private void EmitAppTrackingSummary() + { + var states = _imeLogTracker?.PackageStates; + if (states == null || states.CountAll == 0) return; + + _lastEmittedSummaryHash = GetSummaryHash(states); + + _emitEvent(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = "app_tracking_summary", + Severity = states.HasError ? EventSeverity.Warning : EventSeverity.Info, + Source = "EnrollmentTracker", + Phase = EnrollmentPhase.Unknown, + Message = $"App tracking: {states.CountCompleted}/{states.CountAll} completed" + + (states.HasError ? $" ({states.ErrorCount} errors)" : ""), + Data = states.GetSummaryData() + }); + } + + private static string GetSummaryHash(AppPackageStateList states) + { + return $"{states.CountAll}_{states.CountCompleted}_{states.ErrorCount}_" + + $"{states.Count(x => x.InstallationState == AppInstallationState.Downloading)}_" + + $"{states.Count(x => x.InstallationState == AppInstallationState.Installing)}_" + + $"{states.Count(x => x.InstallationState == AppInstallationState.Installed)}"; + } + public void Dispose() { Stop(); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/GetAgentConfigFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/GetAgentConfigFunction.cs index 770e612c..3e7793bc 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/GetAgentConfigFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/GetAgentConfigFunction.cs @@ -107,7 +107,7 @@ namespace AutopilotMonitor.Functions.Functions var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new AgentConfigResponse { - ConfigVersion = 10, // agent-side analyzers (LocalAdminAnalyzer) with AnalyzerConfiguration + ConfigVersion = 11, // app_tracking_summary: replaced apps array with state-breakdown counters + event-driven emission UploadIntervalSeconds = 30, SelfDestructOnComplete = tenantConfig.SelfDestructOnComplete ?? true, KeepLogFile = tenantConfig.KeepLogFile ?? false, diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/PhaseTimeline.tsx b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/PhaseTimeline.tsx index 58cb8b34..4cb2df1e 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/PhaseTimeline.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/PhaseTimeline.tsx @@ -37,7 +37,7 @@ export default function PhaseTimeline({ currentPhase, completedPhases, events = const trackingSummary = phaseEvents.find(e => e.eventType === "app_tracking_summary"); if (trackingSummary?.data) { const d = trackingSummary.data; - const completed = parseInt(d.appsCompleted ?? "0", 10); + const completed = parseInt(d.completedApps ?? d.appsCompleted ?? "0", 10); const total = parseInt(d.totalApps ?? "0", 10); if (total > 0) { return `Installing apps (${completed}/${total})`; diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx index 7c11df8b..67025a05 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx @@ -9,6 +9,7 @@ import { useNotifications } from "../../../contexts/NotificationContext"; import { ProtectedRoute } from '../../../components/ProtectedRoute'; import PerformanceChart from '../../../components/PerformanceChart'; import DownloadProgress from '../../../components/DownloadProgress'; +import InstallProgress from '../../../components/InstallProgress'; import { API_BASE_URL } from "@/lib/config"; import { V1_PHASE_NAMES, V2_PHASE_NAMES, V1_PHASE_ORDER, V2_PHASE_ORDER } from "./utils/phaseConstants"; @@ -555,6 +556,25 @@ export default function SessionDetailPage() { [events, severityFilters] ); + // Extract latest app_tracking_summary state-breakdown for progress headers + const appSummaryStats = useMemo(() => { + const summaryEvents = events.filter(e => e.eventType === "app_tracking_summary"); + if (summaryEvents.length === 0) return null; + const latest = summaryEvents[summaryEvents.length - 1]; + const d = latest.data; + if (!d) return null; + return { + totalApps: parseInt(d.totalApps ?? "0", 10), + completedApps: parseInt(d.completedApps ?? "0", 10), + downloading: parseInt(d.downloading ?? "0", 10), + installing: parseInt(d.installing ?? "0", 10), + installed: parseInt(d.installed ?? "0", 10), + skipped: parseInt(d.skipped ?? "0", 10), + failed: parseInt(d.failed ?? "0", 10), + pending: parseInt(d.pending ?? "0", 10), + }; + }, [events]); + const isGatherRulesSession = session?.enrollmentType === "gather_rules"; // For gather_rules sessions: if the completed event is present, derive status as Succeeded // (the backend never sets this status automatically for one-shot gather runs). @@ -839,12 +859,23 @@ export default function SessionDetailPage() { /> )} - {/* Download Progress (from download_progress, app_download_started, app_install_skipped, or app_tracking_summary events) */} + {/* Download Progress (from download_progress, app_download_started, app_install_skipped events) */} {!isGatherRulesSession && ( e.eventType === "download_progress" || e.eventType === "app_download_started" || e.eventType === "app_install_skipped" || e.eventType === "app_tracking_summary" + e => e.eventType === "download_progress" || e.eventType === "app_download_started" || e.eventType === "app_install_skipped" )} + summaryStats={appSummaryStats} + /> + )} + + {/* Install Progress (from app_install_started, app_install_completed, app_install_failed, app_install_skipped events) */} + {!isGatherRulesSession && ( + e.eventType === "app_install_started" || e.eventType === "app_install_completed" || e.eventType === "app_install_failed" || e.eventType === "app_install_skipped" + )} + summaryStats={appSummaryStats} /> )} diff --git a/src/Web/autopilot-monitor-web/components/DownloadProgress.tsx b/src/Web/autopilot-monitor-web/components/DownloadProgress.tsx index 280e531a..b8eeb0f7 100644 --- a/src/Web/autopilot-monitor-web/components/DownloadProgress.tsx +++ b/src/Web/autopilot-monitor-web/components/DownloadProgress.tsx @@ -8,8 +8,19 @@ interface DownloadEvent { data?: Record; } +interface SummaryStats { + totalApps?: number; + downloading?: number; + installed?: number; + installing?: number; + skipped?: number; + failed?: number; + pending?: number; +} + interface DownloadProgressProps { events: DownloadEvent[]; + summaryStats?: SummaryStats | null; } interface DownloadItem { @@ -41,7 +52,7 @@ function formatSpeed(bps: number): string { return `${(bps / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`; } -export default function DownloadProgress({ events }: DownloadProgressProps) { +export default function DownloadProgress({ events, summaryStats }: DownloadProgressProps) { const downloads = useMemo(() => { if (events.length === 0) return []; @@ -141,6 +152,14 @@ export default function DownloadProgress({ events }: DownloadProgressProps) { const completedCount = downloads.filter(d => d.isComplete && !d.isSkipped).length; const skippedCount = downloads.filter(d => d.isSkipped).length; + // "X of Y downloaded" from summary stats: apps past download phase / total apps that actually download + const totalDownloadable = summaryStats?.totalApps != null && summaryStats?.skipped != null && summaryStats?.pending != null + ? summaryStats.totalApps - summaryStats.skipped + : null; + const downloadedCount = summaryStats?.installed != null && summaryStats?.installing != null && summaryStats?.failed != null + ? summaryStats.installed + summaryStats.installing + summaryStats.failed + : null; + return (
+ + {expanded &&
+ {installs.map((item) => ( + + ))} +
} +
+ ); +} + +function InstallItemRow({ item }: { item: InstallItem }) { + const [showDetails, setShowDetails] = useState(false); + + const containerClass = item.state === "Skipped" + ? "bg-gray-50 border border-gray-300" + : item.isError + ? "bg-red-50 border border-red-200" + : item.isCompleted + ? "bg-green-50 border border-green-200" + : "bg-gray-50 border border-gray-200"; + + return ( +
+
+
+ {item.state === "Skipped" ? ( + + + + ) : item.isError ? ( + + + + ) : item.isCompleted ? ( + + + + ) : ( + + + + )} + + {item.appName} + + {item.state === "Skipped" && ( + Skipped + )} + {item.state === "Failed" && ( + Failed + )} +
+
+ {item.durationMs != null && item.durationMs > 0 && ( + {formatDuration(item.durationMs)} + )} + {item.eventData && Object.keys(item.eventData).length > 0 && ( + + )} +
+
+ + {/* Error detail */} + {item.isError && item.errorDetail && ( +
+ {item.errorDetail} +
+ )} + + {/* Event details (expandable) */} + {showDetails && item.eventData && ( +
+
{JSON.stringify(item.eventData, null, 2)}
+
+ )} +
+ ); +}