fix(web): stabilize homepage live status
This commit is contained in:
@@ -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<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 () => {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 ?? ''
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user