diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/GetSessionFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/GetSessionFunction.cs new file mode 100644 index 00000000..405ea522 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/GetSessionFunction.cs @@ -0,0 +1,126 @@ +using System.Net; +using System.Web; +using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Functions.Services; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Functions +{ + public class GetSessionFunction + { + private readonly ILogger _logger; + private readonly TableStorageService _storageService; + private readonly GalacticAdminService _galacticAdminService; + + public GetSessionFunction( + ILogger logger, + TableStorageService storageService, + GalacticAdminService galacticAdminService) + { + _logger = logger; + _storageService = storageService; + _galacticAdminService = galacticAdminService; + } + + [Function("GetSession")] + public async Task Run( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "sessions/{sessionId}")] HttpRequestData req, + string sessionId) + { + if (string.IsNullOrWhiteSpace(sessionId)) + { + var badRequest = req.CreateResponse(HttpStatusCode.BadRequest); + await badRequest.WriteAsJsonAsync(new + { + success = false, + message = "SessionId is required" + }); + return badRequest; + } + + var sessionPrefix = $"[Session: {sessionId.Substring(0, Math.Min(8, sessionId.Length))}]"; + + try + { + if (!TenantHelper.IsAuthenticated(req)) + { + _logger.LogWarning($"{sessionPrefix} Unauthenticated GetSession attempt"); + var unauthorizedResponse = req.CreateResponse(HttpStatusCode.Unauthorized); + await unauthorizedResponse.WriteAsJsonAsync(new + { + success = false, + message = "Authentication required. Please provide a valid JWT token." + }); + return unauthorizedResponse; + } + + var userTenantId = TenantHelper.GetTenantId(req); + var userIdentifier = TenantHelper.GetUserIdentifier(req); + var query = HttpUtility.ParseQueryString(req.Url.Query); + var requestedTenantId = query["tenantId"]; + var effectiveTenantId = string.IsNullOrWhiteSpace(requestedTenantId) ? userTenantId : requestedTenantId; + + if (string.IsNullOrWhiteSpace(effectiveTenantId)) + { + var badRequest = req.CreateResponse(HttpStatusCode.BadRequest); + await badRequest.WriteAsJsonAsync(new + { + success = false, + message = "Unable to resolve tenantId." + }); + return badRequest; + } + + if (!string.Equals(effectiveTenantId, userTenantId, StringComparison.OrdinalIgnoreCase)) + { + var isGalacticAdmin = await _galacticAdminService.IsGalacticAdminAsync(userIdentifier); + if (!isGalacticAdmin) + { + _logger.LogWarning($"{sessionPrefix} User {userIdentifier} (tenant {userTenantId}) attempted to access session in tenant {effectiveTenantId}"); + var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden); + await forbiddenResponse.WriteAsJsonAsync(new + { + success = false, + message = "Access denied. You can only view sessions for your own tenant." + }); + return forbiddenResponse; + } + } + + var session = await _storageService.GetSessionAsync(effectiveTenantId, sessionId); + if (session == null) + { + var notFound = req.CreateResponse(HttpStatusCode.NotFound); + await notFound.WriteAsJsonAsync(new + { + success = false, + message = "Session not found", + sessionId + }); + return notFound; + } + + var response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteAsJsonAsync(new + { + success = true, + session + }); + return response; + } + catch (Exception ex) + { + _logger.LogError(ex, $"{sessionPrefix} Error getting session"); + var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError); + await errorResponse.WriteAsJsonAsync(new + { + success = false, + message = "Internal server error" + }); + return errorResponse; + } + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/GitHubRuleRepository.cs b/src/Backend/AutopilotMonitor.Functions/Services/GitHubRuleRepository.cs index 999843f0..19908abd 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/GitHubRuleRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/GitHubRuleRepository.cs @@ -20,7 +20,7 @@ namespace AutopilotMonitor.Functions.Services _httpClient = httpClient; _logger = logger; _baseUrl = config["GitHub:RulesBaseUrl"] - ?? "https://raw.githubusercontent.com/OliverKieselbach/Autopilot-Monitor/main/rules"; + ?? "https://raw.githubusercontent.com/okieselbach/Autopilot-Monitor/refs/heads/main/rules"; } public async Task> FetchGatherRulesAsync() 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 55f93697..3ac29b9d 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx @@ -83,6 +83,7 @@ const V2_PHASE_ORDER = ["Start", "Device Preparation", "App Installation", "Fina // Lookup by phase number — Phase 3 has different names per enrollment type const V1_PHASE_NAMES: Record = { [-1]: "Unknown", 0: "Start", 1: "Device Preparation", 2: "Device Setup", 3: "Apps (Device)", 4: "Account Setup", 5: "Apps (User)", 6: "Finalizing Setup", 7: "Complete", 99: "Failed" }; const V2_PHASE_NAMES: Record = { [-1]: "Unknown", 0: "Start", 1: "Device Preparation", 2: "Device Setup", 3: "App Installation", 4: "Account Setup", 5: "Apps (User)", 6: "Finalizing Setup", 7: "Complete", 99: "Failed" }; +const GUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; // Pure helper — groups a flat event list into phase buckets. // Extracted from the useMemo so it can be called multiple times for WhiteGlove split timelines. @@ -140,11 +141,15 @@ export default function SessionDetailPage() { return false; }); - // Use ref for debounce timeout to persist across renders - const refreshTimeoutRef = useRef(null); + // 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 galacticAdminModeRef = useRef(galacticAdminMode); // Track if we've joined groups to prevent duplicate joins const hasJoinedGroups = useRef(false); @@ -159,6 +164,38 @@ export default function SessionDetailPage() { const { getAccessToken, user } = useAuth(); const { addNotification } = useNotifications(); + useEffect(() => { + sessionRef.current = session; + }, [session]); + + useEffect(() => { + tenantIdRef.current = tenantId; + }, [tenantId]); + + useEffect(() => { + sessionTenantIdRef.current = sessionTenantId; + }, [sessionTenantId]); + + useEffect(() => { + galacticAdminModeRef.current = galacticAdminMode; + }, [galacticAdminMode]); + + const resolveEffectiveTenantId = () => { + const knownSessionTenant = sessionTenantIdRef.current || sessionRef.current?.tenantId || null; + if (knownSessionTenant) return knownSessionTenant; + if (galacticAdminModeRef.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). @@ -196,8 +233,7 @@ export default function SessionDetailPage() { // Uses "subscribe-then-fetch" pattern: join groups first, then re-fetch events // to catch anything that arrived before the group join completed. useEffect(() => { - // Use the session's tenant ID if available - const effectiveTenantId = sessionTenantId || tenantId; + const effectiveTenantId = resolveEffectiveTenantId(); if (!sessionId || !isConnected || !effectiveTenantId) return; if (!hasJoinedGroups.current) { @@ -213,7 +249,7 @@ export default function SessionDetailPage() { // 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. - fetchEvents(); + scheduleFetchEvents(0); }; joinAndCatchUp(); } @@ -230,7 +266,7 @@ export default function SessionDetailPage() { } }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [sessionId, isConnected, sessionTenantId, tenantId]); + }, [sessionId, isConnected, sessionTenantId, tenantId, session?.tenantId, galacticAdminMode]); // Setup SignalR listener - re-register when connection changes useEffect(() => { @@ -240,66 +276,46 @@ export default function SessionDetailPage() { console.log('Event stream signal received via SignalR:', data); if (data.sessionId !== sessionIdRef.current) return; - // Fetch full events from storage (single source of truth) - fetchEvents(); + // Fetch full events from storage (single source of truth), but debounce bursts. + scheduleFetchEvents(); // Session update from SignalR (no extra fetch needed) if (data.session) { setSession(data.session); + if (data.session.tenantId) { + setSessionTenantId(prev => prev || data.session!.tenantId); + } } // Rule results from SignalR (only on enrollment completion) if (data.newRuleResults && data.newRuleResults.length > 0) { - setAnalysisResults(prev => { - const existingIds = new Set(prev.map(r => r.ruleId)); - const newResults = data.newRuleResults!.filter(r => !existingIds.has(r.ruleId)); - return [...prev, ...newResults].sort((a, b) => b.confidenceScore - a.confidenceScore); - }); - } - }; - - // Also listen for summary updates to update session details - const handleNewEvents = (data: { sessionId: string; tenantId: string; eventCount: number; sessionUpdate?: Partial; session?: Session }) => { - if (data.sessionId === sessionIdRef.current) { - // Merge delta update into existing session (no HTTP request needed!) - const update = data.sessionUpdate || data.session; - if (update) { - setSession(prev => prev ? { ...prev, ...update } : prev); - } + fetchAnalysisResults(); } }; on('eventStream', handleEventStream); - on('newevents', handleNewEvents); return () => { off('eventStream', handleEventStream); - off('newevents', handleNewEvents); - if (refreshTimeoutRef.current) { - clearTimeout(refreshTimeoutRef.current); + if (eventRefreshTimeoutRef.current) { + clearTimeout(eventRefreshTimeoutRef.current); } }; }, [on, off]); // Re-register when SignalR connection changes - // Periodic catch-up fetch — fills any SignalR gaps every 30s when tab is visible + // Fallback polling only while SignalR is disconnected. useEffect(() => { - const effectiveTenantId = sessionTenantId || tenantId; - if (!sessionId || !effectiveTenantId) return; + 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]); + }, [sessionId, sessionTenantId, tenantId, session?.tenantId, galacticAdminMode, isConnected]); const fetchSessionDetails = async () => { try { - // In galactic admin mode, fetch from the galactic endpoint (cross-tenant) - // Otherwise, use the current tenant's sessions - const endpoint = galacticAdminMode - ? `${API_BASE_URL}/api/galactic/sessions` - : `${API_BASE_URL}/api/sessions?tenantId=${tenantId}`; - // Get access token and include Authorization header const token = await getAccessToken(); @@ -309,6 +325,14 @@ export default function SessionDetailPage() { hasInitialFetch.current = false; return; } + + const knownTenantId = resolveEffectiveTenantId(); + const endpoint = knownTenantId + ? `${API_BASE_URL}/api/sessions/${sessionId}?tenantId=${knownTenantId}` + : galacticAdminMode + ? `${API_BASE_URL}/api/galactic/sessions` + : `${API_BASE_URL}/api/sessions/${sessionId}`; + const response = await fetch(endpoint, { headers: { 'Authorization': `Bearer ${token}` @@ -316,7 +340,7 @@ export default function SessionDetailPage() { }); if (response.ok) { const data = await response.json(); - const foundSession = data.sessions?.find((s: Session) => s.sessionId === sessionId); + 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 @@ -340,12 +364,13 @@ export default function SessionDetailPage() { fetchEventsQueued.current = true; return; } + const effectiveTenantId = resolveEffectiveTenantId(); + if (!effectiveTenantId || !GUID_REGEX.test(effectiveTenantId)) { + return; + } fetchEventsInFlight.current = true; fetchEventsQueued.current = false; - // Use the session's tenant ID if available, otherwise fall back to current user's tenant ID - const effectiveTenantId = sessionTenantId || tenantId; - try { // Get access token and include Authorization header const token = await getAccessToken(); @@ -365,7 +390,17 @@ export default function SessionDetailPage() { // 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). - setEvents(data.events ?? []); + 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; + }); } else { addNotification('error', 'Backend Error', `Failed to load session events: ${response.statusText}`, 'session-events-fetch-error'); } @@ -385,7 +420,8 @@ export default function SessionDetailPage() { }; const fetchAnalysisResults = async (reanalyze = false) => { - const effectiveTenantId = sessionTenantId || tenantId; + const effectiveTenantId = resolveEffectiveTenantId(); + if (!effectiveTenantId || !GUID_REGEX.test(effectiveTenantId)) return; try { setLoadingAnalysis(true); const token = await getAccessToken();