fix SessionDetailsPage flickering

This commit is contained in:
Oliver Kieselbach
2026-02-28 16:48:25 +01:00
parent 65860d7e9b
commit 35f7f59520
3 changed files with 207 additions and 45 deletions
@@ -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<GetSessionFunction> _logger;
private readonly TableStorageService _storageService;
private readonly GalacticAdminService _galacticAdminService;
public GetSessionFunction(
ILogger<GetSessionFunction> logger,
TableStorageService storageService,
GalacticAdminService galacticAdminService)
{
_logger = logger;
_storageService = storageService;
_galacticAdminService = galacticAdminService;
}
[Function("GetSession")]
public async Task<HttpResponseData> 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;
}
}
}
}
@@ -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<List<GatherRule>> FetchGatherRulesAsync()
@@ -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<number, string> = { [-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<number, string> = { [-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<NodeJS.Timeout | null>(null);
// 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 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?: 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();