feat(web): surface automation evidence on homepage
All checks were successful
Code Review / ai-code-review (push) Successful in 11s
CD Pipeline / tests (push) Successful in 5m54s
CD Pipeline / build-and-deploy (push) Successful in 4m37s
CD Pipeline / post-deploy-checks (push) Successful in 1m28s

This commit is contained in:
Your Name
2026-05-19 18:22:37 +08:00
parent 6ea041d463
commit 61d82b3ad3
4 changed files with 481 additions and 9 deletions

View File

@@ -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",

View File

@@ -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": "失敗",

View File

@@ -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<number | null>(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 } }) {
</div>
{/* 本週操作 */}
<div style={{ flex: 1, background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 8, padding: '8px 12px' }}>
<div style={{ fontSize: 10, textTransform: 'uppercase', letterSpacing: '0.5px', color: '#87867f', fontWeight: 500 }}>{tDashboard('todayIncidents')}</div>
<div style={{ fontSize: 22, fontWeight: 700, color: '#141413', marginTop: 2 }}>{weeklyOps != null ? weeklyOps.toLocaleString() : '--'}</div>
<div style={{ fontSize: 10, textTransform: 'uppercase', letterSpacing: '0.5px', color: '#87867f', fontWeight: 500 }}>{tDashboard('operations24h')}</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6, marginTop: 2 }}>
<span style={{ fontSize: 22, fontWeight: 700, color: '#141413' }}>
{auditStats?.last_24h_count != null ? auditStats.last_24h_count.toLocaleString() : '--'}
</span>
{auditStats?.total_executions != null && (
<span style={{ fontSize: 9, color: '#87867f' }}>
{tDashboard('operationsTotal', { total: auditStats.total_executions.toLocaleString() })}
</span>
)}
</div>
</div>
</div>
@@ -925,6 +942,9 @@ export default function Home({ params }: { params: { locale: string } }) {
{/* 待審批任務 (S7) */}
<PendingApprovalsCard />
{/* AI 自動化證據鏈來源、重複、MCP、修復、人工缺口 */}
<AutomationEvidenceCard />
{/* 飛輪健康度 KPI (ADR-073-C C2) */}
<FlywheelKPICard />

View File

@@ -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<T>(path: string, signal: AbortSignal): Promise<T | null> {
const response = await fetch(`${API_BASE}${path}`, { signal })
if (!response.ok) return null
return response.json() as Promise<T>
}
function EvidenceMetric({
label,
value,
detail,
tone,
icon: Icon,
}: {
label: string
value: string | number
detail: string
tone: Tone
icon: typeof Activity
}) {
return (
<div style={{ background: '#fff', padding: '10px 12px', minHeight: 94 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 10 }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 10, color: '#77736a', fontWeight: 700, letterSpacing: 0.4, textTransform: 'uppercase' }}>
{label}
</div>
<div style={{ marginTop: 5, fontFamily: "'JetBrains Mono', monospace", fontSize: 22, fontWeight: 700, color: '#141413', lineHeight: 1 }}>
{value}
</div>
</div>
<span className={`flex h-8 w-8 shrink-0 items-center justify-center border ${metricToneClass(tone)}`}>
<Icon className="h-4 w-4" aria-hidden="true" />
</span>
</div>
<div style={{ marginTop: 8, fontSize: 11, color: '#5f5b52', lineHeight: 1.45 }}>
{detail}
</div>
</div>
)
}
export function AutomationEvidenceCard() {
const t = useTranslations('dashboard.automationEvidence')
const tQuality = useTranslations('awooop.home.quality')
const [snapshot, setSnapshot] = useState<EvidenceSnapshot | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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<AutomationQualitySummary>('/api/v1/platform/truth-chain/quality/summary?project_id=awoooi&hours=24&limit=200', controller.signal),
fetchJson<DossierCoverageResponse>('/api/v1/platform/events/dossier/coverage?project_id=awoooi&limit=100', controller.signal),
fetchJson<EventRecurrenceResponse>('/api/v1/platform/events/dossier/recurrence?project_id=awoooi&limit=100', controller.signal),
fetchJson<RunsResponse>('/api/v1/platform/runs/list?project_id=awoooi&per_page=25', controller.signal),
fetchJson<AiRouteStatusResponse>('/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 (
<div style={{
background: '#fff',
border: '0.5px solid #e0ddd4',
borderRadius: 12,
overflow: 'hidden',
boxShadow: '0 1px 4px rgba(0,0,0,0.05)',
flexShrink: 0,
}}>
<div style={{
padding: '10px 14px',
borderBottom: '0.5px solid #e0ddd4',
fontSize: 14,
fontWeight: 700,
color: '#141413',
letterSpacing: '0.5px',
fontFamily: 'var(--font-body), monospace',
background: '#faf9f3',
display: 'flex',
alignItems: 'center',
gap: 8,
}}>
<div style={{ width: 6, height: 6, borderRadius: '50%', background: derived.claimReady ? '#22C55E' : '#F59E0B', flexShrink: 0 }} />
{t('title')}
<span style={{ marginLeft: 'auto', fontSize: 10, color: derived.claimReady ? '#17602a' : '#8a5a08', fontWeight: 700 }}>
{derived.claimReady ? t('claimReady') : t('claimBlocked')}
</span>
</div>
{loading ? (
<div style={{ padding: 14, fontSize: 12, color: '#77736a' }}>{t('loading')}</div>
) : error ? (
<div style={{ padding: 14, fontSize: 12, color: '#9f2f25' }}>{t('error', { error })}</div>
) : !hasData ? (
<div style={{ padding: 14, fontSize: 12, color: '#77736a' }}>{t('empty')}</div>
) : (
<>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1, background: '#e0ddd4' }}>
<EvidenceMetric
label={t('sourcePersisted')}
value={derived.coverage?.source_count ?? '--'}
detail={t('sourceDetail', {
missing: derived.coverage?.missing_source_refs_total ?? 0,
latest: formatTime(derived.coverage?.latest_received_at),
})}
tone={(derived.coverage?.missing_source_refs_total ?? 0) === 0 ? 'good' : 'warn'}
icon={CheckCircle2}
/>
<EvidenceMetric
label={t('recurrence')}
value={`${derived.recurrence?.recurrent_group_total ?? 0}/${derived.recurrence?.recurrence_group_total ?? 0}`}
detail={t('recurrenceDetail', {
duplicates: derived.recurrence?.duplicate_event_total ?? 0,
workItems: derived.recurrence?.open_work_item_group_total ?? 0,
})}
tone={(derived.recurrence?.open_work_item_group_total ?? 0) > 0 ? 'warn' : 'good'}
icon={GitBranch}
/>
<EvidenceMetric
label={t('mcpInvestigation')}
value={derived.runEvidence.total || '--'}
detail={t('mcpDetail', {
success: derived.runEvidence.success,
failed: derived.runEvidence.failed,
server: derived.runEvidence.server ?? '--',
})}
tone={derived.runEvidence.failed > 0 ? 'warn' : 'good'}
icon={SearchCheck}
/>
<EvidenceMetric
label={t('autoRepair')}
value={`${derived.quality?.verified_auto_repair_total ?? 0}/${derived.quality?.evaluated_total ?? 0}`}
detail={t('qualityDetail', {
score: (derived.quality?.average_score ?? 0).toFixed(1),
red: derived.quality?.score_buckets?.red ?? 0,
})}
tone={derived.claimReady ? 'good' : 'warn'}
icon={Activity}
/>
<EvidenceMetric
label={t('humanGap')}
value={derived.recurrence?.automation_gap_group_total ?? 0}
detail={t('humanGapDetail', {
gate: tQuality(gateLabelKey(derived.topGate?.gate) as never),
count: derived.topGate?.total ?? 0,
})}
tone={(derived.recurrence?.automation_gap_group_total ?? 0) > 0 ? 'warn' : 'good'}
icon={ShieldCheck}
/>
<EvidenceMetric
label={t('modelRoute')}
value={derived.selectedProvider}
detail={t('routeDetail', {
model: snapshot?.route?.selected_model ?? '--',
fallback: derived.fallback || '--',
})}
tone="neutral"
icon={Route}
/>
</div>
{derived.topGate && (
<div style={{
borderTop: '0.5px solid #e0ddd4',
background: 'rgba(245,158,11,0.08)',
padding: '8px 14px',
display: 'flex',
alignItems: 'center',
gap: 8,
fontSize: 11,
color: '#8a5a08',
lineHeight: 1.45,
}}>
<AlertTriangle className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
{t('topGap', {
gate: tQuality(gateLabelKey(derived.topGate.gate) as never),
count: derived.topGate.total,
})}
</div>
)}
</>
)}
</div>
)
}