feat(web): surface automation evidence on homepage
This commit is contained in:
400
apps/web/src/components/dashboard/automation-evidence-card.tsx
Normal file
400
apps/web/src/components/dashboard/automation-evidence-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user