From b63c829f9aaf3e805eddbc89e5bb30226fba6be6 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 21 May 2026 15:28:21 +0800 Subject: [PATCH] fix(web): stabilize dashboard snapshot hydration --- apps/web/src/stores/dashboard.store.ts | 91 ++++++++++++++++++++++---- 1 file changed, 80 insertions(+), 11 deletions(-) diff --git a/apps/web/src/stores/dashboard.store.ts b/apps/web/src/stores/dashboard.store.ts index f78053a2..74abe533 100644 --- a/apps/web/src/stores/dashboard.store.ts +++ b/apps/web/src/stores/dashboard.store.ts @@ -120,6 +120,9 @@ const MAX_RECONNECT_ATTEMPTS = 10 const BASE_RECONNECT_DELAY = 1000 // 1 second const MAX_RECONNECT_DELAY = 30000 // 30 seconds const HEARTBEAT_TIMEOUT = 45000 // 45 seconds +const SNAPSHOT_FETCH_TIMEOUT = 8000 // 8 seconds +const SNAPSHOT_FETCH_RETRY_DELAY = 750 // 0.75 second +const SNAPSHOT_FETCH_ATTEMPTS = 2 // ============================================================================= // Store Implementation @@ -129,6 +132,39 @@ const HEARTBEAT_TIMEOUT = 45000 // 45 seconds let eventSource: EventSource | null = null let reconnectTimeout: NodeJS.Timeout | null = null let heartbeatTimeout: NodeJS.Timeout | null = null +let snapshotFetchInFlight: Promise | null = null + +function normalizeApiUrl(apiBaseUrl: string): string { + return apiBaseUrl.replace(/\/+$/, '') +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +async function fetchDashboardSnapshot(snapshotUrl: string): Promise { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), SNAPSHOT_FETCH_TIMEOUT) + + try { + const response = await fetch(snapshotUrl, { + cache: 'no-store', + signal: controller.signal, + }) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`) + } + + return await response.json() as DashboardSnapshot + } finally { + clearTimeout(timeout) + } +} export const useDashboardStore = create()( subscribeWithSelector((set, get) => ({ @@ -312,6 +348,10 @@ export const useDashboardStore = create()( }, fetchSnapshot: async (apiBaseUrl: string) => { + if (snapshotFetchInFlight) { + return snapshotFetchInFlight + } + // 統帥鐵律: 禁止任何 Fallback IP const resolvedApiBaseUrl = apiBaseUrl || (typeof window !== 'undefined' ? process.env.NEXT_PUBLIC_API_URL : '') @@ -321,20 +361,49 @@ export const useDashboardStore = create()( return } - try { - console.log('[SSE] Fetching snapshot for hydration from:', resolvedApiBaseUrl) - const response = await fetch(`${resolvedApiBaseUrl}/api/v1/dashboard/snapshot`) + const snapshotUrl = `${normalizeApiUrl(resolvedApiBaseUrl)}/api/v1/dashboard/snapshot` - if (!response.ok) { - throw new Error(`HTTP ${response.status}`) + const run = (async () => { + let lastError: unknown = null + + for (let attempt = 1; attempt <= SNAPSHOT_FETCH_ATTEMPTS; attempt += 1) { + try { + console.log('[SSE] Fetching snapshot for hydration from:', resolvedApiBaseUrl) + const snapshot = await fetchDashboardSnapshot(snapshotUrl) + get().applySnapshot(snapshot) + set({ error: null }) + console.log('[SSE] Snapshot applied, hosts:', snapshot.hosts.length) + return + } catch (err) { + lastError = err + if (attempt < SNAPSHOT_FETCH_ATTEMPTS) { + console.warn( + `[SSE] Snapshot fetch attempt ${attempt} failed; retrying...`, + errorMessage(err) + ) + await wait(SNAPSHOT_FETCH_RETRY_DELAY) + } + } } - const snapshot: DashboardSnapshot = await response.json() - get().applySnapshot(snapshot) - console.log('[SSE] Snapshot applied, hosts:', snapshot.hosts.length) - } catch (err) { - console.error('[SSE] Failed to fetch snapshot:', err) - set({ error: `Snapshot fetch failed: ${err}` }) + const message = `Snapshot unavailable after ${SNAPSHOT_FETCH_ATTEMPTS} attempts: ${errorMessage(lastError)}` + if (get().snapshot) { + console.warn('[SSE] Snapshot refresh unavailable; retaining last snapshot:', errorMessage(lastError)) + set({ error: null }) + return + } + + console.error('[SSE] Snapshot unavailable:', errorMessage(lastError)) + set({ error: message }) + })() + + snapshotFetchInFlight = run + try { + await run + } finally { + if (snapshotFetchInFlight === run) { + snapshotFetchInFlight = null + } } },