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 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-03-02 17:56:56 +01:00
parent 17b3c4cdac
commit 119af503af
7 changed files with 387 additions and 12 deletions
@@ -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) }
};
}
@@ -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()
/// <summary>
/// Emits app_tracking_summary only if state-breakdown counters changed since last emission.
/// Called both event-driven (from HandleAppStateChanged) and periodically (30s timer backstop).
/// </summary>
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
});
}
/// <summary>
/// Emits app_tracking_summary unconditionally (for final summary on completion).
/// </summary>
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();
@@ -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,
@@ -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})`;
@@ -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 && (
<DownloadProgress
events={events.filter(
e => 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 && (
<InstallProgress
events={events.filter(
e => e.eventType === "app_install_started" || e.eventType === "app_install_completed" || e.eventType === "app_install_failed" || e.eventType === "app_install_skipped"
)}
summaryStats={appSummaryStats}
/>
)}
@@ -8,8 +8,19 @@ interface DownloadEvent {
data?: Record<string, any>;
}
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 (
<div className="bg-white shadow rounded-lg p-6 mb-6">
<button
@@ -152,7 +171,11 @@ export default function DownloadProgress({ events }: DownloadProgressProps) {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<h2 className="text-lg font-semibold text-gray-900">Download Progress</h2>
<span className="text-xs text-gray-400">({downloads.length} {downloads.length === 1 ? 'download' : 'downloads'})</span>
{totalDownloadable != null && downloadedCount != null ? (
<span className="text-xs text-gray-400">({downloadedCount} of {totalDownloadable} downloaded)</span>
) : (
<span className="text-xs text-gray-400">({downloads.length} {downloads.length === 1 ? 'download' : 'downloads'})</span>
)}
<div className="flex items-center space-x-2 text-xs">
{activeCount > 0 && (
<span className="px-2 py-0.5 rounded-full bg-blue-100 text-blue-700 font-medium">
@@ -0,0 +1,270 @@
"use client";
import { useMemo, useState } from "react";
interface InstallEvent {
timestamp: string;
eventType?: string;
data?: Record<string, any>;
}
interface SummaryStats {
totalApps?: number;
installing?: number;
installed?: number;
failed?: number;
}
interface InstallProgressProps {
events: InstallEvent[];
summaryStats?: SummaryStats | null;
}
interface InstallItem {
appName: string;
appId: string;
state: "Installing" | "Installed" | "Failed" | "Skipped";
startedAt?: string;
completedAt?: string;
durationMs?: number;
isCompleted: boolean;
isError: boolean;
errorDetail?: string;
errorPatternId?: string;
firstSeenIndex: number;
eventData?: Record<string, any>;
}
function formatDuration(ms: number): string {
const seconds = Math.floor(ms / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
if (minutes < 60) return `${minutes}m ${remainingSeconds}s`;
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return `${hours}h ${remainingMinutes}m`;
}
export default function InstallProgress({ events, summaryStats }: InstallProgressProps) {
const installs = useMemo(() => {
if (events.length === 0) return [];
const sortedEvents = [...events].sort(
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
const installMap = new Map<string, InstallItem>();
let insertionIndex = 0;
for (const evt of sortedEvents) {
const d = evt.data;
if (!d) continue;
const appName = d.appName ?? d.app_name ?? d.appId ?? d.app_id ?? "Unknown App";
if (appName === "Unknown App") continue;
const appId = d.appId ?? d.app_id ?? appName;
const existing = installMap.get(appName);
const eventTs = evt.timestamp;
if (evt.eventType === "app_install_started") {
installMap.set(appName, {
appName,
appId,
state: "Installing",
startedAt: existing?.startedAt ?? eventTs,
isCompleted: false,
isError: false,
firstSeenIndex: existing?.firstSeenIndex ?? insertionIndex++,
eventData: d,
});
} else if (evt.eventType === "app_install_completed") {
const startTime = existing?.startedAt ? new Date(existing.startedAt).getTime() : null;
const endTime = new Date(eventTs).getTime();
const duration = startTime ? endTime - startTime : undefined;
installMap.set(appName, {
appName,
appId,
state: "Installed",
startedAt: existing?.startedAt,
completedAt: eventTs,
durationMs: duration,
isCompleted: true,
isError: false,
firstSeenIndex: existing?.firstSeenIndex ?? insertionIndex++,
eventData: d,
});
} else if (evt.eventType === "app_install_failed") {
const startTime = existing?.startedAt ? new Date(existing.startedAt).getTime() : null;
const endTime = new Date(eventTs).getTime();
const duration = startTime ? endTime - startTime : undefined;
installMap.set(appName, {
appName,
appId,
state: "Failed",
startedAt: existing?.startedAt,
completedAt: eventTs,
durationMs: duration,
isCompleted: true,
isError: true,
errorDetail: d.errorDetail ?? d.error_detail,
errorPatternId: d.errorPatternId ?? d.error_pattern_id,
firstSeenIndex: existing?.firstSeenIndex ?? insertionIndex++,
eventData: d,
});
} else if (evt.eventType === "app_install_skipped") {
installMap.set(appName, {
appName,
appId,
state: "Skipped",
isCompleted: true,
isError: false,
firstSeenIndex: existing?.firstSeenIndex ?? insertionIndex++,
eventData: d,
});
}
}
return Array.from(installMap.values()).sort((a, b) => a.firstSeenIndex - b.firstSeenIndex);
}, [events]);
const [expanded, setExpanded] = useState(true);
if (installs.length === 0) return null;
const activeCount = installs.filter(d => d.state === "Installing").length;
const completedCount = installs.filter(d => d.state === "Installed").length;
const failedCount = installs.filter(d => d.state === "Failed").length;
const skippedCount = installs.filter(d => d.state === "Skipped").length;
// Use summary stats for "X of Y" if available, fall back to local event counts
const totalFromSummary = summaryStats?.totalApps;
const installedFromSummary = summaryStats?.installed;
return (
<div className="bg-white shadow rounded-lg p-6 mb-6">
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center justify-between w-full text-left"
>
<div className="flex items-center space-x-2">
<svg className="w-5 h-5 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
</svg>
<h2 className="text-lg font-semibold text-gray-900">Install Progress</h2>
{totalFromSummary != null && installedFromSummary != null && (
<span className="text-xs text-gray-400">
({installedFromSummary} of {totalFromSummary} installed)
</span>
)}
<div className="flex items-center space-x-2 text-xs">
{activeCount > 0 && (
<span className="px-2 py-0.5 rounded-full bg-indigo-100 text-indigo-700 font-medium">
{activeCount} active
</span>
)}
{completedCount > 0 && (
<span className="px-2 py-0.5 rounded-full bg-green-100 text-green-700 font-medium">
{completedCount} completed
</span>
)}
{failedCount > 0 && (
<span className="px-2 py-0.5 rounded-full bg-red-100 text-red-700 font-medium">
{failedCount} failed
</span>
)}
{skippedCount > 0 && (
<span className="px-2 py-0.5 rounded-full bg-gray-100 text-gray-600 font-medium">
{skippedCount} skipped
</span>
)}
</div>
</div>
<span className="text-gray-400">{expanded ? '▼' : '▶'}</span>
</button>
{expanded && <div className="space-y-3 mt-4">
{installs.map((item) => (
<InstallItemRow key={item.appName} item={item} />
))}
</div>}
</div>
);
}
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 (
<div className={`rounded-lg p-3 ${containerClass}`}>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center space-x-2 min-w-0">
{item.state === "Skipped" ? (
<svg className="w-4 h-4 text-gray-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
</svg>
) : item.isError ? (
<svg className="w-4 h-4 text-red-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
) : item.isCompleted ? (
<svg className="w-4 h-4 text-green-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-4 h-4 text-indigo-500 flex-shrink-0 animate-pulse" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
</svg>
)}
<span className={`text-sm font-medium truncate ${item.state === "Skipped" ? "text-gray-500" : "text-gray-900"}`}>
{item.appName}
</span>
{item.state === "Skipped" && (
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-200 text-gray-600 font-medium">Skipped</span>
)}
{item.state === "Failed" && (
<span className="text-xs px-2 py-0.5 rounded-full bg-red-200 text-red-700 font-medium">Failed</span>
)}
</div>
<div className="flex items-center space-x-3 text-xs text-gray-500 flex-shrink-0 ml-2">
{item.durationMs != null && item.durationMs > 0 && (
<span className="font-medium">{formatDuration(item.durationMs)}</span>
)}
{item.eventData && Object.keys(item.eventData).length > 0 && (
<button
onClick={() => setShowDetails(!showDetails)}
className="text-xs text-blue-600 hover:text-blue-800"
>
{showDetails ? 'Hide' : 'Details'}
</button>
)}
</div>
</div>
{/* Error detail */}
{item.isError && item.errorDetail && (
<div className="mt-1 text-xs text-red-600">
{item.errorDetail}
</div>
)}
{/* Event details (expandable) */}
{showDetails && item.eventData && (
<div className="mt-3 p-2 bg-gray-900 rounded text-xs text-gray-100 font-mono overflow-x-auto">
<pre>{JSON.stringify(item.eventData, null, 2)}</pre>
</div>
)}
</div>
);
}