fix(web): stabilize dashboard snapshot hydration
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user