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 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-03-03 00:44:36 +01:00
parent 2c09ac1b36
commit 1fa30b542d
7 changed files with 213 additions and 74 deletions
@@ -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,
@@ -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.
/// </summary>
public async Task<bool> 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<bool> 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,
@@ -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"
@@ -172,6 +172,13 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public bool IsPreProvisioned { get; set; }
/// <summary>
/// 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.
/// </summary>
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; }
@@ -24,6 +24,8 @@ interface EventTimelineProps {
userEnrollGrouped: { eventsByPhase: Record<string, EnrollmentEvent[]>; 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 (
<>
<div className="space-y-6">
{/* Severity filters + Expand/Collapse — shared controls above the timeline(s) */}
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-medium text-gray-500">Filter:</span>
@@ -104,6 +108,9 @@ export default function EventTimeline({
<div className="flex items-center gap-3 mb-6">
<h2 className="text-xl font-semibold text-gray-900">Pre-Provisioning Part</h2>
<span className="px-2 py-0.5 text-xs font-semibold rounded-full bg-amber-100 text-amber-800">WhiteGlove</span>
{preProvDuration && (
<span className="text-sm text-gray-500">{preProvDuration}</span>
)}
</div>
{preProvGrouped.orderedPhases.length === 0 ? (
<div className="text-gray-500 text-center py-8">No events found.</div>
@@ -123,12 +130,24 @@ export default function EventTimeline({
)}
</div>
{/* Visual separator between the two WhiteGlove parts */}
{userEnrollEvents.length > 0 && (
<div className="flex items-center gap-4 px-4">
<div className="flex-1 border-t-2 border-dashed border-gray-300"></div>
<span className="text-xs text-gray-400 font-medium whitespace-nowrap">Device sealed / powered off</span>
<div className="flex-1 border-t-2 border-dashed border-gray-300"></div>
</div>
)}
{/* User Enrollment Part */}
{userEnrollEvents.length > 0 ? (
<div className="bg-white shadow rounded-lg p-6">
<div className="flex items-center gap-3 mb-6">
<h2 className="text-xl font-semibold text-gray-900">User Enrollment Part</h2>
<span className="px-2 py-0.5 text-xs font-semibold rounded-full bg-blue-100 text-blue-800">Resumed</span>
{userEnrollDuration && (
<span className="text-sm text-gray-500">{userEnrollDuration}</span>
)}
</div>
{userEnrollGrouped.orderedPhases.length === 0 ? (
<div className="text-gray-500 text-center py-8">No events found.</div>
@@ -188,7 +207,7 @@ export default function EventTimeline({
)}
</div>
)}
</>
</div>
);
}
@@ -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 (
<div className="w-full py-4">
<div className="flex w-full">
{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 (
<div key={phase.id} className="flex-1 relative flex flex-col items-center min-w-0">
{/* Connector line from previous phase center to this phase center */}
{index > 0 && (
<div
className={`absolute h-1 ${connColor}`}
style={{ top: '22px', left: '-50%', right: '50%' }}
/>
)}
{/* Arrow when previous phase is current or failed */}
{index > 0 && showArrow && (
<div
className="absolute z-20"
style={{ top: '16px', left: 'calc(50% - 36px)' }}
>
<div
className={`w-0 h-0 border-t-[8px] border-t-transparent border-b-[8px] border-b-transparent border-l-[12px] ${
connColor === 'bg-green-500' ? 'border-l-green-500' :
connColor === 'bg-red-500' ? 'border-l-red-500' : 'border-l-gray-300'
}`}
style={{
filter: connColor === 'bg-green-500'
? 'drop-shadow(0 0 3px rgba(34, 197, 94, 0.5))'
: connColor === 'bg-red-500'
? 'drop-shadow(0 0 3px rgba(239, 68, 68, 0.5))'
: 'drop-shadow(0 0 3px rgba(209, 213, 219, 0.5))'
}}
/>
</div>
)}
{/* Circle - centered, on top of connector */}
<div className={`relative z-10 w-12 h-12 rounded-full flex items-center justify-center border-2 transition-all font-semibold ${getPhaseColor(status)}`}>
{status === 'completed' ? '✓' : status === 'failed' ? '✕' : phase.id + 1}
</div>
{/* Labels - centered below circle */}
<div className="mt-3 text-center">
<div className="text-xs font-medium text-gray-700 whitespace-nowrap">
{phase.shortName}
</div>
{(status === 'completed' || status === 'failed') && getPhaseDuration(phase.id) && (
<div className={`mt-0.5 text-[10px] ${
status === 'failed' ? 'text-red-400' :
isSubPhase(phase.id) ? 'text-gray-400 italic' : 'text-gray-500 font-medium'
}`}>
{isSubPhase(phase.id) ? `(${getPhaseDuration(phase.id)})` : getPhaseDuration(phase.id)}
</div>
)}
{status === 'failed' && (
<div className="mt-0.5 text-[10px] text-red-500 font-semibold">Failed</div>
)}
{status === 'current' && getCurrentActivity(phase.id) && (
<div className="mt-1 max-w-[140px]">
<div className="text-[10px] text-blue-600 font-medium line-clamp-2 animate-pulse" title={getCurrentActivity(phase.id) || undefined}>
{getCurrentActivity(phase.id)}
</div>
</div>
)}
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 (
<div key={phase.id} className="flex-1 relative flex flex-col items-center min-w-0">
{/* Connector line — suppress at WhiteGlove split boundary */}
{index > 0 && !isSplitBoundary && (
<div
className={`absolute h-1 ${connColor}`}
style={{ top: '22px', left: '-50%', right: '50%' }}
/>
)}
{/* Arrow when previous phase is current or failed — suppress at split boundary */}
{index > 0 && showArrow && !isSplitBoundary && (
<div
className="absolute z-20"
style={{ top: '16px', left: 'calc(50% - 36px)' }}
>
<div
className={`w-0 h-0 border-t-[8px] border-t-transparent border-b-[8px] border-b-transparent border-l-[12px] ${
connColor === 'bg-green-500' ? 'border-l-green-500' :
connColor === 'bg-red-500' ? 'border-l-red-500' : 'border-l-gray-300'
}`}
style={{
filter: connColor === 'bg-green-500'
? 'drop-shadow(0 0 3px rgba(34, 197, 94, 0.5))'
: connColor === 'bg-red-500'
? 'drop-shadow(0 0 3px rgba(239, 68, 68, 0.5))'
: 'drop-shadow(0 0 3px rgba(209, 213, 219, 0.5))'
}}
/>
</div>
)}
{/* Circle */}
<div className={`relative z-10 w-12 h-12 rounded-full flex items-center justify-center border-2 transition-all font-semibold ${getPhaseColor(status)}`}>
{status === 'completed' ? '✓' : status === 'failed' ? '✕' : phase.id + 1}
</div>
{/* Labels */}
<div className="mt-3 text-center">
<div className="text-xs font-medium text-gray-700 whitespace-nowrap">
{phase.shortName}
</div>
{(status === 'completed' || status === 'failed') && getPhaseDuration(phase.id) && (
<div className={`mt-0.5 text-[10px] ${
status === 'failed' ? 'text-red-400' :
isSubPhase(phase.id) ? 'text-gray-400 italic' : 'text-gray-500 font-medium'
}`}>
{isSubPhase(phase.id) ? `(${getPhaseDuration(phase.id)})` : getPhaseDuration(phase.id)}
</div>
)}
{status === 'failed' && (
<div className="mt-0.5 text-[10px] text-red-500 font-semibold">Failed</div>
)}
{status === 'current' && getCurrentActivity(phase.id) && (
<div className="mt-1 max-w-[140px]">
<div className="text-[10px] text-blue-600 font-medium line-clamp-2 animate-pulse" title={getCurrentActivity(phase.id) || undefined}>
{getCurrentActivity(phase.id)}
</div>
</div>
)}
</div>
</div>
);
};
return (
<div className="w-full py-4">
{/* Group labels for WhiteGlove sessions */}
{isWhiteGloveV1 && splitIndex > 0 && (
<div className="flex w-full mb-3">
<div style={{ flex: splitIndex }} className="text-center">
<span className="text-xs font-semibold text-amber-700 bg-amber-50 px-2.5 py-0.5 rounded-full border border-amber-200">
Pre-Provisioning
</span>
</div>
<div className="w-8 flex-shrink-0" />
<div style={{ flex: phases.length - splitIndex }} className="text-center">
<span className="text-xs font-semibold text-blue-700 bg-blue-50 px-2.5 py-0.5 rounded-full border border-blue-200">
User Enrollment
</span>
</div>
</div>
)}
<div className="flex w-full">
{phases.map((phase, index) => {
const isSplitPoint = isWhiteGloveV1 && index === splitIndex;
return (
<React.Fragment key={phase.id}>
{/* Vertical dashed separator at the WhiteGlove split point */}
{isSplitPoint && (
<div className="flex-shrink-0 flex flex-col items-center justify-start px-1" style={{ width: '32px' }}>
<div className="w-px h-12 border-l-2 border-dashed border-gray-300" style={{ marginTop: '10px' }} />
</div>
)}
{renderPhaseNode(phase, index)}
</React.Fragment>
);
})}
</div>
@@ -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<string, EnrollmentEvent[]>, orderedPhases: [] as string[] };
@@ -818,7 +856,7 @@ export default function SessionDetailPage() {
{session && (
<SessionInfoCard
session={session}
enrollmentDuration={enrollmentDurationFromEvents}
enrollmentDuration={isWhiteGloveSession && whiteGloveDurations.combinedDuration ? whiteGloveDurations.combinedDuration : enrollmentDurationFromEvents}
displayStatus={displayStatus}
isGatherRulesSession={isGatherRulesSession}
/>
@@ -837,6 +875,7 @@ export default function SessionDetailPage() {
events={events}
sessionStatus={session.status}
enrollmentType={session.enrollmentType}
isPreProvisioned={isWhiteGloveSession}
/>
</div>
)}
@@ -900,6 +939,8 @@ export default function SessionDetailPage() {
userEnrollGrouped={userEnrollGrouped}
userEnrollEvents={userEnrollEvents}
isGalacticAdmin={user?.isGalacticAdmin}
preProvDuration={whiteGloveDurations.preProvDuration}
userEnrollDuration={whiteGloveDurations.userEnrollDuration}
/>
</div>