refactor web structure (session details page uses hooks)

This commit is contained in:
Oliver Kieselbach
2026-04-14 12:09:53 +02:00
parent ebc588c460
commit 95949abdd3
7 changed files with 904 additions and 607 deletions
@@ -0,0 +1,269 @@
"use client";
import { useMemo } from "react";
import { EnrollmentEvent, Session } from "@/types";
import { V1_PHASE_NAMES, V2_PHASE_NAMES, V1_PHASE_ORDER, V2_PHASE_ORDER } from "../utils/phaseConstants";
import { groupEventsByPhase } from "../utils/eventHelpers";
interface PhaseGrouping {
eventsByPhase: Record<string, EnrollmentEvent[]>;
orderedPhases: string[];
}
export interface UseSessionDerivedDataReturn {
filteredEvents: EnrollmentEvent[];
appSummaryStats: {
totalApps: number; completedApps: number; downloading: number; installing: number;
installed: number; skipped: number; failed: number; pending: number;
} | null;
ntpOffset: { offsetSeconds: number; ntpServer: string | undefined } | null;
configMgrDetected: {
ccmVersion: string | undefined; ccmServiceState: string | undefined;
siteCode: string | undefined; confidenceScore: number;
} | null;
isGatherRulesSession: boolean;
gatherRulesSucceeded: boolean;
displayStatus: string;
enrollmentDurationFromEvents: string | null;
phaseNamesMap: Record<number, string>;
phaseOrder: string[];
isSkipUserStatusPage: boolean;
isWhiteGloveSession: boolean;
whiteGloveSplitSequence: number;
preProvEvents: EnrollmentEvent[];
userEnrollEvents: EnrollmentEvent[];
whiteGloveDurations: {
preProvDuration: string | null;
userEnrollDuration: string | null;
combinedDuration: string | null;
};
eventsByPhase: Record<string, EnrollmentEvent[]>;
orderedPhases: string[];
preProvGrouped: PhaseGrouping;
userEnrollGrouped: PhaseGrouping;
}
/**
* Pure derivations on top of events + session + severityFilters.
* All useMemo — no side effects. Keeps page.tsx render body lean and
* makes these derivations testable in isolation.
*/
export function useSessionDerivedData(
events: EnrollmentEvent[],
session: Session | null,
severityFilters: Set<string>,
): UseSessionDerivedDataReturn {
// Filter events by severity for the timeline.
// Trace events are always excluded — they are for backend-side auditing only.
const filteredEvents = useMemo(
() => events.filter(e => e.severity !== "Trace" && severityFilters.has(e.severity)),
[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]);
// Extract NTP offset from the first ntp_time_check event (if present)
const ntpOffset = useMemo(() => {
const ntpEvent = events.find(e => e.eventType === "ntp_time_check");
if (!ntpEvent?.data?.offsetSeconds) return null;
return {
offsetSeconds: ntpEvent.data.offsetSeconds as number,
ntpServer: ntpEvent.data.ntpServer as string | undefined,
};
}, [events]);
// Extract ConfigMgr co-management detection (if present)
// Only show badge when confidence >= 50 (directory-only is too weak).
// Default to 100 for backward compat with old agent events that lack confidenceScore.
const configMgrDetected = useMemo(() => {
const evt = events.find(e => e.eventType === "configmgr_client_detected");
if (!evt?.data) return null;
const confidence = (evt.data.confidenceScore as number) ?? 100;
if (confidence < 50) return null;
return {
ccmVersion: evt.data.ccmVersion as string | undefined,
ccmServiceState: evt.data.ccmServiceState as string | undefined,
siteCode: evt.data.siteCode as string | undefined,
confidenceScore: confidence,
};
}, [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).
const gatherRulesSucceeded = isGatherRulesSession &&
events.some(e => e.eventType === "gather_rules_collection_completed");
const displayStatus = gatherRulesSucceeded ? "Succeeded" : (session?.status ?? "");
// Calculate enrollment duration from events (first event → enrollment_complete or last event)
// More accurate than session.durationSeconds which is based on registration StartedAt
const enrollmentDurationFromEvents = useMemo(() => {
if (events.length === 0) return null;
const timestamps = events.map(e => new Date(e.timestamp).getTime());
const firstEventTime = Math.min(...timestamps);
const completeEvent = events.find(e => e.eventType === "enrollment_complete");
const endTime = completeEvent
? new Date(completeEvent.timestamp).getTime()
: Math.max(...timestamps);
const durationSec = Math.round((endTime - firstEventTime) / 1000);
if (durationSec < 60) return `${durationSec}s`;
if (durationSec < 3600) return `${Math.floor(durationSec / 60)}m ${durationSec % 60}s`;
return `${Math.floor(durationSec / 3600)}h ${Math.floor((durationSec % 3600) / 60)}m`;
}, [events]);
const phaseNamesMap = session?.enrollmentType === "v2" ? V2_PHASE_NAMES : V1_PHASE_NAMES;
const phaseOrder = session?.enrollmentType === "v2" ? V2_PHASE_ORDER : V1_PHASE_ORDER;
// Detect SkipUserStatusPage from esp_config_detected event
const isSkipUserStatusPage = useMemo(() => {
if (session?.enrollmentType === "v2") return false;
const espConfigEvent = events.find(e => e.eventType === "esp_config_detected");
if (!espConfigEvent?.data) return false;
const val = espConfigEvent.data.skipUserStatusPage;
return val === true || val === "True" || val === "true";
}, [events, session?.enrollmentType]);
// Detect WhiteGlove session and find the split point
const isWhiteGloveSession = session?.isPreProvisioned === true ||
events.some(e => e.eventType === "whiteglove_complete");
const whiteGloveSplitSequence = useMemo(() => {
if (!isWhiteGloveSession) return -1;
const wgEvent = events.find(e => e.eventType === "whiteglove_complete");
const resumedEvent = events.find(e => e.eventType === "whiteglove_resumed");
// whiteglove_resumed is the definitive Part 2 marker (emitted at Part 2 agent startup).
// This handles both normal ordering and the race condition where Windows writes the
// whiteglove_complete event (Event 62407) after the Part 2 reboot.
if (resumedEvent) {
return resumedEvent.sequence - 1;
}
// Fallback for old agents without whiteglove_resumed:
// first agent_started AFTER whiteglove_complete = Part 2 start.
// NOTE: We do NOT use the "last agent_started before whiteglove_complete" heuristic
// because reboots within Part 1 would be misidentified as Part 2.
if (wgEvent) {
const nextStart = events.find(e =>
e.eventType === "agent_started" && e.sequence > wgEvent.sequence
);
if (nextStart) return nextStart.sequence - 1;
// Pre-provisioning only (no Part 2 yet). Include cleanup events
// (local_admin_analysis, agent_shutdown) that follow whiteglove_complete.
const shutdownAfterWg = events.find(e =>
e.eventType === "agent_shutdown" && e.sequence > wgEvent.sequence
);
return shutdownAfterWg?.sequence ?? wgEvent.sequence;
}
return -1;
}, [events, isWhiteGloveSession]);
// For WhiteGlove sessions: split filtered events into pre-provisioning and user-enrollment parts.
// Events are assigned purely by sequence number — no special-casing for whiteglove_complete.
// In the race-condition case (Windows writes the WhiteGlove success event after the reboot),
// whiteglove_complete naturally lands in the user-enrollment part, preserving chronological order.
const preProvEvents = useMemo(() => {
if (!isWhiteGloveSession || whiteGloveSplitSequence < 0) return [] as EnrollmentEvent[];
return filteredEvents.filter(e => e.sequence <= whiteGloveSplitSequence);
}, [filteredEvents, isWhiteGloveSession, whiteGloveSplitSequence]);
const userEnrollEvents = useMemo(() => {
if (!isWhiteGloveSession || whiteGloveSplitSequence < 0) return [] as EnrollmentEvent[];
return filteredEvents.filter(e => e.sequence > whiteGloveSplitSequence);
}, [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);
const userEnrollEvts = events.filter(e => e.sequence > whiteGloveSplitSequence);
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[] };
return groupEventsByPhase(filteredEvents, phaseNamesMap, phaseOrder, { preventPhaseRegression: true });
}, [filteredEvents, isWhiteGloveSession, phaseNamesMap, phaseOrder]);
const preProvGrouped = useMemo(() =>
isWhiteGloveSession && preProvEvents.length > 0
? groupEventsByPhase(preProvEvents, phaseNamesMap, phaseOrder, { preventPhaseRegression: true })
: { eventsByPhase: {} as Record<string, EnrollmentEvent[]>, orderedPhases: [] as string[] },
[preProvEvents, isWhiteGloveSession, phaseNamesMap, phaseOrder]
);
const userEnrollGrouped = useMemo(() =>
isWhiteGloveSession && userEnrollEvents.length > 0
? groupEventsByPhase(userEnrollEvents, phaseNamesMap, phaseOrder, { preventPhaseRegression: true })
: { eventsByPhase: {} as Record<string, EnrollmentEvent[]>, orderedPhases: [] as string[] },
[userEnrollEvents, isWhiteGloveSession, phaseNamesMap, phaseOrder]
);
return {
filteredEvents,
appSummaryStats,
ntpOffset,
configMgrDetected,
isGatherRulesSession: !!isGatherRulesSession,
gatherRulesSucceeded: !!gatherRulesSucceeded,
displayStatus,
enrollmentDurationFromEvents,
phaseNamesMap,
phaseOrder,
isSkipUserStatusPage,
isWhiteGloveSession,
whiteGloveSplitSequence,
preProvEvents,
userEnrollEvents,
whiteGloveDurations,
eventsByPhase,
orderedPhases,
preProvGrouped,
userEnrollGrouped,
};
}
@@ -0,0 +1,161 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { isGuid } from "@/utils/inputValidation";
import { Session } from "@/types";
import type { NotificationType } from "@/contexts/NotificationContext";
type AddNotification = (
type: NotificationType,
title: string,
message: string,
key?: string,
href?: string,
) => void;
interface User {
isTenantAdmin?: boolean;
isGlobalAdmin?: boolean;
role?: string | null;
}
interface UseSessionDetailParams {
sessionId: string;
tenantId: string;
globalAdminMode: boolean;
user: User | null | undefined;
getAccessToken: (forceRefresh?: boolean) => Promise<string | null>;
addNotification: AddNotification;
}
export interface UseSessionDetailReturn {
session: Session | null;
setSession: React.Dispatch<React.SetStateAction<Session | null>>;
sessionTenantId: string | null;
setSessionTenantId: React.Dispatch<React.SetStateAction<string | null>>;
loading: boolean;
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
fetchSessionDetails: () => Promise<void>;
resolveEffectiveTenantId: () => string | null;
sessionRef: React.MutableRefObject<Session | null>;
sessionIdRef: React.MutableRefObject<string>;
}
/**
* Owns the session detail page's core session-object lifecycle:
* - initial fetch (gated on auth + tenantId readiness; resets on sessionId change)
* - StrictMode-safe single-fetch guards (hasInitialFetch, lastFetchedSessionId)
* - eager sessionTenantId hydration from TenantContext to eliminate fetch waterfall
* - resolveEffectiveTenantId helper used by events/SignalR hooks
*/
export function useSessionDetail({
sessionId,
tenantId,
globalAdminMode,
user,
getAccessToken,
addNotification,
}: UseSessionDetailParams): UseSessionDetailReturn {
const [session, setSession] = useState<Session | null>(null);
const [sessionTenantId, setSessionTenantId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const sessionRef = useRef<Session | null>(null);
const sessionIdRef = useRef(sessionId);
const tenantIdRef = useRef(tenantId);
const sessionTenantIdRef = useRef<string | null>(sessionTenantId);
const globalAdminModeRef = useRef(globalAdminMode);
const hasInitialFetch = useRef(false);
const lastFetchedSessionId = useRef<string | null>(null);
useEffect(() => { sessionRef.current = session; }, [session]);
useEffect(() => { sessionIdRef.current = sessionId; }, [sessionId]);
useEffect(() => { tenantIdRef.current = tenantId; }, [tenantId]);
useEffect(() => { sessionTenantIdRef.current = sessionTenantId; }, [sessionTenantId]);
useEffect(() => { globalAdminModeRef.current = globalAdminMode; }, [globalAdminMode]);
const resolveEffectiveTenantId = useCallback((): string | null => {
const knownSessionTenant = sessionTenantIdRef.current || sessionRef.current?.tenantId || null;
if (knownSessionTenant) return knownSessionTenant;
if (globalAdminModeRef.current) return null;
return tenantIdRef.current || null;
}, []);
const fetchSessionDetails = useCallback(async () => {
try {
const knownTenantId = resolveEffectiveTenantId();
// Always use the direct session endpoint — the backend resolves the tenant
// via FindSessionTenantIdAsync for global admins when tenantId is unknown.
const endpoint = knownTenantId
? api.sessions.get(sessionId, knownTenantId)
: api.sessions.get(sessionId);
const response = await authenticatedFetch(endpoint, getAccessToken);
if (response.ok) {
const data = await response.json();
const foundSession = data.session ?? data.sessions?.find((s: Session) => s.sessionId === sessionId);
if (foundSession) {
setSession(foundSession);
setSessionTenantId(foundSession.tenantId);
}
} else {
addNotification('error', 'Backend Error', `Failed to load session details: ${response.statusText}`, 'session-detail-fetch-error');
}
} catch (error) {
if (error instanceof TokenExpiredError) {
addNotification('error', 'Session Expired', error.message, 'session-expired-error');
return;
}
console.error("Failed to fetch session details:", error);
addNotification('error', 'Backend Not Reachable', 'Unable to load session details. Please check your connection.', 'session-detail-fetch-error');
// Allow retry on network errors
hasInitialFetch.current = false;
}
}, [sessionId, getAccessToken, addNotification, resolveEffectiveTenantId]);
// Initial data fetch — wait for auth to be ready and a real tenantId before calling the backend.
// TenantContext initializes to '' and updates once AuthContext finishes loading.
// `user` is included so that a retry fires once MSAL settles (token becomes available).
useEffect(() => {
if (!sessionId) return;
if (!globalAdminMode && !tenantId) return; // wait for real tenant ID
// Reset fetch flag only if navigating to a different session
if (lastFetchedSessionId.current !== sessionId) {
hasInitialFetch.current = false;
lastFetchedSessionId.current = sessionId;
setSessionTenantId(null);
}
// Prevent duplicate fetches in React StrictMode (development double-mounting)
if (hasInitialFetch.current) return;
hasInitialFetch.current = true;
// Performance: eager-set sessionTenantId if we already know it from TenantContext.
// This lets dependent effects kick off fetchEvents/analysis/vulns/config in parallel
// with fetchSessionDetails instead of waiting for its roundtrip — eliminates a waterfall.
// Global Admins in all-tenant view fall through with null and keep the old behavior.
const knownTenantId = resolveEffectiveTenantId();
if (knownTenantId && isGuid(knownTenantId)) {
setSessionTenantId(knownTenantId);
}
fetchSessionDetails();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId, tenantId, globalAdminMode, user]);
return {
session,
setSession,
sessionTenantId,
setSessionTenantId,
loading,
setLoading,
fetchSessionDetails,
resolveEffectiveTenantId,
sessionRef,
sessionIdRef,
};
}
@@ -0,0 +1,179 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { isGuid } from "@/utils/inputValidation";
import { EnrollmentEvent, Session } from "@/types";
import type { NotificationType } from "@/contexts/NotificationContext";
type AddNotification = (
type: NotificationType,
title: string,
message: string,
key?: string,
href?: string,
) => void;
interface UseSessionEventsParams {
sessionId: string;
sessionTenantId: string | null;
resolveEffectiveTenantId: () => string | null;
sessionRef: React.MutableRefObject<Session | null>;
sessionIdRef: React.MutableRefObject<string>;
fetchSessionDetails: () => Promise<void>;
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
isConnected: boolean;
getAccessToken: (forceRefresh?: boolean) => Promise<string | null>;
addNotification: AddNotification;
}
export interface UseSessionEventsReturn {
events: EnrollmentEvent[];
setEvents: React.Dispatch<React.SetStateAction<EnrollmentEvent[]>>;
fetchEvents: () => Promise<void>;
scheduleFetchEvents: (delayMs?: number) => void;
}
/**
* Owns the session detail page's event list lifecycle:
* - fetch events from Table Storage (canonical truth)
* - in-flight dedup (SignalR + 30s timer + group-join can overlap)
* - debounced scheduleFetchEvents to absorb bursts
* - empty-refresh guard: ignores transient empty lists, keeps last known-good
* - terminal-event detection: triggers session refetch if SignalR status delta was lost
* - triggers initial fetch once sessionTenantId is known
* - 30s fallback polling while SignalR disconnected (visible tab only)
*/
export function useSessionEvents({
sessionId,
sessionTenantId,
resolveEffectiveTenantId,
sessionRef,
sessionIdRef,
fetchSessionDetails,
setLoading,
isConnected,
getAccessToken,
addNotification,
}: UseSessionEventsParams): UseSessionEventsReturn {
const [events, setEvents] = useState<EnrollmentEvent[]>([]);
// Debounce real-time event refreshes to avoid burst reads in Table Storage.
const eventRefreshTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Deduplication: track in-flight fetchEvents to avoid concurrent calls
const fetchEventsInFlight = useRef(false);
const fetchEventsQueued = useRef(false);
const fetchEvents = useCallback(async () => {
// Deduplication: if a fetch is already in flight, queue one follow-up instead of
// stacking concurrent requests (SignalR signal + 30s timer + group-join can overlap).
if (fetchEventsInFlight.current) {
fetchEventsQueued.current = true;
return;
}
const effectiveTenantId = resolveEffectiveTenantId();
if (!effectiveTenantId || !isGuid(effectiveTenantId)) {
return;
}
fetchEventsInFlight.current = true;
fetchEventsQueued.current = false;
try {
const response = await authenticatedFetch(
api.sessions.events(sessionId, effectiveTenantId),
getAccessToken
);
if (response.ok) {
const data = await response.json();
// Replace entire event list with fresh data from Table Storage (canonical truth).
// No merge needed — fetchEvents() is the single source; SignalR only signals.
// phaseName is computed at render time in eventsByPhase useMemo (no race condition).
const fetchedEvents = Array.isArray(data.events) ? data.events : [];
setEvents(prevEvents => {
// Keep the last known-good snapshot if backend transiently returns an empty list.
if (fetchedEvents.length === 0 && prevEvents.length > 0) {
console.warn(
`[SessionDetail] Ignoring empty event refresh for session ${sessionIdRef.current} (tenant ${effectiveTenantId}); keeping ${prevEvents.length} cached events`
);
return prevEvents;
}
return fetchedEvents;
});
// Surgical status-stale detection: if events contain a terminal event but session
// status is still InProgress, the SignalR status delta was likely lost. Refetch
// session details from the DB (single read, only when truly needed).
const currentStatus = sessionRef.current?.status;
if (currentStatus && currentStatus !== "Succeeded" && currentStatus !== "Failed") {
const hasTerminalEvent = fetchedEvents.some(
(e: EnrollmentEvent) => e.eventType === "enrollment_complete" || e.eventType === "enrollment_failed"
);
if (hasTerminalEvent) {
console.info(
`[SessionDetail] Terminal event detected but session status is '${currentStatus}' — refetching session details`
);
fetchSessionDetails();
}
}
} else {
addNotification('error', 'Backend Error', `Failed to load session events: ${response.statusText}`, 'session-events-fetch-error');
}
} catch (error) {
if (error instanceof TokenExpiredError) {
addNotification('error', 'Session Expired', error.message, 'session-expired-error');
} else {
console.error("Failed to fetch events:", error);
addNotification('error', 'Backend Not Reachable', 'Unable to load session events. Please check your connection.', 'session-events-fetch-error');
}
} finally {
setLoading(false);
fetchEventsInFlight.current = false;
// If another fetch was requested while we were in flight, run it now
if (fetchEventsQueued.current) {
fetchEventsQueued.current = false;
fetchEvents();
}
}
}, [sessionId, resolveEffectiveTenantId, sessionRef, sessionIdRef, fetchSessionDetails, setLoading, getAccessToken, addNotification]);
const scheduleFetchEvents = useCallback((delayMs = 300) => {
if (eventRefreshTimeoutRef.current) {
clearTimeout(eventRefreshTimeoutRef.current);
}
eventRefreshTimeoutRef.current = setTimeout(() => {
fetchEvents();
}, delayMs);
}, [fetchEvents]);
// Fetch events when we have the session's tenant ID
useEffect(() => {
if (sessionTenantId && sessionId) {
fetchEvents();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionTenantId, sessionId]);
// Clear debounce timer on unmount
useEffect(() => {
return () => {
if (eventRefreshTimeoutRef.current) {
clearTimeout(eventRefreshTimeoutRef.current);
}
};
}, []);
// Fallback polling only while SignalR is disconnected.
useEffect(() => {
const effectiveTenantId = resolveEffectiveTenantId();
if (!sessionId || !effectiveTenantId || isConnected) return;
const interval = setInterval(() => {
if (document.visibilityState === "visible") fetchEvents();
}, 30_000);
return () => clearInterval(interval);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId, sessionTenantId, isConnected]);
return { events, setEvents, fetchEvents, scheduleFetchEvents };
}
@@ -0,0 +1,155 @@
"use client";
import { useEffect, useRef } from "react";
import { Session, RuleResult } from "@/types";
interface SignalRApi {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
on: (event: string, handler: (...args: any[]) => void) => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
off: (event: string, handler: (...args: any[]) => void) => void;
isConnected: boolean;
joinGroup: (group: string) => Promise<void>;
leaveGroup: (group: string) => Promise<void>;
}
interface UseSessionSignalRParams {
sessionId: string;
sessionTenantId: string | null;
tenantId: string;
sessionTenantIdFromSession: string | undefined;
globalAdminMode: boolean;
sessionIdRef: React.MutableRefObject<string>;
resolveEffectiveTenantId: () => string | null;
signalR: SignalRApi;
scheduleFetchEvents: (delayMs?: number) => void;
setSession: React.Dispatch<React.SetStateAction<Session | null>>;
setSessionTenantId: React.Dispatch<React.SetStateAction<string | null>>;
fetchAnalysisResults: (reanalyze?: boolean) => Promise<void>;
fetchVulnerabilityReport: (rescan?: boolean) => Promise<void>;
}
/**
* Owns the session detail page's SignalR integration:
* - joins tenant + session groups using subscribe-then-fetch pattern
* - listens for eventStream, newevents, ruleResultsReady, vulnerabilityReportReady
* - cleans up groups + handlers on unmount / sessionId change
*/
export function useSessionSignalR({
sessionId,
sessionTenantId,
tenantId,
sessionTenantIdFromSession,
globalAdminMode,
sessionIdRef,
resolveEffectiveTenantId,
signalR,
scheduleFetchEvents,
setSession,
setSessionTenantId,
fetchAnalysisResults,
fetchVulnerabilityReport,
}: UseSessionSignalRParams): void {
const { on, off, isConnected, joinGroup, leaveGroup } = signalR;
const hasJoinedGroups = useRef(false);
// Join SignalR groups when connected (for multi-tenancy and cost optimization)
// Uses "subscribe-then-fetch" pattern: join groups first, then re-fetch events
// to catch anything that arrived before the group join completed.
useEffect(() => {
const effectiveTenantId = resolveEffectiveTenantId();
if (!sessionId || !isConnected || !effectiveTenantId) return;
if (!hasJoinedGroups.current) {
const tenantGroupName = `tenant-${effectiveTenantId}`;
const sessionGroupName = `session-${effectiveTenantId}-${sessionId}`;
const joinAndCatchUp = async () => {
await joinGroup(tenantGroupName);
await joinGroup(sessionGroupName);
hasJoinedGroups.current = true;
// Re-fetch events after group join to catch any SignalR messages
// that were sent before the client joined the session group.
// The frontend deduplicates by eventId, so no duplicates.
scheduleFetchEvents(0);
};
joinAndCatchUp();
}
return () => {
if (hasJoinedGroups.current && effectiveTenantId) {
const tenantGroupName = `tenant-${effectiveTenantId}`;
const sessionGroupName = `session-${effectiveTenantId}-${sessionId}`;
leaveGroup(tenantGroupName);
leaveGroup(sessionGroupName);
hasJoinedGroups.current = false;
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId, isConnected, sessionTenantId, tenantId, sessionTenantIdFromSession, globalAdminMode]);
// Setup SignalR listener - re-register when connection changes
useEffect(() => {
// Listen for event stream signal — backend sends a lightweight signal (no event payload).
// Frontend fetches fresh events from Table Storage on receipt: canonical truth, no gaps.
const handleEventStream = (data: { sessionId: string; tenantId: string; newEventCount: number; newRuleResults?: RuleResult[] }) => {
console.log('Event stream signal received via SignalR:', data);
if (data.sessionId !== sessionIdRef.current) return;
// Fetch full events from storage (single source of truth), but debounce bursts.
// Session updates arrive via the "newevents" message (tenant group) — no session
// object in this signal to keep payloads minimal.
scheduleFetchEvents();
if (data.tenantId) {
setSessionTenantId(prev => prev || data.tenantId);
}
// Rule results from SignalR (only on enrollment completion)
if (data.newRuleResults && data.newRuleResults.length > 0) {
fetchAnalysisResults();
}
};
// Listen for session delta updates via the tenant group ("newevents").
// This replaces the full session object that was previously sent inside "eventStream".
const handleNewEvents = (data: { sessionId: string; tenantId: string; sessionUpdate?: Partial<Session> }) => {
if (data.sessionId !== sessionIdRef.current) return;
if (data.sessionUpdate) {
setSession(prev => prev ? { ...prev, ...data.sessionUpdate } : prev);
}
if (data.tenantId) {
setSessionTenantId(prev => prev || data.tenantId);
}
};
// Listen for async rule engine results (pushed after background analysis completes)
const handleRuleResultsReady = (data: { sessionId: string; tenantId: string; ruleResultCount: number }) => {
if (data.sessionId !== sessionIdRef.current) return;
console.info(`[SessionDetail] ruleResultsReady signal: ${data.ruleResultCount} results`);
fetchAnalysisResults();
};
// Listen for async vulnerability correlation results
const handleVulnerabilityReportReady = (data: { sessionId: string; tenantId: string; overallRisk: string }) => {
if (data.sessionId !== sessionIdRef.current) return;
console.info(`[SessionDetail] vulnerabilityReportReady signal: risk=${data.overallRisk}`);
fetchVulnerabilityReport();
};
on('eventStream', handleEventStream);
on('newevents', handleNewEvents);
on('ruleResultsReady', handleRuleResultsReady);
on('vulnerabilityReportReady', handleVulnerabilityReportReady);
return () => {
off('eventStream', handleEventStream);
off('newevents', handleNewEvents);
off('ruleResultsReady', handleRuleResultsReady);
off('vulnerabilityReportReady', handleVulnerabilityReportReady);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [on, off]);
}
@@ -0,0 +1,41 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import { authenticatedFetch } from "@/lib/authenticatedFetch";
interface UseSessionTenantConfigReturn {
showScriptOutput: boolean;
enableSoftwareInventoryAnalyzer: boolean;
}
/**
* Fetches the session's tenant-level UI config (best-effort).
* Triggers once sessionTenantId is known. Swallows errors — these flags
* are non-critical (fall back to sensible defaults).
*/
export function useSessionTenantConfig(
sessionTenantId: string | null,
getAccessToken: (forceRefresh?: boolean) => Promise<string | null>,
): UseSessionTenantConfigReturn {
const [showScriptOutput, setShowScriptOutput] = useState(true);
const [enableSoftwareInventoryAnalyzer, setEnableSoftwareInventoryAnalyzer] = useState(false);
useEffect(() => {
if (!sessionTenantId) return;
let cancelled = false;
(async () => {
try {
const res = await authenticatedFetch(api.config.tenant(sessionTenantId), getAccessToken);
if (!res.ok || cancelled) return;
const cfg = await res.json();
if (cancelled) return;
setShowScriptOutput(cfg.showScriptOutput ?? true);
setEnableSoftwareInventoryAnalyzer(cfg.enableSoftwareInventoryAnalyzer ?? false);
} catch { /* non-fatal */ }
})();
return () => { cancelled = true; };
}, [sessionTenantId, getAccessToken]);
return { showScriptOutput, enableSoftwareInventoryAnalyzer };
}
@@ -1,8 +1,6 @@
"use client";
import { useEffect, useState, useRef, useMemo } from "react";
import { useSessionAnalysis } from "./hooks/useSessionAnalysis";
import { useAutoScroll } from "./hooks/useAutoScroll";
import { useEffect, useMemo, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useSignalR } from "../../../contexts/SignalRContext";
import { useTenant } from "../../../contexts/TenantContext";
@@ -17,8 +15,14 @@ import { useLatestVersions } from '@/lib/useLatestVersions';
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { V1_PHASE_NAMES, V2_PHASE_NAMES, V1_PHASE_ORDER, V2_PHASE_ORDER } from "./utils/phaseConstants";
import { groupEventsByPhase } from "./utils/eventHelpers";
import { useSessionAnalysis } from "./hooks/useSessionAnalysis";
import { useAutoScroll } from "./hooks/useAutoScroll";
import { useSessionDetail } from "./hooks/useSessionDetail";
import { useSessionEvents } from "./hooks/useSessionEvents";
import { useSessionSignalR } from "./hooks/useSessionSignalR";
import { useSessionDerivedData } from "./hooks/useSessionDerivedData";
import { useSessionTenantConfig } from "./hooks/useSessionTenantConfig";
import SessionInfoCard from "./components/SessionInfoCard";
import PhaseTimeline from "./components/PhaseTimeline";
import EventTimeline from "./components/EventTimeline";
@@ -31,403 +35,135 @@ import { PageSectionItem } from "../../../contexts/SidebarContext";
import { InformationCircleIcon, ComputerDesktopIcon, PlayCircleIcon, SparklesIcon, ChartBarIcon, CodeBracketIcon, ArrowDownTrayIcon, ListBulletIcon, ClockIcon, ShieldCheckIcon } from "../../../lib/sidebarIcons";
import DeviceDetailsCard from "./components/DeviceDetailsCard";
import { generateUiExport, generateCsvExport, generateSessionCsvExport, generateRuleResultsCsvExport, SessionExportEvent } from "@/utils/sessionExportUtils";
import { Session, EnrollmentEvent, RuleResult } from "@/types";
import { trackEvent } from "@/lib/appInsights";
import { useAdminMode } from "@/hooks/useAdminMode";
import { isGuid } from "@/utils/inputValidation";
export default function SessionDetailPage() {
const params = useParams();
const router = useRouter();
const sessionId = params?.sessionId as string;
const [events, setEvents] = useState<EnrollmentEvent[]>([]);
const [session, setSession] = useState<Session | null>(null);
const [sessionTenantId, setSessionTenantId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
// UI-only state (modals, expand/collapse, severity filters)
const [severityFilters, setSeverityFilters] = useState<Set<string>>(new Set(["Info", "Warning", "Error", "Critical"]));
const [showMarkFailedConfirm, setShowMarkFailedConfirm] = useState(false);
const [showMarkSucceededConfirm, setShowMarkSucceededConfirm] = useState(false);
const [showReportModal, setShowReportModal] = useState(false);
const [reportSubmitting, setReportSubmitting] = useState(false);
const [showScriptOutput, setShowScriptOutput] = useState(true);
const [enableSoftwareInventoryAnalyzer, setEnableSoftwareInventoryAnalyzer] = useState(false);
const [analysisExpanded, setAnalysisExpanded] = useState(true);
const [vulnerabilityReportExpanded, setVulnerabilityReportExpanded] = useState(false);
const [phaseTimelineExpanded, setPhaseTimelineExpanded] = useState(true);
const [perfExpanded, setPerfExpanded] = useState(true);
const [timelineExpanded, setTimelineExpanded] = useState(true);
const { adminMode, setAdminMode, globalAdminMode, setGlobalAdminMode } = useAdminMode();
const [expandedPhases, setExpandedPhases] = useState<Set<string>>(new Set());
// Debounce real-time event refreshes to avoid burst reads in Table Storage.
const eventRefreshTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const sessionIdRef = useRef(sessionId);
const sessionRef = useRef<Session | null>(null);
const hasInitialFetch = useRef(false);
const lastFetchedSessionId = useRef<string | null>(null);
const tenantIdRef = useRef<string>("");
const sessionTenantIdRef = useRef<string | null>(sessionTenantId);
const globalAdminModeRef = useRef(globalAdminMode);
const { adminMode, globalAdminMode } = useAdminMode();
// Track if we've joined groups to prevent duplicate joins
const hasJoinedGroups = useRef(false);
// Deduplication: track in-flight fetchEvents to avoid concurrent calls
const fetchEventsInFlight = useRef(false);
const fetchEventsQueued = useRef(false);
// Use global contexts
// Global contexts
const { on, off, isConnected, joinGroup, leaveGroup } = useSignalR();
const { tenantId } = useTenant();
const { getAccessToken, user } = useAuth();
const { addNotification } = useNotifications();
const { latestAgentVersion, latestBootstrapVersion } = useLatestVersions(getAccessToken);
// Session-scoped hooks (data/SignalR/derivations)
const detail = useSessionDetail({ sessionId, tenantId, globalAdminMode, user, getAccessToken, addNotification });
const analysis = useSessionAnalysis(sessionId, detail.sessionTenantId, getAccessToken);
const tenantConfig = useSessionTenantConfig(detail.sessionTenantId, getAccessToken);
const eventsApi = useSessionEvents({
sessionId,
sessionTenantId: detail.sessionTenantId,
resolveEffectiveTenantId: detail.resolveEffectiveTenantId,
sessionRef: detail.sessionRef,
sessionIdRef: detail.sessionIdRef,
fetchSessionDetails: detail.fetchSessionDetails,
setLoading: detail.setLoading,
isConnected,
getAccessToken,
addNotification,
});
useSessionSignalR({
sessionId,
sessionTenantId: detail.sessionTenantId,
tenantId,
sessionTenantIdFromSession: detail.session?.tenantId,
globalAdminMode,
sessionIdRef: detail.sessionIdRef,
resolveEffectiveTenantId: detail.resolveEffectiveTenantId,
signalR: { on, off, isConnected, joinGroup, leaveGroup },
scheduleFetchEvents: eventsApi.scheduleFetchEvents,
setSession: detail.setSession,
setSessionTenantId: detail.setSessionTenantId,
fetchAnalysisResults: analysis.fetchAnalysisResults,
fetchVulnerabilityReport: analysis.fetchVulnerabilityReport,
});
const derived = useSessionDerivedData(eventsApi.events, detail.session, severityFilters);
const { autoScroll, handleAutoScrollToggle } = useAutoScroll(eventsApi.events);
// Convenience local aliases (keeps the JSX below readable, matches previous names)
const { session, setSession, sessionTenantId, loading } = detail;
const events = eventsApi.events;
const { analysisResults, loadingAnalysis, vulnerabilityReport, fetchAnalysisResults, fetchVulnerabilityReport } = analysis;
const { showScriptOutput, enableSoftwareInventoryAnalyzer } = tenantConfig;
const {
analysisResults,
loadingAnalysis,
vulnerabilityReport,
setVulnerabilityReport,
fetchAnalysisResults,
fetchVulnerabilityReport,
} = useSessionAnalysis(sessionId, sessionTenantId, getAccessToken);
const { autoScroll, handleAutoScrollToggle } = useAutoScroll(events);
filteredEvents,
appSummaryStats,
ntpOffset,
configMgrDetected,
isGatherRulesSession,
displayStatus,
enrollmentDurationFromEvents,
isSkipUserStatusPage,
isWhiteGloveSession,
whiteGloveSplitSequence,
userEnrollEvents,
whiteGloveDurations,
eventsByPhase,
orderedPhases,
preProvGrouped,
userEnrollGrouped,
} = derived;
// Auto-expand new phases as they appear (keeps existing expanded/collapsed state).
// For WhiteGlove sessions we use prefixed keys (pre-X, user-X) to avoid collisions.
useEffect(() => {
sessionRef.current = session;
}, [session]);
setExpandedPhases(prev => {
const newExpanded = new Set(prev);
let hasChanges = false;
useEffect(() => {
tenantIdRef.current = tenantId;
}, [tenantId]);
const allPhases = isWhiteGloveSession
? [
...preProvGrouped.orderedPhases.map(p => `pre-${p}`),
...userEnrollGrouped.orderedPhases.map(p => `user-${p}`),
]
: orderedPhases;
useEffect(() => {
sessionTenantIdRef.current = sessionTenantId;
}, [sessionTenantId]);
useEffect(() => {
globalAdminModeRef.current = globalAdminMode;
}, [globalAdminMode]);
const resolveEffectiveTenantId = () => {
const knownSessionTenant = sessionTenantIdRef.current || sessionRef.current?.tenantId || null;
if (knownSessionTenant) return knownSessionTenant;
if (globalAdminModeRef.current) return null;
return tenantIdRef.current || null;
};
const scheduleFetchEvents = (delayMs = 300) => {
if (eventRefreshTimeoutRef.current) {
clearTimeout(eventRefreshTimeoutRef.current);
}
eventRefreshTimeoutRef.current = setTimeout(() => {
fetchEvents();
}, delayMs);
};
// Initial data fetch — wait for auth to be ready and a real tenantId before calling the backend.
// TenantContext initializes to '' and updates once AuthContext finishes loading.
// `user` is included so that a retry fires once MSAL settles (token becomes available).
useEffect(() => {
if (!sessionId) return;
if (!globalAdminMode && !tenantId) return; // wait for real tenant ID
// Update sessionIdRef
sessionIdRef.current = sessionId;
// Reset fetch flag only if navigating to a different session
if (lastFetchedSessionId.current !== sessionId) {
hasInitialFetch.current = false;
lastFetchedSessionId.current = sessionId;
setSessionTenantId(null); // Reset session tenant ID for new session
}
// Prevent duplicate fetches in React StrictMode (development double-mounting)
if (hasInitialFetch.current) return;
hasInitialFetch.current = true;
// Performance: eager-set sessionTenantId if we already know it from TenantContext.
// This lets the second useEffect kick off fetchEvents/analysis/vulns/config in parallel
// with fetchSessionDetails instead of waiting for its roundtrip — eliminates a waterfall.
// Global Admins in all-tenant view fall through with null and keep the old behavior.
const knownTenantId = resolveEffectiveTenantId();
if (knownTenantId && isGuid(knownTenantId)) {
setSessionTenantId(knownTenantId);
}
fetchSessionDetails();
// fetchEvents will be called after sessionTenantId is set
}, [sessionId, tenantId, globalAdminMode, user]);
// Fetch events, analysis, and vulnerability report when we have the session's tenant ID
useEffect(() => {
if (sessionTenantId && sessionId) {
fetchEvents();
fetchAnalysisResults();
fetchVulnerabilityReport();
// Fetch showScriptOutput setting (best-effort)
(async () => {
try {
const res = await authenticatedFetch(
api.config.tenant(sessionTenantId),
getAccessToken
);
if (res.ok) {
const cfg = await res.json();
setShowScriptOutput(cfg.showScriptOutput ?? true);
setEnableSoftwareInventoryAnalyzer(cfg.enableSoftwareInventoryAnalyzer ?? false);
}
} catch { /* non-fatal */ }
})();
}
}, [sessionTenantId, sessionId]);
// Join SignalR groups when connected (for multi-tenancy and cost optimization)
// Uses "subscribe-then-fetch" pattern: join groups first, then re-fetch events
// to catch anything that arrived before the group join completed.
useEffect(() => {
const effectiveTenantId = resolveEffectiveTenantId();
if (!sessionId || !isConnected || !effectiveTenantId) return;
if (!hasJoinedGroups.current) {
// Join both tenant group (for newEvents) and session group (for eventStream)
const tenantGroupName = `tenant-${effectiveTenantId}`;
const sessionGroupName = `session-${effectiveTenantId}-${sessionId}`;
const joinAndCatchUp = async () => {
await joinGroup(tenantGroupName);
await joinGroup(sessionGroupName);
hasJoinedGroups.current = true;
// Re-fetch events after group join to catch any SignalR messages
// that were sent before the client joined the session group.
// The frontend deduplicates by eventId, so no duplicates.
scheduleFetchEvents(0);
};
joinAndCatchUp();
}
return () => {
// Leave groups when component unmounts or sessionId changes
if (hasJoinedGroups.current && effectiveTenantId) {
const tenantGroupName = `tenant-${effectiveTenantId}`;
const sessionGroupName = `session-${effectiveTenantId}-${sessionId}`;
leaveGroup(tenantGroupName);
leaveGroup(sessionGroupName);
hasJoinedGroups.current = false;
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId, isConnected, sessionTenantId, tenantId, session?.tenantId, globalAdminMode]);
// Setup SignalR listener - re-register when connection changes
useEffect(() => {
// Listen for event stream signal — backend sends a lightweight signal (no event payload).
// Frontend fetches fresh events from Table Storage on receipt: canonical truth, no gaps.
const handleEventStream = (data: { sessionId: string; tenantId: string; newEventCount: number; newRuleResults?: RuleResult[] }) => {
console.log('Event stream signal received via SignalR:', data);
if (data.sessionId !== sessionIdRef.current) return;
// Fetch full events from storage (single source of truth), but debounce bursts.
// Session updates arrive via the "newevents" message (tenant group) — no session
// object in this signal to keep payloads minimal.
scheduleFetchEvents();
if (data.tenantId) {
setSessionTenantId(prev => prev || data.tenantId);
}
// Rule results from SignalR (only on enrollment completion)
if (data.newRuleResults && data.newRuleResults.length > 0) {
fetchAnalysisResults();
}
};
// Listen for session delta updates via the tenant group ("newevents").
// This replaces the full session object that was previously sent inside "eventStream".
const handleNewEvents = (data: { sessionId: string; tenantId: string; sessionUpdate?: Partial<Session> }) => {
if (data.sessionId !== sessionIdRef.current) return;
if (data.sessionUpdate) {
setSession(prev => prev ? { ...prev, ...data.sessionUpdate } : prev);
}
if (data.tenantId) {
setSessionTenantId(prev => prev || data.tenantId);
}
};
// Listen for async rule engine results (pushed after background analysis completes)
const handleRuleResultsReady = (data: { sessionId: string; tenantId: string; ruleResultCount: number }) => {
if (data.sessionId !== sessionIdRef.current) return;
console.info(`[SessionDetail] ruleResultsReady signal: ${data.ruleResultCount} results`);
fetchAnalysisResults();
};
// Listen for async vulnerability correlation results
const handleVulnerabilityReportReady = (data: { sessionId: string; tenantId: string; overallRisk: string }) => {
if (data.sessionId !== sessionIdRef.current) return;
console.info(`[SessionDetail] vulnerabilityReportReady signal: risk=${data.overallRisk}`);
fetchVulnerabilityReport();
};
on('eventStream', handleEventStream);
on('newevents', handleNewEvents);
on('ruleResultsReady', handleRuleResultsReady);
on('vulnerabilityReportReady', handleVulnerabilityReportReady);
return () => {
off('eventStream', handleEventStream);
off('newevents', handleNewEvents);
off('ruleResultsReady', handleRuleResultsReady);
off('vulnerabilityReportReady', handleVulnerabilityReportReady);
if (eventRefreshTimeoutRef.current) {
clearTimeout(eventRefreshTimeoutRef.current);
}
};
}, [on, off]); // Re-register when SignalR connection changes
// Fallback polling only while SignalR is disconnected.
useEffect(() => {
const effectiveTenantId = resolveEffectiveTenantId();
if (!sessionId || !effectiveTenantId || isConnected) return;
const interval = setInterval(() => {
if (document.visibilityState === "visible") fetchEvents();
}, 30_000);
return () => clearInterval(interval);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId, sessionTenantId, tenantId, session?.tenantId, globalAdminMode, isConnected]);
const fetchSessionDetails = async () => {
try {
const knownTenantId = resolveEffectiveTenantId();
// Always use the direct session endpoint — the backend resolves the tenant
// via FindSessionTenantIdAsync for global admins when tenantId is unknown.
const endpoint = knownTenantId
? api.sessions.get(sessionId, knownTenantId)
: api.sessions.get(sessionId);
const response = await authenticatedFetch(endpoint, getAccessToken);
if (response.ok) {
const data = await response.json();
const foundSession = data.session ?? data.sessions?.find((s: Session) => s.sessionId === sessionId);
if (foundSession) {
setSession(foundSession);
// Store the session's tenant ID for subsequent requests
setSessionTenantId(foundSession.tenantId);
for (const phase of allPhases) {
if (!prev.has(phase)) {
newExpanded.add(phase);
hasChanges = true;
}
} else {
addNotification('error', 'Backend Error', `Failed to load session details: ${response.statusText}`, 'session-detail-fetch-error');
}
} catch (error) {
if (error instanceof TokenExpiredError) {
addNotification('error', 'Session Expired', error.message, 'session-expired-error');
return;
}
console.error("Failed to fetch session details:", error);
addNotification('error', 'Backend Not Reachable', 'Unable to load session details. Please check your connection.', 'session-detail-fetch-error');
// Allow retry on network errors
hasInitialFetch.current = false;
}
};
const fetchEvents = async () => {
// Deduplication: if a fetch is already in flight, queue one follow-up instead of
// stacking concurrent requests (SignalR signal + 30s timer + group-join can overlap).
if (fetchEventsInFlight.current) {
fetchEventsQueued.current = true;
return;
}
const effectiveTenantId = resolveEffectiveTenantId();
if (!effectiveTenantId || !isGuid(effectiveTenantId)) {
return;
}
fetchEventsInFlight.current = true;
fetchEventsQueued.current = false;
return hasChanges ? newExpanded : prev;
});
}, [orderedPhases, preProvGrouped.orderedPhases, userEnrollGrouped.orderedPhases, isWhiteGloveSession]);
try {
const response = await authenticatedFetch(
api.sessions.events(sessionId, effectiveTenantId),
getAccessToken
);
if (response.ok) {
const data = await response.json();
// Replace entire event list with fresh data from Table Storage (canonical truth).
// No merge needed — fetchEvents() is the single source; SignalR only signals.
// phaseName is computed at render time in eventsByPhase useMemo (no race condition).
const fetchedEvents = Array.isArray(data.events) ? data.events : [];
setEvents(prevEvents => {
// Keep the last known-good snapshot if backend transiently returns an empty list.
if (fetchedEvents.length === 0 && prevEvents.length > 0) {
console.warn(
`[SessionDetail] Ignoring empty event refresh for session ${sessionIdRef.current} (tenant ${effectiveTenantId}); keeping ${prevEvents.length} cached events`
);
return prevEvents;
}
return fetchedEvents;
});
// Surgical status-stale detection: if events contain a terminal event but session
// status is still InProgress, the SignalR status delta was likely lost. Refetch
// session details from the DB (single read, only when truly needed).
const currentStatus = sessionRef.current?.status;
if (currentStatus && currentStatus !== "Succeeded" && currentStatus !== "Failed") {
const hasTerminalEvent = fetchedEvents.some(
(e: EnrollmentEvent) => e.eventType === "enrollment_complete" || e.eventType === "enrollment_failed"
);
if (hasTerminalEvent) {
console.info(
`[SessionDetail] Terminal event detected but session status is '${currentStatus}' — refetching session details`
);
fetchSessionDetails();
}
}
} else {
addNotification('error', 'Backend Error', `Failed to load session events: ${response.statusText}`, 'session-events-fetch-error');
}
} catch (error) {
if (error instanceof TokenExpiredError) {
addNotification('error', 'Session Expired', error.message, 'session-expired-error');
} else {
console.error("Failed to fetch events:", error);
addNotification('error', 'Backend Not Reachable', 'Unable to load session events. Please check your connection.', 'session-events-fetch-error');
}
} finally {
setLoading(false);
fetchEventsInFlight.current = false;
// If another fetch was requested while we were in flight, run it now
if (fetchEventsQueued.current) {
fetchEventsQueued.current = false;
fetchEvents();
}
}
};
const markAsFailed = () => {
setShowMarkFailedConfirm(true);
};
const markAsSucceeded = () => {
setShowMarkSucceededConfirm(true);
};
const markAsFailed = () => setShowMarkFailedConfirm(true);
const markAsSucceeded = () => setShowMarkSucceededConfirm(true);
const cancelMarkFailed = () => setShowMarkFailedConfirm(false);
const cancelMarkSucceeded = () => setShowMarkSucceededConfirm(false);
const confirmMarkFailed = async () => {
const effectiveTenantId = sessionTenantId || tenantId;
try {
const response = await authenticatedFetch(
api.sessions.markFailed(sessionId, effectiveTenantId),
getAccessToken,
{ method: 'POST' }
);
if (response.ok) {
setShowMarkFailedConfirm(false);
if (session) {
setSession({ ...session, status: 'Failed' });
}
if (session) setSession({ ...session, status: 'Failed' });
} else {
console.error('Failed to mark session as failed');
}
@@ -438,19 +174,15 @@ export default function SessionDetailPage() {
const confirmMarkSucceeded = async () => {
const effectiveTenantId = sessionTenantId || tenantId;
try {
const response = await authenticatedFetch(
api.sessions.markSucceeded(sessionId, effectiveTenantId),
getAccessToken,
{ method: 'POST' }
);
if (response.ok) {
setShowMarkSucceededConfirm(false);
if (session) {
setSession({ ...session, status: 'Succeeded' });
}
if (session) setSession({ ...session, status: 'Succeeded' });
} else {
console.error('Failed to mark session as succeeded');
}
@@ -459,14 +191,6 @@ export default function SessionDetailPage() {
}
};
const cancelMarkFailed = () => {
setShowMarkFailedConfirm(false);
};
const cancelMarkSucceeded = () => {
setShowMarkSucceededConfirm(false);
};
const handleSubmitReport = async (
comment: string, email: string,
screenshotBase64: string | null, screenshotFileName: string | null,
@@ -534,233 +258,12 @@ export default function SessionDetailPage() {
const toggleSeverityFilter = (severity: string) => {
setSeverityFilters(prev => {
const next = new Set(prev);
if (next.has(severity)) {
next.delete(severity);
} else {
next.add(severity);
}
if (next.has(severity)) next.delete(severity);
else next.add(severity);
return next;
});
};
// Filter events by severity for the timeline.
// Trace events are always excluded — they are for backend-side auditing only.
const filteredEvents = useMemo(() =>
events.filter(e => e.severity !== "Trace" && severityFilters.has(e.severity)),
[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]);
// Extract NTP offset from the first ntp_time_check event (if present)
const ntpOffset = useMemo(() => {
const ntpEvent = events.find(e => e.eventType === "ntp_time_check");
if (!ntpEvent?.data?.offsetSeconds) return null;
return {
offsetSeconds: ntpEvent.data.offsetSeconds as number,
ntpServer: ntpEvent.data.ntpServer as string | undefined,
};
}, [events]);
// Extract ConfigMgr co-management detection (if present)
// Only show badge when confidence >= 50 (directory-only is too weak).
// Default to 100 for backward compat with old agent events that lack confidenceScore.
const configMgrDetected = useMemo(() => {
const evt = events.find(e => e.eventType === "configmgr_client_detected");
if (!evt?.data) return null;
const confidence = (evt.data.confidenceScore as number) ?? 100;
if (confidence < 50) return null;
return {
ccmVersion: evt.data.ccmVersion as string | undefined,
ccmServiceState: evt.data.ccmServiceState as string | undefined,
siteCode: evt.data.siteCode as string | undefined,
confidenceScore: confidence,
};
}, [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).
const gatherRulesSucceeded = isGatherRulesSession &&
events.some(e => e.eventType === "gather_rules_collection_completed");
const displayStatus = gatherRulesSucceeded ? "Succeeded" : (session?.status ?? "");
// Calculate enrollment duration from events (first event → enrollment_complete or last event)
// More accurate than session.durationSeconds which is based on registration StartedAt
const enrollmentDurationFromEvents = useMemo(() => {
if (events.length === 0) return null;
const timestamps = events.map(e => new Date(e.timestamp).getTime());
const firstEventTime = Math.min(...timestamps);
const completeEvent = events.find(e => e.eventType === "enrollment_complete");
const endTime = completeEvent
? new Date(completeEvent.timestamp).getTime()
: Math.max(...timestamps);
const durationSec = Math.round((endTime - firstEventTime) / 1000);
if (durationSec < 60) return `${durationSec}s`;
if (durationSec < 3600) return `${Math.floor(durationSec / 60)}m ${durationSec % 60}s`;
return `${Math.floor(durationSec / 3600)}h ${Math.floor((durationSec % 3600) / 60)}m`;
}, [events]);
const phaseNamesMap = session?.enrollmentType === "v2" ? V2_PHASE_NAMES : V1_PHASE_NAMES;
const phaseOrder = session?.enrollmentType === "v2" ? V2_PHASE_ORDER : V1_PHASE_ORDER;
// Detect SkipUserStatusPage from esp_config_detected event
const isSkipUserStatusPage = useMemo(() => {
if (session?.enrollmentType === "v2") return false;
const espConfigEvent = events.find(e => e.eventType === "esp_config_detected");
if (!espConfigEvent?.data) return false;
const val = espConfigEvent.data.skipUserStatusPage;
return val === true || val === "True" || val === "true";
}, [events, session?.enrollmentType]);
// Detect WhiteGlove session and find the split point
const isWhiteGloveSession = session?.isPreProvisioned === true ||
events.some(e => e.eventType === "whiteglove_complete");
const whiteGloveSplitSequence = useMemo(() => {
if (!isWhiteGloveSession) return -1;
const wgEvent = events.find(e => e.eventType === "whiteglove_complete");
const resumedEvent = events.find(e => e.eventType === "whiteglove_resumed");
// whiteglove_resumed is the definitive Part 2 marker (emitted at Part 2 agent startup).
// This handles both normal ordering and the race condition where Windows writes the
// whiteglove_complete event (Event 62407) after the Part 2 reboot.
if (resumedEvent) {
return resumedEvent.sequence - 1;
}
// Fallback for old agents without whiteglove_resumed:
// first agent_started AFTER whiteglove_complete = Part 2 start.
// NOTE: We do NOT use the "last agent_started before whiteglove_complete" heuristic
// because reboots within Part 1 would be misidentified as Part 2.
if (wgEvent) {
const nextStart = events.find(e =>
e.eventType === "agent_started" && e.sequence > wgEvent.sequence
);
if (nextStart) return nextStart.sequence - 1;
// Pre-provisioning only (no Part 2 yet). Include cleanup events
// (local_admin_analysis, agent_shutdown) that follow whiteglove_complete.
const shutdownAfterWg = events.find(e =>
e.eventType === "agent_shutdown" && e.sequence > wgEvent.sequence
);
return shutdownAfterWg?.sequence ?? wgEvent.sequence;
}
return -1;
}, [events, isWhiteGloveSession]);
// For WhiteGlove sessions: split filtered events into pre-provisioning and user-enrollment parts.
// Events are assigned purely by sequence number — no special-casing for whiteglove_complete.
// In the race-condition case (Windows writes the WhiteGlove success event after the reboot),
// whiteglove_complete naturally lands in the user-enrollment part, preserving chronological order.
const preProvEvents = useMemo(() => {
if (!isWhiteGloveSession || whiteGloveSplitSequence < 0) return [] as EnrollmentEvent[];
return filteredEvents.filter(e => e.sequence <= whiteGloveSplitSequence);
}, [filteredEvents, isWhiteGloveSession, whiteGloveSplitSequence]);
const userEnrollEvents = useMemo(() => {
if (!isWhiteGloveSession || whiteGloveSplitSequence < 0) return [] as EnrollmentEvent[];
return filteredEvents.filter(e => e.sequence > whiteGloveSplitSequence);
}, [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);
const userEnrollEvts = events.filter(e => e.sequence > whiteGloveSplitSequence);
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[] };
return groupEventsByPhase(filteredEvents, phaseNamesMap, phaseOrder, { preventPhaseRegression: true });
}, [filteredEvents, isWhiteGloveSession, phaseNamesMap, phaseOrder]);
const preProvGrouped = useMemo(() =>
isWhiteGloveSession && preProvEvents.length > 0
? groupEventsByPhase(preProvEvents, phaseNamesMap, phaseOrder, { preventPhaseRegression: true })
: { eventsByPhase: {} as Record<string, EnrollmentEvent[]>, orderedPhases: [] as string[] },
[preProvEvents, isWhiteGloveSession, phaseNamesMap, phaseOrder]
);
const userEnrollGrouped = useMemo(() =>
isWhiteGloveSession && userEnrollEvents.length > 0
? groupEventsByPhase(userEnrollEvents, phaseNamesMap, phaseOrder, { preventPhaseRegression: true })
: { eventsByPhase: {} as Record<string, EnrollmentEvent[]>, orderedPhases: [] as string[] },
[userEnrollEvents, isWhiteGloveSession, phaseNamesMap, phaseOrder]
);
const [expandedPhases, setExpandedPhases] = useState<Set<string>>(new Set());
// Auto-expand new phases as they appear (keeps existing expanded/collapsed state).
// For WhiteGlove sessions we use prefixed keys (pre-X, user-X) to avoid collisions.
useEffect(() => {
setExpandedPhases(prev => {
const newExpanded = new Set(prev);
let hasChanges = false;
const allPhases = isWhiteGloveSession
? [
...preProvGrouped.orderedPhases.map(p => `pre-${p}`),
...userEnrollGrouped.orderedPhases.map(p => `user-${p}`),
]
: orderedPhases;
for (const phase of allPhases) {
if (!prev.has(phase)) {
newExpanded.add(phase);
hasChanges = true;
}
}
return hasChanges ? newExpanded : prev;
});
}, [orderedPhases, preProvGrouped.orderedPhases, userEnrollGrouped.orderedPhases, isWhiteGloveSession]);
const expandAll = () => {
if (isWhiteGloveSession) {
setExpandedPhases(new Set([
@@ -772,19 +275,14 @@ export default function SessionDetailPage() {
}
};
const collapseAll = () => {
setExpandedPhases(new Set());
};
const collapseAll = () => setExpandedPhases(new Set());
const scrollToPhase = (phaseName: string) => {
const id = `phase-${phaseName.replace(/[^a-zA-Z0-9]/g, '-')}`;
// Try both WhiteGlove prefixed and plain ids
const el = document.getElementById(id);
if (el) {
// Expand the phase section if collapsed
setExpandedPhases(prev => {
const newExpanded = new Set(prev);
// For WhiteGlove sessions, the expandedPhases key may be prefixed
if (isWhiteGloveSession) {
newExpanded.add(`pre-${phaseName}`);
newExpanded.add(`user-${phaseName}`);
@@ -793,7 +291,6 @@ export default function SessionDetailPage() {
}
return newExpanded;
});
// Scroll after a tick so the section is expanded
setTimeout(() => {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 50);
@@ -815,18 +312,14 @@ export default function SessionDetailPage() {
setTimeout(() => el.classList.remove("ring-2", "ring-amber-400", "ring-offset-1"), 3000);
}
}, 100);
// Clear hash so it doesn't re-trigger
window.history.replaceState(null, "", window.location.pathname + window.location.search);
}, [loading, events.length]); // eslint-disable-line react-hooks/exhaustive-deps
const togglePhase = (phaseName: string) => {
setExpandedPhases(prev => {
const newExpanded = new Set(prev);
if (newExpanded.has(phaseName)) {
newExpanded.delete(phaseName);
} else {
newExpanded.add(phaseName);
}
if (newExpanded.has(phaseName)) newExpanded.delete(phaseName);
else newExpanded.add(phaseName);
return newExpanded;
});
};
@@ -1129,4 +622,3 @@ export default function SessionDetailPage() {
</ProtectedRoute>
);
}