mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
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:
@@ -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; }
|
||||
|
||||
+21
-2
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+108
-66
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user