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