diff --git a/apps/web/src/components/dashboard/flywheel-kpi-card.tsx b/apps/web/src/components/dashboard/flywheel-kpi-card.tsx index 21029834..c45ac7d2 100644 --- a/apps/web/src/components/dashboard/flywheel-kpi-card.tsx +++ b/apps/web/src/components/dashboard/flywheel-kpi-card.tsx @@ -5,7 +5,7 @@ * * 飛輪健康度 KPI 面板。 * C2: 初始載入 GET /api/v1/stats/summary(HTTP fallback) - * C3: WebSocket /api/v1/stats/flywheel/ws 即時推送(10s 更新) + * C3: WebSocket /api/v1/stats/flywheel/ws 即時推送(旗標開啟時) * * 2026-04-12 ogt (ADR-073-C C2 + C3) */ @@ -16,6 +16,7 @@ import { FlywheelDiagram, type FlowItem } from './flywheel-diagram' const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? '' // ws(s):// mirror of NEXT_PUBLIC_API_URL const WS_BASE = API_BASE.replace(/^https/, 'wss').replace(/^http/, 'ws') +const ENABLE_FLYWHEEL_WS = process.env.NEXT_PUBLIC_ENABLE_FLYWHEEL_WS === 'true' interface FlywheelSummary { playbook_count: number @@ -37,23 +38,29 @@ export function FlywheelKPICard() { const [error, setError] = useState(false) const wsRef = useRef(null) - // C2: HTTP fallback (initial load + 30s poll when WS unavailable) + // C2: HTTP fallback (initial load + 30s poll) useEffect(() => { let cancelled = false let pollId: ReturnType | null = null - const load = () => { + const loadSummary = () => { fetch(`${API_BASE}/api/v1/stats/summary`) .then(r => r.ok ? r.json() : Promise.reject(r.status)) .then(d => { if (!cancelled) { setData(d); setError(false) } }) .catch(() => { if (!cancelled) setError(true) }) } - // 載入飛輪節點狀態(C4 用) - fetch(`${API_BASE}/api/v1/stats/flywheel`) - .then(r => r.ok ? r.json() : null) - .then(d => { if (!cancelled && d) setFlowData(d) }) - .catch(() => {}) + const loadFlow = () => { + fetch(`${API_BASE}/api/v1/stats/flywheel`) + .then(r => r.ok ? r.json() : null) + .then(d => { if (!cancelled && d) setFlowData(d) }) + .catch(() => {}) + } + + const load = () => { + loadSummary() + loadFlow() + } load() @@ -63,7 +70,7 @@ export function FlywheelKPICard() { let wsRetryTimer: ReturnType | null = null const connectWS = () => { - if (!WS_BASE || cancelled) return + if (!ENABLE_FLYWHEEL_WS || !WS_BASE || cancelled) return const ws = new WebSocket(`${WS_BASE}/api/v1/stats/flywheel/ws`) wsRef.current = ws @@ -94,7 +101,7 @@ export function FlywheelKPICard() { } connectWS() - // Also start polling as backup until WS opens + // Production default: HTTP polling stays authoritative unless WS is enabled explicitly. pollId = setInterval(load, 30_000) return () => { diff --git a/apps/web/src/components/incident/flow-pipeline.tsx b/apps/web/src/components/incident/flow-pipeline.tsx index 978a3d6b..82e31349 100644 --- a/apps/web/src/components/incident/flow-pipeline.tsx +++ b/apps/web/src/components/incident/flow-pipeline.tsx @@ -378,13 +378,22 @@ export function FlowPipeline({ activeStage, isResolved = false, severity = 'P3', // 2026-04-02 Claude Code: severity → pipeline style mapping // P0=StyleA(脈衝光波) P1=StyleB(進度條) P2=StyleC(卡片步驟) P3=StyleD(時間軸) // isResolved 傳入各 Style 自行處理顏色,保留嚴重度視覺識別 + const renderedStage = isResolved ? 'resolved' : activeStage return ( <> - {severity === 'P0' && } - {severity === 'P1' && } - {severity === 'P2' && } - {(severity === 'P3' || !['P0','P1','P2'].includes(severity)) && } +
+ {severity === 'P0' && } + {severity === 'P1' && } + {severity === 'P2' && } + {(severity === 'P3' || !['P0','P1','P2'].includes(severity)) && } +
) } diff --git a/apps/web/src/components/incident/incident-card.tsx b/apps/web/src/components/incident/incident-card.tsx index 809ba373..b4cc3f39 100644 --- a/apps/web/src/components/incident/incident-card.tsx +++ b/apps/web/src/components/incident/incident-card.tsx @@ -46,18 +46,24 @@ const SEV_CONFIG = { P3: { barColor: '#22C55E', label: 'P3', labelBg: 'rgba(34,197,94,0.1)', labelColor: '#16a34a' }, } as const -/** 根據 incident status 對應 FlowStage */ -function toFlowStage(status: string, severity: string): FlowStage { - switch (status) { - case 'new': return 'alert' - case 'investigating': return 'detection' - case 'analyzing': return 'analysis' - case 'proposal_generated': return 'proposal' - case 'waiting_approval': return 'approval' - case 'executing': return 'execution' - case 'resolved': return 'resolved' - default: return severity === 'P0' ? 'alert' : 'detection' - } +/** 根據 incident + decision evidence 對應 FlowStage */ +function toFlowStage(status: string, severity: string, decision?: DecisionInfo | null): FlowStage { + const normalizedStatus = status.toLowerCase() + const decisionState = decision?.state + + if (['resolved', 'closed', 'completed', 'success'].includes(normalizedStatus)) return 'resolved' + if (decisionState === 'completed') return 'execution' + if (['executing', 'mitigating'].includes(normalizedStatus) || decisionState === 'executing') return 'execution' + if ( + ['waiting_approval', 'pending_approval', 'approval_pending'].includes(normalizedStatus) || + decisionState === 'ready' || + !!decision?.proposal_id + ) return 'approval' + if (['proposal_generated', 'proposed'].includes(normalizedStatus) || !!decision?.proposal_data?.action) return 'proposal' + if (['analyzing', 'analysis'].includes(normalizedStatus) || decisionState === 'analyzing') return 'analysis' + if (['new', 'open'].includes(normalizedStatus)) return 'alert' + + return severity === 'P0' ? 'alert' : 'detection' } /** 格式化持續時間 */ @@ -209,13 +215,13 @@ export function IncidentCard({ incident, decision, onApprovalChange }: IncidentC const incidentStatus = incident.status as string const sev = incident.severity as keyof typeof SEV_CONFIG const sevCfg = SEV_CONFIG[sev] ?? SEV_CONFIG.P3 - const flowStage = toFlowStage(incidentStatus, incident.severity) - const isResolved = incidentStatus === 'resolved' - const isWaitingApproval = incidentStatus === 'waiting_approval' + const flowStage = toFlowStage(incidentStatus, incident.severity, decision) + const isResolved = incidentStatus === 'resolved' || incidentStatus === 'closed' const serviceName = incident.affected_services?.[0] ?? '--' const duration = formatDuration(incident.created_at) const isDecisionReady = decision?.state === 'ready' || !!currentProposalId + const isWaitingApproval = incidentStatus === 'waiting_approval' || isDecisionReady const isAnalyzing = decision?.state === 'analyzing' const decisionAction = decision?.proposal_data?.action ?? '' const decisionReasoning = decision?.proposal_data?.reasoning ?? '' diff --git a/apps/web/src/components/layout/page-tabs.tsx b/apps/web/src/components/layout/page-tabs.tsx index 97497347..5f3450ff 100644 --- a/apps/web/src/components/layout/page-tabs.tsx +++ b/apps/web/src/components/layout/page-tabs.tsx @@ -22,11 +22,10 @@ * 建立者: Claude Code (Sprint 5 Phase 1) */ -import { useState, useCallback, useMemo, Suspense, type ReactNode } from 'react' +import { useState, useCallback, useMemo, useEffect, Suspense, type ReactNode } from 'react' import type { Route } from 'next' import { useSearchParams, useRouter, usePathname } from 'next/navigation' import { useTranslations } from 'next-intl' -import { cn } from '@/lib/utils' // ============================================================================= // 型別 @@ -100,6 +99,14 @@ export function PageTabs({ tabs, defaultTab, syncWithUrl = true }: PageTabsProps const initialTab = urlTab || defaultTab || tabs[0]?.id || '' const [activeTab, setActiveTab] = useState(initialTab) + const tabIds = tabs.map(t => t.id).join('|') + const firstTabId = tabs[0]?.id || '' + + useEffect(() => { + const nextTab = (syncWithUrl ? urlTab : null) || defaultTab || firstTabId + if (!nextTab || !tabIds.split('|').includes(nextTab)) return + setActiveTab(prev => prev === nextTab ? prev : nextTab) + }, [defaultTab, firstTabId, syncWithUrl, tabIds, urlTab]) // 切換 Tab const switchTab = useCallback((tabId: string) => { diff --git a/apps/web/src/hooks/useCSRF.ts b/apps/web/src/hooks/useCSRF.ts index 8230498c..deab3508 100644 --- a/apps/web/src/hooks/useCSRF.ts +++ b/apps/web/src/hooks/useCSRF.ts @@ -50,6 +50,36 @@ interface UseCSRFReturn { refresh: () => Promise; } +let cachedCSRFToken: string | null = null; +let csrfInFlight: Promise | null = null; + +async function requestCSRFToken(force = false): Promise { + if (cachedCSRFToken && !force) return cachedCSRFToken; + if (csrfInFlight) return csrfInFlight; + + csrfInFlight = (async () => { + const apiUrl = getApiUrl(); + const response = await fetch(`${apiUrl}/api/v1/csrf/token`, { + method: "GET", + credentials: "include", // 確保 cookie 被設定 + }); + + if (!response.ok) { + throw new Error(`Failed to fetch CSRF token: ${response.status}`); + } + + const data: CSRFTokenResponse = await response.json(); + cachedCSRFToken = data.token; + return data.token; + })(); + + try { + return await csrfInFlight; + } finally { + csrfInFlight = null; + } +} + /** * CSRF Token Hook * @@ -57,27 +87,24 @@ interface UseCSRFReturn { * 供敏感請求使用。 */ export function useCSRF(): UseCSRFReturn { - const [csrfToken, setCSRFToken] = useState(null); - const [isLoading, setIsLoading] = useState(true); + const [csrfToken, setCSRFToken] = useState(cachedCSRFToken); + const [isLoading, setIsLoading] = useState(!cachedCSRFToken); const [error, setError] = useState(null); - const fetchToken = useCallback(async () => { + const fetchToken = useCallback(async (force = false) => { + if (cachedCSRFToken && !force) { + setCSRFToken(cachedCSRFToken); + setIsLoading(false); + setError(null); + return; + } + setIsLoading(true); setError(null); try { - const apiUrl = getApiUrl(); - const response = await fetch(`${apiUrl}/api/v1/csrf/token`, { - method: "GET", - credentials: "include", // 確保 cookie 被設定 - }); - - if (!response.ok) { - throw new Error(`Failed to fetch CSRF token: ${response.status}`); - } - - const data: CSRFTokenResponse = await response.json(); - setCSRFToken(data.token); + const token = await requestCSRFToken(force); + setCSRFToken(token); } catch (err) { setError(err instanceof Error ? err : new Error(String(err))); console.error("[useCSRF] Failed to fetch CSRF token:", err); @@ -107,7 +134,7 @@ export function useCSRF(): UseCSRFReturn { isLoading, error, getHeaders, - refresh: fetchToken, + refresh: () => fetchToken(true), }; } diff --git a/apps/web/src/stores/dashboard.store.ts b/apps/web/src/stores/dashboard.store.ts index 0a599e32..f78053a2 100644 --- a/apps/web/src/stores/dashboard.store.ts +++ b/apps/web/src/stores/dashboard.store.ts @@ -176,6 +176,11 @@ export const useDashboardStore = create()( } set({ connectionStatus: 'connecting', error: null }) + + // HTTP snapshot is the visible homepage baseline; SSE should enhance it, + // not be the only way the dashboard gets hydrated. + void get().fetchSnapshot(resolvedApiBaseUrl) + console.log('[SSE] Connecting to', `${resolvedApiBaseUrl}/api/v1/dashboard/stream`) // Create EventSource @@ -202,8 +207,10 @@ export const useDashboardStore = create()( }) resetHeartbeat() - // Hydration: Fetch snapshot after connection - get().fetchSnapshot(resolvedApiBaseUrl) + // Hydration fallback: if the pre-SSE snapshot did not apply, retry after connect. + if (!get().snapshot) { + void get().fetchSnapshot(resolvedApiBaseUrl) + } } // Handle events