fix(web): stabilize dashboard snapshot hydration
All checks were successful
Code Review / ai-code-review (push) Successful in 14s
CD Pipeline / tests (push) Successful in 4m9s
CD Pipeline / build-and-deploy (push) Successful in 4m21s
CD Pipeline / post-deploy-checks (push) Successful in 2m23s

This commit is contained in:
Your Name
2026-05-21 15:28:21 +08:00
parent efc454a346
commit b63c829f9a

View File

@@ -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<void> | 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<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async function fetchDashboardSnapshot(snapshotUrl: string): Promise<DashboardSnapshot> {
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<DashboardState>()(
subscribeWithSelector((set, get) => ({
@@ -312,6 +348,10 @@ export const useDashboardStore = create<DashboardState>()(
},
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<DashboardState>()(
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
}
}
},