fix(web): stabilize homepage live status
All checks were successful
Code Review / ai-code-review (push) Successful in 10s
CD Pipeline / tests (push) Successful in 1m8s
CD Pipeline / build-and-deploy (push) Successful in 3m30s
CD Pipeline / post-deploy-checks (push) Successful in 1m22s

This commit is contained in:
Your Name
2026-05-19 11:12:09 +08:00
parent 504d038a9e
commit 10f2f1abaf
6 changed files with 112 additions and 49 deletions

View File

@@ -5,7 +5,7 @@
*
* 飛輪健康度 KPI 面板。
* C2: 初始載入 GET /api/v1/stats/summaryHTTP 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<WebSocket | null>(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<typeof setInterval> | 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<typeof setTimeout> | 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 () => {

View File

@@ -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 (
<>
<style>{SHARED_KEYFRAMES}</style>
{severity === 'P0' && <PipelineStyleA activeStage={activeStage} isResolved={isResolved} />}
{severity === 'P1' && <PipelineStyleB activeStage={activeStage} isResolved={isResolved} />}
{severity === 'P2' && <PipelineStyleC activeStage={activeStage} isResolved={isResolved} />}
{(severity === 'P3' || !['P0','P1','P2'].includes(severity)) && <PipelineStyleD activeStage={activeStage} isResolved={isResolved} />}
<div
data-testid="incident-flow-pipeline"
data-flow-stage={renderedStage}
data-flow-severity={severity}
data-flow-resolved={isResolved ? 'true' : 'false'}
aria-label={`incident-flow:${renderedStage}`}
>
{severity === 'P0' && <PipelineStyleA activeStage={activeStage} isResolved={isResolved} />}
{severity === 'P1' && <PipelineStyleB activeStage={activeStage} isResolved={isResolved} />}
{severity === 'P2' && <PipelineStyleC activeStage={activeStage} isResolved={isResolved} />}
{(severity === 'P3' || !['P0','P1','P2'].includes(severity)) && <PipelineStyleD activeStage={activeStage} isResolved={isResolved} />}
</div>
</>
)
}

View File

@@ -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 ?? ''

View File

@@ -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) => {

View File

@@ -50,6 +50,36 @@ interface UseCSRFReturn {
refresh: () => Promise<void>;
}
let cachedCSRFToken: string | null = null;
let csrfInFlight: Promise<string> | null = null;
async function requestCSRFToken(force = false): Promise<string> {
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<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [csrfToken, setCSRFToken] = useState<string | null>(cachedCSRFToken);
const [isLoading, setIsLoading] = useState(!cachedCSRFToken);
const [error, setError] = useState<Error | null>(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),
};
}

View File

@@ -176,6 +176,11 @@ export const useDashboardStore = create<DashboardState>()(
}
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<DashboardState>()(
})
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