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

@@ -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>
)
}