From 1fa30b542deec255e32b8e0437eb8e0420cc631d Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Tue, 3 Mar 2026 00:44:36 +0100 Subject: [PATCH] WhiteGlove session display improvements and per-phase notification durations Frontend: Add per-block durations to WhiteGlove event timeline headers, pause-excluded combined duration in Session Info, visual dashed separator between Pre-Provisioning and User Enrollment blocks, and group labels with vertical separator in the Enrollment Progress tracker. Backend: Store ResumedAt timestamp when WhiteGlove Part 2 starts, use it to compute Duration 2 (user enrollment only) for Teams enrollment notifications. Rename "WhiteGlove Pre-Provisioning" to "Pre-Provisioning" in notification text. Co-Authored-By: Claude Opus 4.6 --- .../Functions/IngestEventsFunction.cs | 13 +- .../Services/TableStorageService.Sessions.cs | 21 ++- .../Services/TeamsNotificationService.cs | 6 +- .../Models/ApiModels.cs | 7 + .../[sessionId]/components/EventTimeline.tsx | 23 ++- .../[sessionId]/components/PhaseTimeline.tsx | 174 +++++++++++------- .../app/sessions/[sessionId]/page.tsx | 43 ++++- 7 files changed, 213 insertions(+), 74 deletions(-) diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs index d961aa20..da259705 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs @@ -330,7 +330,8 @@ namespace AutopilotMonitor.Functions.Functions whiteGloveResumedEvent.Phase, earliestEventTimestamp: earliestEventTimestamp, latestEventTimestamp: latestEventTimestamp, - isUserDriven: true + isUserDriven: true, + resumedAt: whiteGloveResumedEvent.Timestamp ); _logger.LogInformation("{SessionPrefix} Status: InProgress (WhiteGlove Part 2 resumed, IsUserDriven=true)", sessionPrefix); } @@ -436,6 +437,16 @@ namespace AutopilotMonitor.Functions.Functions var duration = updatedSession?.DurationSeconds != null ? TimeSpan.FromSeconds(updatedSession.DurationSeconds.Value) : (TimeSpan?)null; + + // For WhiteGlove sessions: show user enrollment duration only (Duration 2) + // instead of the full wall-clock time which includes the pre-provisioning pause. + if (updatedSession?.IsPreProvisioned == true && updatedSession?.ResumedAt != null) + { + var completionTime = completionEvent?.Timestamp ?? failureEvent?.Timestamp; + if (completionTime.HasValue) + duration = completionTime.Value - updatedSession.ResumedAt.Value; + } + _ = _teamsNotificationService.SendEnrollmentNotificationAsync( tenantConfig.TeamsWebhookUrl, updatedSession?.DeviceName, diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs index 113a7b49..00770c9a 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs @@ -65,6 +65,7 @@ namespace AutopilotMonitor.Functions.Services DateTime? lastEventAt = null; int? durationSeconds = null; string? diagnosticsBlobName = null; + DateTime? resumedAt = null; try { @@ -88,6 +89,7 @@ namespace AutopilotMonitor.Functions.Services lastEventAt = existingEntity.GetDateTimeOffset("LastEventAt")?.UtcDateTime; durationSeconds = existingEntity.GetInt32("DurationSeconds"); diagnosticsBlobName = existingEntity.GetString("DiagnosticsBlobName"); + resumedAt = existingEntity.GetDateTimeOffset("ResumedAt")?.UtcDateTime; // WhiteGlove resumption: if the existing session was in Pending state, // this re-registration means the user has received the device and booted it. @@ -96,6 +98,9 @@ namespace AutopilotMonitor.Functions.Services { _logger.LogInformation($"Session {registration.SessionId} resuming from Pending (WhiteGlove Part 2)"); status = SessionStatus.InProgress.ToString(); + // Store ResumedAt as fallback if whiteglove_resumed event hasn't set it yet + if (!resumedAt.HasValue) + resumedAt = registration.StartedAt; } } catch (RequestFailedException ex) when (ex.Status == 404) @@ -145,6 +150,9 @@ namespace AutopilotMonitor.Functions.Services if (!string.IsNullOrWhiteSpace(diagnosticsBlobName)) entity["DiagnosticsBlobName"] = diagnosticsBlobName; + if (resumedAt.HasValue) + entity["ResumedAt"] = EnsureUtc(resumedAt.Value); + await tableClient.UpsertEntityAsync(entity); _logger.LogInformation($"Stored session {registration.SessionId}"); return true; @@ -401,7 +409,7 @@ namespace AutopilotMonitor.Functions.Services /// Event count is maintained atomically by IncrementSessionEventCountAsync and is not /// recounted here — avoiding an expensive full-partition scan on every status change. /// - public async Task UpdateSessionStatusAsync(string tenantId, string sessionId, SessionStatus status, EnrollmentPhase? currentPhase = null, string? failureReason = null, DateTime? completedAt = null, DateTime? earliestEventTimestamp = null, DateTime? latestEventTimestamp = null, bool? isPreProvisioned = null, bool? isUserDriven = null) + public async Task UpdateSessionStatusAsync(string tenantId, string sessionId, SessionStatus status, EnrollmentPhase? currentPhase = null, string? failureReason = null, DateTime? completedAt = null, DateTime? earliestEventTimestamp = null, DateTime? latestEventTimestamp = null, bool? isPreProvisioned = null, bool? isUserDriven = null, DateTime? resumedAt = null) { SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId)); @@ -499,6 +507,13 @@ namespace AutopilotMonitor.Functions.Services update["IsUserDriven"] = isUserDriven.Value; } + // Store ResumedAt timestamp for WhiteGlove Part 2 (user enrollment start). + // Used to compute Duration 2 (user enrollment only) for Teams notifications. + if (resumedAt.HasValue) + { + update["ResumedAt"] = EnsureUtc(resumedAt.Value); + } + // Merge mode: only the fields set above are written; all other fields remain untouched. // This drastically reduces ETag conflicts when concurrent requests update different fields. await tableClient.UpdateEntityAsync(update, session.ETag, TableUpdateMode.Merge); @@ -574,6 +589,9 @@ namespace AutopilotMonitor.Functions.Services if (isUserDriven.HasValue) forceUpdate["IsUserDriven"] = isUserDriven.Value; + if (resumedAt.HasValue) + forceUpdate["ResumedAt"] = EnsureUtc(resumedAt.Value); + // Unconditional merge write — ETag.All bypasses concurrency check await forceTableClient.UpdateEntityAsync(forceUpdate, ETag.All, TableUpdateMode.Merge); _logger.LogInformation($"Force-updated session {sessionId} status to {status} (unconditional merge after ETag exhaustion)"); @@ -871,6 +889,7 @@ namespace AutopilotMonitor.Functions.Services DiagnosticsBlobName = entity.GetString("DiagnosticsBlobName"), LastEventAt = SafeGetDateTime(entity, "LastEventAt"), IsPreProvisioned = entity.GetBoolean("IsPreProvisioned") ?? false, + ResumedAt = SafeGetDateTime(entity, "ResumedAt"), OsBuild = entity.GetString("OsBuild") ?? string.Empty, OsEdition = entity.GetString("OsEdition") ?? string.Empty, OsLanguage = entity.GetString("OsLanguage") ?? string.Empty, diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TeamsNotificationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TeamsNotificationService.cs index 9ebe73c7..0ac7e7ec 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TeamsNotificationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TeamsNotificationService.cs @@ -151,11 +151,11 @@ namespace AutopilotMonitor.Functions.Services try { - var title = success ? "🔵 WhiteGlove Pre-Provisioning Completed" : "❌ WhiteGlove Pre-Provisioning Failed"; + var title = success ? "🔵 Pre-Provisioning Completed" : "❌ Pre-Provisioning Failed"; var themeColor = success ? "0078D4" : "FF0000"; var summary = success - ? $"WhiteGlove Pre-Provisioning Completed: {deviceName ?? "Unknown Device"}" - : $"WhiteGlove Pre-Provisioning Failed: {deviceName ?? "Unknown Device"}"; + ? $"Pre-Provisioning Completed: {deviceName ?? "Unknown Device"}" + : $"Pre-Provisioning Failed: {deviceName ?? "Unknown Device"}"; var durationText = duration.HasValue ? $"{(int)duration.Value.TotalMinutes}m {duration.Value.Seconds}s" diff --git a/src/Shared/AutopilotMonitor.Shared/Models/ApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/ApiModels.cs index 760d8dbf..27c93be4 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/ApiModels.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/ApiModels.cs @@ -172,6 +172,13 @@ namespace AutopilotMonitor.Shared.Models /// public bool IsPreProvisioned { get; set; } + /// + /// Timestamp when the WhiteGlove session resumed for user enrollment (Part 2). + /// Set when the agent sends a whiteglove_resumed event or re-registers from Pending state. + /// Used to compute the user enrollment duration (Duration 2) for Teams notifications. + /// + public DateTime? ResumedAt { get; set; } + // Device detail fields — stored in the Sessions table but omitted from earlier versions public string OsBuild { get; set; } public string OsEdition { get; set; } diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/EventTimeline.tsx b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/EventTimeline.tsx index 27a8c65b..696ed9b8 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/EventTimeline.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/EventTimeline.tsx @@ -24,6 +24,8 @@ interface EventTimelineProps { userEnrollGrouped: { eventsByPhase: Record; orderedPhases: string[] }; userEnrollEvents: EnrollmentEvent[]; isGalacticAdmin?: boolean; + preProvDuration?: string | null; + userEnrollDuration?: string | null; } export default function EventTimeline({ @@ -46,9 +48,11 @@ export default function EventTimeline({ userEnrollGrouped, userEnrollEvents, isGalacticAdmin, + preProvDuration, + userEnrollDuration, }: EventTimelineProps) { return ( - <> +
{/* Severity filters + Expand/Collapse — shared controls above the timeline(s) */}
Filter: @@ -104,6 +108,9 @@ export default function EventTimeline({

Pre-Provisioning Part

WhiteGlove + {preProvDuration && ( + {preProvDuration} + )}
{preProvGrouped.orderedPhases.length === 0 ? (
No events found.
@@ -123,12 +130,24 @@ export default function EventTimeline({ )}
+ {/* Visual separator between the two WhiteGlove parts */} + {userEnrollEvents.length > 0 && ( +
+
+ Device sealed / powered off +
+
+ )} + {/* User Enrollment Part */} {userEnrollEvents.length > 0 ? (

User Enrollment Part

Resumed + {userEnrollDuration && ( + {userEnrollDuration} + )}
{userEnrollGrouped.orderedPhases.length === 0 ? (
No events found.
@@ -188,7 +207,7 @@ export default function EventTimeline({ )}
)} - +
); } 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 4cb2df1e..4e5e0f23 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 @@ -1,5 +1,6 @@ "use client"; +import React from "react"; import { EnrollmentEvent } from "../page"; import { V1_PHASES, V2_PHASES } from "../utils/phaseConstants"; @@ -9,9 +10,10 @@ interface PhaseTimelineProps { events?: EnrollmentEvent[]; sessionStatus?: string; enrollmentType?: string; + isPreProvisioned?: boolean; } -export default function PhaseTimeline({ currentPhase, completedPhases, events = [], sessionStatus, enrollmentType }: PhaseTimelineProps) { +export default function PhaseTimeline({ currentPhase, completedPhases, events = [], sessionStatus, enrollmentType, isPreProvisioned }: PhaseTimelineProps) { const phases = enrollmentType === "v2" ? V2_PHASES : V1_PHASES; // Derive current activity for the active phase from events @@ -244,74 +246,114 @@ export default function PhaseTimeline({ currentPhase, completedPhases, events = return 'bg-gray-300'; }; - return ( -
-
- {phases.map((phase, index) => { - const status = getPhaseStatus(phase.id); - const prevStatus = index > 0 ? getPhaseStatus(phases[index - 1].id) : null; - const connColor = index > 0 ? getConnectorColor(phases[index - 1].id) : ''; - const showArrow = prevStatus === 'current' || prevStatus === 'failed'; + // WhiteGlove V1: determine where to visually split pre-provisioning from user enrollment. + // Pre-provisioning covers phases 0-3 (Start → Apps Device), user enrollment covers 4-7. + const isWhiteGloveV1 = isPreProvisioned && enrollmentType !== "v2"; + const preProvPhaseIds = new Set([0, 1, 2, 3]); + const splitIndex = isWhiteGloveV1 + ? phases.findIndex(p => !preProvPhaseIds.has(p.id)) + : -1; - return ( -
- {/* Connector line from previous phase center to this phase center */} - {index > 0 && ( -
- )} - {/* Arrow when previous phase is current or failed */} - {index > 0 && showArrow && ( -
-
-
- )} - {/* Circle - centered, on top of connector */} -
- {status === 'completed' ? '✓' : status === 'failed' ? '✕' : phase.id + 1} -
- {/* Labels - centered below circle */} -
-
- {phase.shortName} -
- {(status === 'completed' || status === 'failed') && getPhaseDuration(phase.id) && ( -
- {isSubPhase(phase.id) ? `(${getPhaseDuration(phase.id)})` : getPhaseDuration(phase.id)} -
- )} - {status === 'failed' && ( -
Failed
- )} - {status === 'current' && getCurrentActivity(phase.id) && ( -
-
- {getCurrentActivity(phase.id)} -
-
- )} + const renderPhaseNode = (phase: typeof phases[number], index: number) => { + const status = getPhaseStatus(phase.id); + const prevStatus = index > 0 ? getPhaseStatus(phases[index - 1].id) : null; + const connColor = index > 0 ? getConnectorColor(phases[index - 1].id) : ''; + const showArrow = prevStatus === 'current' || prevStatus === 'failed'; + const isSplitBoundary = isWhiteGloveV1 && index === splitIndex; + + return ( +
+ {/* Connector line — suppress at WhiteGlove split boundary */} + {index > 0 && !isSplitBoundary && ( +
+ )} + {/* Arrow when previous phase is current or failed — suppress at split boundary */} + {index > 0 && showArrow && !isSplitBoundary && ( +
+
+
+ )} + {/* Circle */} +
+ {status === 'completed' ? '✓' : status === 'failed' ? '✕' : phase.id + 1} +
+ {/* Labels */} +
+
+ {phase.shortName} +
+ {(status === 'completed' || status === 'failed') && getPhaseDuration(phase.id) && ( +
+ {isSubPhase(phase.id) ? `(${getPhaseDuration(phase.id)})` : getPhaseDuration(phase.id)} +
+ )} + {status === 'failed' && ( +
Failed
+ )} + {status === 'current' && getCurrentActivity(phase.id) && ( +
+
+ {getCurrentActivity(phase.id)}
+ )} +
+
+ ); + }; + + return ( +
+ {/* Group labels for WhiteGlove sessions */} + {isWhiteGloveV1 && splitIndex > 0 && ( +
+
+ + Pre-Provisioning + +
+
+
+ + User Enrollment + +
+
+ )} +
+ {phases.map((phase, index) => { + const isSplitPoint = isWhiteGloveV1 && index === splitIndex; + return ( + + {/* Vertical dashed separator at the WhiteGlove split point */} + {isSplitPoint && ( +
+
+
+ )} + {renderPhaseNode(phase, index)} + ); })}
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 67025a05..8756d462 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx @@ -650,6 +650,44 @@ export default function SessionDetailPage() { ); }, [filteredEvents, isWhiteGloveSession, whiteGloveSplitSequence]); + // Compute per-block durations for WhiteGlove sessions (using unfiltered events for accuracy). + // Duration 1 = pre-provisioning, Duration 2 = user enrollment, combined = D1 + D2 (pause excluded). + const whiteGloveDurations = useMemo(() => { + if (!isWhiteGloveSession || whiteGloveSplitSequence < 0) { + return { preProvDuration: null as string | null, userEnrollDuration: null as string | null, combinedDuration: null as string | null }; + } + + const preProvEvts = events.filter(e => + e.sequence <= whiteGloveSplitSequence || e.eventType === "whiteglove_complete" + ); + const userEnrollEvts = events.filter(e => + e.sequence > whiteGloveSplitSequence && e.eventType !== "whiteglove_complete" + ); + + const calcMs = (evts: EnrollmentEvent[]): number => { + if (evts.length === 0) return 0; + const ts = evts.map(e => new Date(e.timestamp).getTime()); + return Math.max(...ts) - Math.min(...ts); + }; + + const fmt = (ms: number): string | null => { + const sec = Math.round(ms / 1000); + if (sec < 1) return null; + if (sec < 60) return `${sec}s`; + if (sec < 3600) return `${Math.floor(sec / 60)}m ${sec % 60}s`; + return `${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}m`; + }; + + const preProvMs = calcMs(preProvEvts); + const userEnrollMs = calcMs(userEnrollEvts); + + return { + preProvDuration: fmt(preProvMs), + userEnrollDuration: fmt(userEnrollMs), + combinedDuration: fmt(preProvMs + userEnrollMs), + }; + }, [events, isWhiteGloveSession, whiteGloveSplitSequence]); + // Group events by phase — single timeline for normal sessions, two groups for WhiteGlove const { eventsByPhase, orderedPhases } = useMemo(() => { if (isWhiteGloveSession) return { eventsByPhase: {} as Record, orderedPhases: [] as string[] }; @@ -818,7 +856,7 @@ export default function SessionDetailPage() { {session && ( @@ -837,6 +875,7 @@ export default function SessionDetailPage() { events={events} sessionStatus={session.status} enrollmentType={session.enrollmentType} + isPreProvisioned={isWhiteGloveSession} />
)} @@ -900,6 +939,8 @@ export default function SessionDetailPage() { userEnrollGrouped={userEnrollGrouped} userEnrollEvents={userEnrollEvents} isGalacticAdmin={user?.isGalacticAdmin} + preProvDuration={whiteGloveDurations.preProvDuration} + userEnrollDuration={whiteGloveDurations.userEnrollDuration} />