diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index fb3feebc..d2056bd8 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -145,6 +145,8 @@ "activeIncidents": "Active Incidents", "serviceHealth": "Service Health", "todayIncidents": "Today Incidents", + "operations24h": "24h Operations", + "operationsTotal": "{total} total", "autoRemediationRate": "Auto Remediation", "mttrAvg": "MTTR Avg", "stable": "Stable", @@ -220,7 +222,30 @@ "byAnomalyAutoRate": "Auto {pct}%", "mttrTitle": "MTTR Overview", "mttrUnit": "min", - "mttrNoData": "No MTTR data yet" + "mttrNoData": "No MTTR data yet", + "automationEvidence": { + "title": "AI Automation Evidence", + "claimReady": "Loop claim ready", + "claimBlocked": "Gaps remain", + "loading": "Loading AI automation evidence...", + "empty": "No AI automation evidence is available yet.", + "missingApiBase": "NEXT_PUBLIC_API_URL is not set", + "loadFailed": "Load failed", + "error": "Evidence chain failed to load: {error}", + "sourcePersisted": "Source persisted", + "sourceDetail": "{missing} missing refs, latest {latest}", + "recurrence": "Recurrence", + "recurrenceDetail": "{duplicates} duplicate events, {workItems} work items", + "mcpInvestigation": "MCP investigation", + "mcpDetail": "{success} success / {failed} failed, latest {server}", + "autoRepair": "Auto repair", + "qualityDetail": "Average {score}, red {red}", + "humanGap": "Human gap", + "humanGapDetail": "{gate} missing {count}", + "modelRoute": "Model route", + "routeDetail": "{model}; fallback {fallback}", + "topGap": "Largest current gap: {gate}, {count} items." + } }, "openclaw": { "name": "OpenClaw", @@ -1659,7 +1684,8 @@ "autoRepairRecorded": "Auto-Repair Recorded", "verificationRecorded": "Verification Recorded", "learningRecorded": "Learning Writeback", - "timelineRecorded": "Timeline Recorded" + "timelineRecorded": "Timeline Recorded", + "unknown": "Unknown Gate" }, "gateStatuses": { "failed": "Failed", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 57e92c65..69582579 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -146,6 +146,8 @@ "activeIncidents": "活躍事件", "serviceHealth": "服務健康", "todayIncidents": "今日事件", + "operations24h": "近 24h 操作", + "operationsTotal": "總計 {total}", "autoRemediationRate": "自動處置率", "mttrAvg": "MTTR 均值", "stable": "穩定", @@ -221,7 +223,30 @@ "byAnomalyAutoRate": "自動修復率 {pct}%", "mttrTitle": "MTTR 概覽", "mttrUnit": "分鐘", - "mttrNoData": "尚無 MTTR 資料" + "mttrNoData": "尚無 MTTR 資料", + "automationEvidence": { + "title": "AI 自動化證據鏈", + "claimReady": "可宣稱閉環", + "claimBlocked": "仍有缺口", + "loading": "讀取 AI 自動化證據中...", + "empty": "尚無可呈現的 AI 自動化證據。", + "missingApiBase": "NEXT_PUBLIC_API_URL 未設定", + "loadFailed": "讀取失敗", + "error": "證據鏈讀取失敗:{error}", + "sourcePersisted": "來源入庫", + "sourceDetail": "缺關聯 {missing},最新 {latest}", + "recurrence": "重複收斂", + "recurrenceDetail": "重複事件 {duplicates},待處理 {workItems}", + "mcpInvestigation": "MCP 調查", + "mcpDetail": "成功 {success} / 失敗 {failed},最新 {server}", + "autoRepair": "自動修復", + "qualityDetail": "平均 {score},紅燈 {red}", + "humanGap": "人工缺口", + "humanGapDetail": "{gate} 缺 {count} 筆", + "modelRoute": "模型路由", + "routeDetail": "{model};備援 {fallback}", + "topGap": "目前最大缺口:{gate},共 {count} 筆。" + } }, "openclaw": { "name": "OpenClaw", @@ -1660,7 +1685,8 @@ "autoRepairRecorded": "自動修復記錄", "verificationRecorded": "驗證記錄", "learningRecorded": "學習回寫", - "timelineRecorded": "Timeline 記錄" + "timelineRecorded": "Timeline 記錄", + "unknown": "未知閘門" }, "gateStatuses": { "failed": "失敗", diff --git a/apps/web/src/app/[locale]/page.tsx b/apps/web/src/app/[locale]/page.tsx index 43a92bc0..ddf7ba22 100644 --- a/apps/web/src/app/[locale]/page.tsx +++ b/apps/web/src/app/[locale]/page.tsx @@ -33,6 +33,7 @@ import { RecentActivity } from '@/components/shared/recent-activity' import { PendingApprovalsCard } from '@/components/shared/pending-approvals-card' import { AIModelStatus } from '@/components/shared/ai-model-status' import { FlywheelKPICard } from '@/components/dashboard/flywheel-kpi-card' +import { AutomationEvidenceCard } from '@/components/dashboard/automation-evidence-card' const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? '' @@ -713,12 +714,19 @@ export default function Home({ params }: { params: { locale: string } }) { const p1Count = incidents?.filter(i => i.severity === 'P1').length ?? 0 const p2Count = incidents?.filter(i => i.severity === 'P2').length ?? 0 - // 本週操作數 - const [weeklyOps, setWeeklyOps] = useState(null) + // 近 24h 操作數。首頁 KPI 必須顯示窗口值,避免把 total_executions 誤讀成今日事件。 + const [auditStats, setAuditStats] = useState<{ last_24h_count?: number; total_executions?: number } | null>(null) useEffect(() => { fetch(`${API_BASE}/api/v1/audit-logs/stats`) .then(r => r.ok ? r.json() : null) - .then(d => { if (d?.total_executions != null) setWeeklyOps(d.total_executions) }) + .then(d => { + if (d) { + setAuditStats({ + last_24h_count: typeof d.last_24h_count === 'number' ? d.last_24h_count : undefined, + total_executions: typeof d.total_executions === 'number' ? d.total_executions : undefined, + }) + } + }) .catch(() => {}) }, []) @@ -815,8 +823,17 @@ export default function Home({ params }: { params: { locale: string } }) { {/* 本週操作 */}
-
{tDashboard('todayIncidents')}
-
{weeklyOps != null ? weeklyOps.toLocaleString() : '--'}
+
{tDashboard('operations24h')}
+
+ + {auditStats?.last_24h_count != null ? auditStats.last_24h_count.toLocaleString() : '--'} + + {auditStats?.total_executions != null && ( + + {tDashboard('operationsTotal', { total: auditStats.total_executions.toLocaleString() })} + + )} +
@@ -925,6 +942,9 @@ export default function Home({ params }: { params: { locale: string } }) { {/* 待審批任務 (S7) */} + {/* AI 自動化證據鏈:來源、重複、MCP、修復、人工缺口 */} + + {/* 飛輪健康度 KPI (ADR-073-C C2) */} diff --git a/apps/web/src/components/dashboard/automation-evidence-card.tsx b/apps/web/src/components/dashboard/automation-evidence-card.tsx new file mode 100644 index 00000000..65436159 --- /dev/null +++ b/apps/web/src/components/dashboard/automation-evidence-card.tsx @@ -0,0 +1,400 @@ +'use client' + +/** + * AutomationEvidenceCard — homepage AI automation truth-chain snapshot. + * + * Shows the operator whether alerts are persisted, deduped, investigated by + * MCP, auto-repaired, or blocked by human work items using existing AwoooP + * read APIs. No fallback sample data is rendered. + */ + +import { useEffect, useMemo, useState } from 'react' +import { useTranslations } from 'next-intl' +import { + Activity, + AlertTriangle, + CheckCircle2, + GitBranch, + Route, + SearchCheck, + ShieldCheck, +} from 'lucide-react' + +const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? '' + +interface AutomationQualitySummary { + evaluated_total: number + verified_auto_repair_total: number + average_score: number + score_buckets?: { + green?: number + yellow?: number + red?: number + } + production_claim?: { + can_claim_full_auto_repair?: boolean + reason?: string + } + gate_failures?: Array<{ + gate: string + total: number + }> +} + +interface DossierCoverageResponse { + summary?: { + source_count?: number + missing_source_refs_total?: number + duplicate_total?: number + latest_received_at?: string | null + } +} + +interface EventRecurrenceResponse { + summary?: { + recurrence_group_total?: number + recurrent_group_total?: number + duplicate_event_total?: number + linked_run_total?: number + auto_repair_linked_total?: number + verified_repair_group_total?: number + open_work_item_group_total?: number + automation_gap_group_total?: number + latest_received_at?: string | null + } +} + +interface RunSummary { + remediation_summary?: { + mcp_observation_total?: number + mcp_observation_success?: number + mcp_observation_failed?: number + latest_route?: string | null + latest_mcp_server?: string | null + } | null +} + +interface RunsResponse { + runs?: RunSummary[] + items?: RunSummary[] +} + +interface AiRouteStatusResponse { + selected_provider?: string | null + selected_model?: string | null + fallback_chain?: Array<{ + provider_name: string + }> +} + +interface EvidenceSnapshot { + quality: AutomationQualitySummary | null + coverage: DossierCoverageResponse | null + recurrence: EventRecurrenceResponse | null + runs: RunSummary[] + route: AiRouteStatusResponse | null +} + +type Tone = 'good' | 'warn' | 'neutral' + +function metricToneClass(tone: Tone) { + if (tone === 'good') return 'border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]' + if (tone === 'warn') return 'border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]' + return 'border-[#d8d3c7] bg-[#faf9f3] text-[#5f5b52]' +} + +function formatTime(value?: string | null) { + if (!value) return '--' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return '--' + return date.toLocaleTimeString('zh-TW', { hour: '2-digit', minute: '2-digit' }) +} + +function gateLabelKey(gate?: string) { + switch (gate) { + case 'source_persisted': + return 'gates.sourcePersisted' + case 'outbound_recorded': + return 'gates.outboundRecorded' + case 'evidence_collected': + return 'gates.evidenceCollected' + case 'mcp_gateway_observed': + return 'gates.mcpGatewayObserved' + case 'approval_state': + return 'gates.approvalState' + case 'execution_recorded': + return 'gates.executionRecorded' + case 'auto_repair_recorded': + return 'gates.autoRepairRecorded' + case 'verification_recorded': + return 'gates.verificationRecorded' + case 'learning_recorded': + return 'gates.learningRecorded' + case 'timeline_recorded': + return 'gates.timelineRecorded' + default: + return 'gates.unknown' + } +} + +async function fetchJson(path: string, signal: AbortSignal): Promise { + const response = await fetch(`${API_BASE}${path}`, { signal }) + if (!response.ok) return null + return response.json() as Promise +} + +function EvidenceMetric({ + label, + value, + detail, + tone, + icon: Icon, +}: { + label: string + value: string | number + detail: string + tone: Tone + icon: typeof Activity +}) { + return ( +
+
+
+
+ {label} +
+
+ {value} +
+
+ + +
+
+ {detail} +
+
+ ) +} + +export function AutomationEvidenceCard() { + const t = useTranslations('dashboard.automationEvidence') + const tQuality = useTranslations('awooop.home.quality') + const [snapshot, setSnapshot] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + if (!API_BASE) { + setError(t('missingApiBase')) + setLoading(false) + return + } + + const controller = new AbortController() + + async function load() { + try { + setError(null) + const [ + quality, + coverage, + recurrence, + runs, + route, + ] = await Promise.all([ + fetchJson('/api/v1/platform/truth-chain/quality/summary?project_id=awoooi&hours=24&limit=200', controller.signal), + fetchJson('/api/v1/platform/events/dossier/coverage?project_id=awoooi&limit=100', controller.signal), + fetchJson('/api/v1/platform/events/dossier/recurrence?project_id=awoooi&limit=100', controller.signal), + fetchJson('/api/v1/platform/runs/list?project_id=awoooi&per_page=25', controller.signal), + fetchJson('/api/v1/platform/ai-route-status?workload_type=deep_rca', controller.signal), + ]) + + setSnapshot({ + quality, + coverage, + recurrence, + runs: Array.isArray(runs?.runs) ? runs.runs : Array.isArray(runs?.items) ? runs.items : [], + route, + }) + } catch (err) { + if (!controller.signal.aborted) { + setError(err instanceof Error ? err.message : t('loadFailed')) + } + } finally { + if (!controller.signal.aborted) setLoading(false) + } + } + + load() + const timer = setInterval(load, 30_000) + return () => { + controller.abort() + clearInterval(timer) + } + }, [t]) + + const derived = useMemo(() => { + const quality = snapshot?.quality + const coverage = snapshot?.coverage?.summary + const recurrence = snapshot?.recurrence?.summary + const runEvidence = (snapshot?.runs ?? []).reduce( + (acc, run) => { + const summary = run.remediation_summary + acc.total += summary?.mcp_observation_total ?? 0 + acc.success += summary?.mcp_observation_success ?? 0 + acc.failed += summary?.mcp_observation_failed ?? 0 + if (!acc.route && summary?.latest_route) acc.route = summary.latest_route + if (!acc.server && summary?.latest_mcp_server) acc.server = summary.latest_mcp_server + return acc + }, + { total: 0, success: 0, failed: 0, route: null as string | null, server: null as string | null } + ) + + const topGate = quality?.gate_failures?.[0] + const claimReady = Boolean(quality?.production_claim?.can_claim_full_auto_repair) + const selectedProvider = snapshot?.route?.selected_provider ?? '--' + const fallback = snapshot?.route?.fallback_chain + ?.map((item) => item.provider_name) + .join(' -> ') + + return { + quality, + coverage, + recurrence, + runEvidence, + topGate, + claimReady, + selectedProvider, + fallback, + } + }, [snapshot]) + + const hasData = Boolean(snapshot?.quality || snapshot?.coverage || snapshot?.recurrence) + + return ( +
+
+
+ {t('title')} + + {derived.claimReady ? t('claimReady') : t('claimBlocked')} + +
+ + {loading ? ( +
{t('loading')}
+ ) : error ? ( +
{t('error', { error })}
+ ) : !hasData ? ( +
{t('empty')}
+ ) : ( + <> +
+ + 0 ? 'warn' : 'good'} + icon={GitBranch} + /> + 0 ? 'warn' : 'good'} + icon={SearchCheck} + /> + + 0 ? 'warn' : 'good'} + icon={ShieldCheck} + /> + +
+ + {derived.topGate && ( +
+
+ )} + + )} +
+ ) +}