feat(web): surface automation evidence on homepage
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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": "失敗",
|
||||
|
||||
@@ -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 />
|
||||
|
||||
|
||||
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