diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 8fd95b96..5cc682ec 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -681,7 +681,11 @@ "execFailed": "Failed: {error}", "executing": "Executing...", "execute": "Execute Repair", - "noEligible": "No incidents eligible for auto-repair" + "noEligible": "No incidents eligible for auto-repair", + "dispositionAuto": "Auto Repair", + "dispositionHuman": "Human Approved", + "dispositionManual": "Manual Resolved", + "dispositionCold": "Cold Start Trust" }, "openclawPanel": { "patrolling": "[AGENT] patrolling...", @@ -793,7 +797,17 @@ "avgResolutionTime": "Avg Resolution Time", "resolutionRate": "Resolution Rate", "fetchError": "Failed to load report data", - "noData": "No statistics available" + "noData": "No statistics available", + "totalDispositions": "Total Dispositions", + "autoRate": "Automation Rate", + "humanRate": "Human Intervention Rate", + "autoRepair": "Auto Repair", + "humanApproved": "Human Approved", + "manualResolved": "Manual Resolved", + "coldStartTrust": "Cold Start Trust", + "dispositionBreakdown": "Disposition Breakdown", + "byAnomalyType": "By Anomaly Type", + "anomalyKey": "Anomaly Type" }, "apm": { "title": "APM", @@ -1068,6 +1082,12 @@ "relatedServices": "Related Services", "dataImpact": "Data Impact", "dryRunChecks": "Dry-Run Checks", - "approvalQueueCount": "{count} pending approvals" + "approvalQueueCount": "{count} pending approvals", + "dispositionBreakdown": "Disposition Breakdown", + "dispositionAuto": "Auto Repair", + "dispositionHuman": "Human Approved", + "dispositionManual": "Manual Resolved", + "dispositionCold": "Cold Start Trust", + "autoRateLabel": "Automation Rate" } } \ No newline at end of file diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index c9ac3960..c1f566e7 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -682,7 +682,11 @@ "execFailed": "執行失敗: {error}", "executing": "執行中...", "execute": "執行修復", - "noEligible": "目前無符合自動修復條件的 Incident" + "noEligible": "目前無符合自動修復條件的 Incident", + "dispositionAuto": "自動修復", + "dispositionHuman": "人工審核", + "dispositionManual": "手動處理", + "dispositionCold": "冷啟動信任" }, "openclawPanel": { "patrolling": "[AGENT] patrolling...", @@ -794,7 +798,17 @@ "avgResolutionTime": "平均解決時間", "resolutionRate": "解決率", "fetchError": "無法取得報表資料", - "noData": "目前無統計資料" + "noData": "目前無統計資料", + "totalDispositions": "處置總次數", + "autoRate": "自動化率", + "humanRate": "人工介入率", + "autoRepair": "自動修復", + "humanApproved": "人工審核", + "manualResolved": "手動處理", + "coldStartTrust": "冷啟動信任", + "dispositionBreakdown": "處置方式分佈", + "byAnomalyType": "按異常類型明細", + "anomalyKey": "異常類型" }, "apm": { "title": "APM", @@ -1069,6 +1083,12 @@ "relatedServices": "相關服務", "dataImpact": "資料影響", "dryRunChecks": "Dry-Run 檢查", - "approvalQueueCount": "共 {count} 個待審核項目" + "approvalQueueCount": "共 {count} 個待審核項目", + "dispositionBreakdown": "告警處置分佈", + "dispositionAuto": "自動修復", + "dispositionHuman": "人工審核", + "dispositionManual": "手動處理", + "dispositionCold": "冷啟動信任", + "autoRateLabel": "自動化率" } } \ No newline at end of file diff --git a/apps/web/src/app/[locale]/auto-repair/page.tsx b/apps/web/src/app/[locale]/auto-repair/page.tsx index af5c4058..7f0e2d4b 100644 --- a/apps/web/src/app/[locale]/auto-repair/page.tsx +++ b/apps/web/src/app/[locale]/auto-repair/page.tsx @@ -28,12 +28,23 @@ import { // Types // ============================================================================= +// 2026-04-07 Claude Code: Sprint 4 C2 — disposition_summary 擴充 +interface DispositionSummary { + auto_repair: number + human_approved: number + manual_resolved: number + cold_start_trust: number + total: number + auto_rate: number +} + interface AutoRepairStats { approved_playbooks: number high_quality_playbooks: number total_executions: number overall_success_rate: number auto_repair_eligible: boolean + disposition_summary?: DispositionSummary } interface EvaluateResponse { @@ -264,6 +275,7 @@ export default function AutoRepairPage({ params }: { params: { locale: string } const [stats, setStats] = useState(null) const [statsLoading, setStatsLoading] = useState(true) const [statsError, setStatsError] = useState(null) + const [disposition, setDisposition] = useState<{ total: number; auto_repair: number; human_approved: number; manual_resolved: number; cold_start_trust: number; auto_rate: number } | null>(null) const abortRef = useRef(null) const { incidents, isLoading: incidentsLoading } = useIncidents({ @@ -284,9 +296,16 @@ export default function AutoRepairPage({ params }: { params: { locale: string } setStatsLoading(true) setStatsError(null) try { - const res = await fetch(`${base}/api/v1/auto-repair/stats`, { signal: ctrl.signal }) + const [res, dispRes] = await Promise.all([ + fetch(`${base}/api/v1/auto-repair/stats`, { signal: ctrl.signal }), + fetch(`${base}/api/v1/stats/disposition`, { signal: ctrl.signal }).catch(() => null), + ]) if (!res.ok) throw new Error(`HTTP ${res.status}`) setStats(await res.json()) + if (dispRes?.ok) { + const d = await dispRes.json() + setDisposition(d.summary ?? null) + } } catch (e) { if (e instanceof Error && e.name === 'AbortError') return setStatsError(e instanceof Error ? e.message : 'Unknown error') @@ -361,6 +380,28 @@ export default function AutoRepairPage({ params }: { params: { locale: string } )} + {/* Sprint 4 E3: 處置概況 */} + {disposition && disposition.total > 0 && ( +
+
+

{t('dispositionAuto')}

+

{disposition.auto_repair}

+
+
+

{t('dispositionHuman')}

+

{disposition.human_approved}

+
+
+

{t('dispositionManual')}

+

{disposition.manual_resolved}

+
+
+

{t('dispositionCold')}

+

{disposition.cold_start_trust}

+
+
+ )} + {/* Eligible indicator */} {stats && (
([]) const [activeIncidents, setActiveIncidents] = useState([]) + const [dispositionSummary, setDispositionSummary] = useState<{ total: number; auto_repair: number; human_approved: number; manual_resolved: number; cold_start_trust: number; auto_rate: number } | null>(null) const fetchData = useCallback(async () => { try { - const [statsRes, pbRes, histRes, approvalsRes, incidentsRes] = await Promise.all([ + const [statsRes, pbRes, histRes, approvalsRes, incidentsRes, dispRes] = await Promise.all([ fetch('/api/v1/auto-repair/stats'), fetch('/api/v1/playbooks/'), fetch('/api/v1/auto-repair/history?limit=20'), fetch('/api/v1/approvals/pending'), fetch('/api/v1/incidents?status=firing&limit=10'), + fetch('/api/v1/stats/disposition').catch(() => null), ]) if (statsRes.ok) { @@ -97,6 +99,10 @@ export default function NeuralCommandPage({ params }: { params: { locale: string const data = await incidentsRes.json() setActiveIncidents(data.incidents ?? []) } + if (dispRes?.ok) { + const data = await dispRes.json() + setDispositionSummary(data.summary ?? null) + } setLastRefresh(new Date()) } catch { @@ -191,7 +197,7 @@ export default function NeuralCommandPage({ params }: { params: { locale: string )} {activeTab === 'stats' && ( - + )} {activeTab === 'approval' && ( diff --git a/apps/web/src/app/[locale]/reports/page.tsx b/apps/web/src/app/[locale]/reports/page.tsx index d23a3846..3ea0fde7 100644 --- a/apps/web/src/app/[locale]/reports/page.tsx +++ b/apps/web/src/app/[locale]/reports/page.tsx @@ -1,107 +1,317 @@ 'use client' /** - * 報表 Page - * @created 2026-04-01 ogt - 路由佔位 (awaiting implementation) - * @updated 2026-04-02 ogt - 升級為真實 UI,串接 /api/v1/stats/incident-summary + /api/v1/stats/resolution-stats + * 報表 Page — 完整處置統計儀表板 + * ==================================== + * Sprint 4 E1: 告警處置統計主戰場 + * + * 串接: + * GET /api/v1/stats/incident-summary + * GET /api/v1/stats/resolution-stats + * GET /api/v1/stats/disposition (Sprint 4 新增) + * + * 統帥鐵律: 禁止假數據!無資料顯示 '--' + * i18n: 100% next-intl + * + * 2026-04-02 Claude Code — 初版 + * 2026-04-07 Claude Code — Sprint 4 E1 處置統計升級 */ -import { useEffect, useState } from 'react' +import { useEffect, useState, useMemo } from 'react' import { useTranslations } from 'next-intl' import { AppLayout } from '@/components/layout' +import { cn } from '@/lib/utils' +import { Bot, UserCheck, Wrench, Sparkles, TrendingUp, BarChart3, Inbox, RefreshCw } from 'lucide-react' const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? '' +// ============================================================================= +// Types (對齊後端 DispositionResponse) +// ============================================================================= + +interface DispositionSummary { + total: number + auto_repair: number + human_approved: number + manual_resolved: number + cold_start_trust: number + auto_rate: number + human_rate: number +} + +interface DispositionByAnomaly { + anomaly_key: string + alert_name: string + disposition: DispositionSummary +} + +interface DispositionResponse { + summary: DispositionSummary + by_anomaly: DispositionByAnomaly[] +} + interface IncidentSummary { total?: number resolved?: number unresolved?: number - [key: string]: unknown } interface ResolutionStats { resolutionRate?: number avgResolutionTime?: string | number - [key: string]: unknown } -function StatCard({ label, value }: { label: string; value: string | number }) { - return ( -
-
{label}
-
{value}
-
- ) -} +// ============================================================================= +// Page +// ============================================================================= export default function ReportsPage({ params }: { params: { locale: string } }) { const t = useTranslations('reports') const tc = useTranslations('common') const [summary, setSummary] = useState(null) const [resolution, setResolution] = useState(null) + const [disposition, setDisposition] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(false) - useEffect(() => { + const fetchAll = () => { setLoading(true) setError(false) Promise.all([ fetch(`${API_BASE}/api/v1/stats/incident-summary`).then(r => r.json()).catch(() => null), fetch(`${API_BASE}/api/v1/stats/resolution-stats`).then(r => r.json()).catch(() => null), + fetch(`${API_BASE}/api/v1/stats/disposition`).then(r => r.json()).catch(() => null), ]) - .then(([s, r]) => { + .then(([s, r, d]) => { setSummary(s) setResolution(r) + setDisposition(d) }) .catch(() => setError(true)) .finally(() => setLoading(false)) - }, []) + } + + useEffect(() => { fetchAll() }, []) + + const ds = disposition?.summary + + // 處置分佈百分比 (用於堆疊條) + const pcts = useMemo(() => { + if (!ds || ds.total === 0) return null + const t = ds.total + return { + auto: Math.round((ds.auto_repair / t) * 100), + human: Math.round((ds.human_approved / t) * 100), + manual: Math.round((ds.manual_resolved / t) * 100), + cold: Math.round((ds.cold_start_trust / t) * 100), + } + }, [ds]) return ( -
-
-

{t('title')}

-

{t('subtitle')}

+
+ + {/* Header */} +
+
+

{t('title')}

+

{t('subtitle')}

+
+
{loading && ( -
{tc('loading')}
+
{tc('loading')}
)} {!loading && error && ( -
{t('fetchError')}
+
{t('fetchError')}
)} {!loading && !error && ( <> - {/* Incident Summary Section */} -
-
- - {t('incidentSummary')} + {/* ── 頂部 KPI 3 卡 ── */} +
+ } + label={t('totalDispositions')} + value={ds?.total ?? '--'} + color="text-blue-500" + /> + } + label={t('autoRate')} + value={ds ? `${Math.round(ds.auto_rate * 100)}%` : '--'} + color="text-green-500" + /> + } + label={t('humanRate')} + value={ds ? `${Math.round(ds.human_rate * 100)}%` : '--'} + color="text-orange-500" + /> +
+ + {/* ── 四大計數卡片 ── */} +
+ } + label={t('autoRepair')} + count={ds?.auto_repair ?? 0} + color="text-green-500" + bg="bg-green-500/10" + border="border-green-500/25" + /> + } + label={t('humanApproved')} + count={ds?.human_approved ?? 0} + color="text-orange-500" + bg="bg-orange-500/10" + border="border-orange-500/25" + /> + } + label={t('manualResolved')} + count={ds?.manual_resolved ?? 0} + color="text-purple-500" + bg="bg-purple-500/10" + border="border-purple-500/25" + /> + } + label={t('coldStartTrust')} + count={ds?.cold_start_trust ?? 0} + color="text-blue-500" + bg="bg-blue-500/10" + border="border-blue-500/25" + /> +
+ + {/* ── 堆疊分佈條 ── */} + {pcts && ( +
+

{t('dispositionBreakdown')}

+
+ {pcts.auto > 0 &&
} + {pcts.cold > 0 &&
} + {pcts.human > 0 &&
} + {pcts.manual > 0 &&
} +
+
+ {t('autoRepair')} {pcts.auto}% + {t('coldStartTrust')} {pcts.cold}% + {t('humanApproved')} {pcts.human}% + {t('manualResolved')} {pcts.manual}% +
-
- - - + )} + + {/* ── 按異常類型明細 ── */} + {disposition?.by_anomaly && disposition.by_anomaly.length > 0 && ( +
+

{t('byAnomalyType')}

+ + + + + + + + + + + + + {disposition.by_anomaly.map(item => ( + + + + + + + + + ))} + +
{t('anomalyKey')}{t('autoRepair')}{t('humanApproved')}{t('manualResolved')}{t('coldStartTrust')}{t('totalDispositions')}
{item.alert_name || item.anomaly_key.slice(0, 12)}{item.disposition.auto_repair}{item.disposition.human_approved}{item.disposition.manual_resolved}{item.disposition.cold_start_trust}{item.disposition.total}
+
+ )} + + {/* ── 既有: 事件摘要 ── */} +
+
+

{t('incidentSummary')}

+
+ + + +
+
+
+

{t('resolutionStats')}

+
+ + +
- {/* Resolution Stats Section */} -
-
- - {t('resolutionStats')} + {/* 無資料提示 */} + {!ds || ds.total === 0 ? ( +
+ +

{t('noData')}

-
- - -
-
+ ) : null} )}
) } + +// ============================================================================= +// Sub-components +// ============================================================================= + +function KPICard({ icon, label, value, color }: { icon: React.ReactNode; label: string; value: string | number; color: string }) { + return ( +
+
+ {icon} +
+
+

{value}

+

{label}

+
+
+ ) +} + +function DispositionCard({ icon, label, count, color, bg, border }: { icon: React.ReactNode; label: string; count: number; color: string; bg: string; border: string }) { + return ( +
+
+ {icon} + {label} +
+

{count}

+
+ ) +} + +function MiniStat({ label, value }: { label: string; value: string | number }) { + return ( +
+

{value}

+

{label}

+
+ ) +} diff --git a/apps/web/src/components/neural-command/NeuralStats.tsx b/apps/web/src/components/neural-command/NeuralStats.tsx index 007f7590..cff607ab 100644 --- a/apps/web/src/components/neural-command/NeuralStats.tsx +++ b/apps/web/src/components/neural-command/NeuralStats.tsx @@ -15,11 +15,21 @@ import { cn } from '@/lib/utils' import { Inbox } from 'lucide-react' import type { AutoRepairStats, PlaybookItem, RepairHistoryItem } from './types' +interface DispositionSummary { + total: number + auto_repair: number + human_approved: number + manual_resolved: number + cold_start_trust: number + auto_rate: number +} + interface Props { stats: AutoRepairStats | null playbooks: PlaybookItem[] history: RepairHistoryItem[] pendingCount: number + disposition?: DispositionSummary | null } const TYPE_BADGE: Record = { @@ -34,7 +44,7 @@ const SCHEME_ICON: Record + {/* ── 處置分佈 (Sprint 4 E4) ── */} + {disposition && disposition.total > 0 && ( +
+

{t('dispositionBreakdown')}

+
+ {[ + { label: t('dispositionAuto'), value: disposition.auto_repair, color: 'text-green-500', bg: 'bg-green-500/10', border: 'border-green-500/25' }, + { label: t('dispositionHuman'), value: disposition.human_approved, color: 'text-orange-500', bg: 'bg-orange-500/10', border: 'border-orange-500/25' }, + { label: t('dispositionManual'), value: disposition.manual_resolved, color: 'text-purple-500', bg: 'bg-purple-500/10', border: 'border-purple-500/25' }, + { label: t('dispositionCold'), value: disposition.cold_start_trust, color: 'text-blue-500', bg: 'bg-blue-500/10', border: 'border-blue-500/25' }, + ].map(d => ( +
+

{d.label}

+

{d.value}

+
+ ))} +
+

+ {t('autoRateLabel')}: {Math.round(disposition.auto_rate * 100)}% +

+
+ )} +
{/* ── Scheme breakdown ── */}