From 95949abdd32b0e1017ef56a945bac04605367e42 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Tue, 14 Apr 2026 12:09:53 +0200 Subject: [PATCH] refactor web structure (session details page uses hooks) --- .claude/commands | 2 +- .../hooks/useSessionDerivedData.ts | 269 +++++++ .../[sessionId]/hooks/useSessionDetail.ts | 161 ++++ .../[sessionId]/hooks/useSessionEvents.ts | 179 +++++ .../[sessionId]/hooks/useSessionSignalR.ts | 155 ++++ .../hooks/useSessionTenantConfig.ts | 41 + .../app/sessions/[sessionId]/page.tsx | 704 +++--------------- 7 files changed, 904 insertions(+), 607 deletions(-) create mode 100644 src/Web/autopilot-monitor-web/app/sessions/[sessionId]/hooks/useSessionDerivedData.ts create mode 100644 src/Web/autopilot-monitor-web/app/sessions/[sessionId]/hooks/useSessionDetail.ts create mode 100644 src/Web/autopilot-monitor-web/app/sessions/[sessionId]/hooks/useSessionEvents.ts create mode 100644 src/Web/autopilot-monitor-web/app/sessions/[sessionId]/hooks/useSessionSignalR.ts create mode 100644 src/Web/autopilot-monitor-web/app/sessions/[sessionId]/hooks/useSessionTenantConfig.ts diff --git a/.claude/commands b/.claude/commands index bf64e13e..9a799daf 160000 --- a/.claude/commands +++ b/.claude/commands @@ -1 +1 @@ -Subproject commit bf64e13e7dc8d0b4b9ff85e9ff6b90b8a2b720bb +Subproject commit 9a799daf666d209a2c68498d88a650a450b875ae diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/hooks/useSessionDerivedData.ts b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/hooks/useSessionDerivedData.ts new file mode 100644 index 00000000..ba6822c3 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/hooks/useSessionDerivedData.ts @@ -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; + 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; + phaseOrder: string[]; + isSkipUserStatusPage: boolean; + isWhiteGloveSession: boolean; + whiteGloveSplitSequence: number; + preProvEvents: EnrollmentEvent[]; + userEnrollEvents: EnrollmentEvent[]; + whiteGloveDurations: { + preProvDuration: string | null; + userEnrollDuration: string | null; + combinedDuration: string | null; + }; + eventsByPhase: Record; + 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, +): 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, 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, orderedPhases: [] as string[] }, + [preProvEvents, isWhiteGloveSession, phaseNamesMap, phaseOrder] + ); + + const userEnrollGrouped = useMemo(() => + isWhiteGloveSession && userEnrollEvents.length > 0 + ? groupEventsByPhase(userEnrollEvents, phaseNamesMap, phaseOrder, { preventPhaseRegression: true }) + : { eventsByPhase: {} as Record, 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, + }; +} diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/hooks/useSessionDetail.ts b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/hooks/useSessionDetail.ts new file mode 100644 index 00000000..8bedc764 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/hooks/useSessionDetail.ts @@ -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; + addNotification: AddNotification; +} + +export interface UseSessionDetailReturn { + session: Session | null; + setSession: React.Dispatch>; + sessionTenantId: string | null; + setSessionTenantId: React.Dispatch>; + loading: boolean; + setLoading: React.Dispatch>; + fetchSessionDetails: () => Promise; + resolveEffectiveTenantId: () => string | null; + sessionRef: React.MutableRefObject; + sessionIdRef: React.MutableRefObject; +} + +/** + * 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(null); + const [sessionTenantId, setSessionTenantId] = useState(null); + const [loading, setLoading] = useState(true); + + const sessionRef = useRef(null); + const sessionIdRef = useRef(sessionId); + const tenantIdRef = useRef(tenantId); + const sessionTenantIdRef = useRef(sessionTenantId); + const globalAdminModeRef = useRef(globalAdminMode); + const hasInitialFetch = useRef(false); + const lastFetchedSessionId = useRef(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, + }; +} diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/hooks/useSessionEvents.ts b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/hooks/useSessionEvents.ts new file mode 100644 index 00000000..9e187ced --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/hooks/useSessionEvents.ts @@ -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; + sessionIdRef: React.MutableRefObject; + fetchSessionDetails: () => Promise; + setLoading: React.Dispatch>; + isConnected: boolean; + getAccessToken: (forceRefresh?: boolean) => Promise; + addNotification: AddNotification; +} + +export interface UseSessionEventsReturn { + events: EnrollmentEvent[]; + setEvents: React.Dispatch>; + fetchEvents: () => Promise; + 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([]); + + // Debounce real-time event refreshes to avoid burst reads in Table Storage. + const eventRefreshTimeoutRef = useRef(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 }; +} diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/hooks/useSessionSignalR.ts b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/hooks/useSessionSignalR.ts new file mode 100644 index 00000000..33683493 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/hooks/useSessionSignalR.ts @@ -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; + leaveGroup: (group: string) => Promise; +} + +interface UseSessionSignalRParams { + sessionId: string; + sessionTenantId: string | null; + tenantId: string; + sessionTenantIdFromSession: string | undefined; + globalAdminMode: boolean; + sessionIdRef: React.MutableRefObject; + resolveEffectiveTenantId: () => string | null; + signalR: SignalRApi; + scheduleFetchEvents: (delayMs?: number) => void; + setSession: React.Dispatch>; + setSessionTenantId: React.Dispatch>; + fetchAnalysisResults: (reanalyze?: boolean) => Promise; + fetchVulnerabilityReport: (rescan?: boolean) => Promise; +} + +/** + * 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 }) => { + 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]); +} diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/hooks/useSessionTenantConfig.ts b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/hooks/useSessionTenantConfig.ts new file mode 100644 index 00000000..e9719155 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/hooks/useSessionTenantConfig.ts @@ -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, +): 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 }; +} diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx index 5bc363c6..29e9e837 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx @@ -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([]); - const [session, setSession] = useState(null); - const [sessionTenantId, setSessionTenantId] = useState(null); - const [loading, setLoading] = useState(true); + // UI-only state (modals, expand/collapse, severity filters) const [severityFilters, setSeverityFilters] = useState>(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>(new Set()); - // Debounce real-time event refreshes to avoid burst reads in Table Storage. - const eventRefreshTimeoutRef = useRef(null); - const sessionIdRef = useRef(sessionId); - const sessionRef = useRef(null); - const hasInitialFetch = useRef(false); - const lastFetchedSessionId = useRef(null); - const tenantIdRef = useRef(""); - const sessionTenantIdRef = useRef(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 }) => { - 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, 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, orderedPhases: [] as string[] }, - [preProvEvents, isWhiteGloveSession, phaseNamesMap, phaseOrder] - ); - - const userEnrollGrouped = useMemo(() => - isWhiteGloveSession && userEnrollEvents.length > 0 - ? groupEventsByPhase(userEnrollEvents, phaseNamesMap, phaseOrder, { preventPhaseRegression: true }) - : { eventsByPhase: {} as Record, orderedPhases: [] as string[] }, - [userEnrollEvents, isWhiteGloveSession, phaseNamesMap, phaseOrder] - ); - - const [expandedPhases, setExpandedPhases] = useState>(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() { ); } -