feat(web): show automation product work map
Some checks failed
CD Pipeline / tests (push) Successful in 1m29s
Code Review / ai-code-review (push) Successful in 12s
CD Pipeline / build-and-deploy (push) Successful in 3m31s
CD Pipeline / post-deploy-checks (push) Has been cancelled

This commit is contained in:
Your Name
2026-05-26 00:07:51 +08:00
parent eb6308f7b5
commit 0a981a5990
3 changed files with 849 additions and 6 deletions

View File

@@ -37,10 +37,95 @@ const STATUS_CHAIN_PREFETCH_LIMIT = 25
const HOMEPAGE_INCIDENT_LIMIT = STATUS_CHAIN_PREFETCH_LIMIT
interface HomepageAutomationQualitySummary {
average_score?: number
evaluated_total?: number
verified_auto_repair_total?: number
gate_failures?: HomepageAutomationGateFailure[]
execution_backend_summary?: HomepageExecutionBackendSummary | null
ansible_runtime?: HomepageAnsibleRuntime | null
production_claim?: {
can_claim_full_auto_repair?: boolean
reason?: string | null
}
}
interface HomepageAutomationGateFailure {
gate?: string | null
total?: number | null
statuses?: Record<string, number>
}
interface HomepageExecutionBackendSummary {
operation_records_total?: number
effective_execution_records_total?: number
audit_only_operation_records_total?: number
auto_repair_execution_records_total?: number
ansible_candidate_total?: number
ansible_check_mode_total?: number
ansible_apply_total?: number
ansible_pending_check_mode_total?: number
}
interface HomepageAnsibleRuntime {
can_run_check_mode?: boolean
blockers?: string[]
}
interface HomepageCallbackReplyAuditSummary {
outbound_reply_markup_missing_trace_ref_total?: number
outbound_reply_markup_missing_trace_ref_recent_1h_total?: number
outbound_reply_markup_missing_trace_ref_recent_24h_total?: number
outbound_reply_markup_trace_ref_after_gap_total?: number
outbound_reply_markup_trace_ref_gap_status?: string | null
outbound_reply_markup_trace_ref_gap_next_action?: string | null
outbound_reply_markup_trace_ref_gap_recovery_status?: string | null
}
interface HomepageCallbackRepliesResponse {
total?: number
summary?: HomepageCallbackReplyAuditSummary | null
}
interface HomepageAiRouteStatusResponse {
lane_mode?: string | null
selected_provider?: string | null
skipped_lanes?: Array<{ provider_name?: string | null }>
operator_action?: {
human_required?: boolean | null
action?: string | null
reason?: string | null
} | null
}
interface HomepageKmStaleCandidatesResponse {
total_stale?: number
threshold_days?: number
}
interface HomepageAutomationBriefSnapshot {
callbackReplies?: HomepageCallbackRepliesResponse | null
aiRouteStatus?: HomepageAiRouteStatusResponse | null
kmStaleCandidates?: HomepageKmStaleCandidatesResponse | null
}
type HomepageWorkTone = 'live' | 'progress' | 'blocked' | 'watching'
interface HomepageWorkItemSummary {
key: string
title: string
status: string
detail: string
href: string
tone: HomepageWorkTone
}
async function fetchHomepageJson<T>(url: string, signal?: AbortSignal): Promise<T | null> {
try {
const response = await fetch(url, { signal, cache: 'no-store' })
if (!response.ok) return null
return await response.json() as T
} catch {
return null
}
}
@@ -547,6 +632,9 @@ export default function Home({ params }: { params: { locale: string } }) {
// 2026-04-07 Claude Code: Sprint 4 E2 — 從 disposition API 取得真實自動化率
const [dispositionRate, setDispositionRate] = useState<{ auto_rate: number; total: number } | null>(null)
const [automationQuality, setAutomationQuality] = useState<HomepageAutomationQualitySummary | null>(null)
const [automationQualityLoaded, setAutomationQualityLoaded] = useState(false)
const [automationBrief, setAutomationBrief] = useState<HomepageAutomationBriefSnapshot>({})
const [automationBriefLoaded, setAutomationBriefLoaded] = useState(false)
useEffect(() => {
fetch(`${API_BASE}/api/v1/stats/disposition`)
.then(r => r.json())
@@ -564,20 +652,55 @@ export default function Home({ params }: { params: { locale: string } }) {
.then(d => {
if (!d) return
setAutomationQuality({
average_score: typeof d.average_score === 'number' ? d.average_score : undefined,
evaluated_total: typeof d.evaluated_total === 'number' ? d.evaluated_total : undefined,
verified_auto_repair_total: typeof d.verified_auto_repair_total === 'number' ? d.verified_auto_repair_total : undefined,
gate_failures: Array.isArray(d.gate_failures) ? d.gate_failures : [],
execution_backend_summary: d.execution_backend_summary ?? null,
ansible_runtime: d.ansible_runtime ?? null,
production_claim: d.production_claim,
})
})
.catch(() => {})
.finally(() => setAutomationQualityLoaded(true))
return () => controller.abort()
}, [])
useEffect(() => {
const controller = new AbortController()
const encodedProjectId = encodeURIComponent('awoooi')
Promise.all([
fetchHomepageJson<HomepageCallbackRepliesResponse>(
`${API_BASE}/api/v1/platform/runs/callback-replies?project_id=${encodedProjectId}&per_page=20`,
controller.signal
),
fetchHomepageJson<HomepageAiRouteStatusResponse>(
`${API_BASE}/api/v1/platform/ai-route-status?workload_type=deep_rca`,
controller.signal
),
fetchHomepageJson<HomepageKmStaleCandidatesResponse>(
`${API_BASE}/api/v1/ai/governance/km-stale-candidates?project_id=${encodedProjectId}&limit=5`,
controller.signal
),
]).then(([callbackReplies, aiRouteStatus, kmStaleCandidates]) => {
setAutomationBrief({ callbackReplies, aiRouteStatus, kmStaleCandidates })
}).catch(() => {})
.finally(() => setAutomationBriefLoaded(true))
return () => controller.abort()
}, [])
// 自動處置率 — 首頁 KPI 使用 24h truth-chain 驗證率,避免把歷史 disposition 總表誤讀成今日閉環。
const evaluatedAutomationTotal = automationQuality?.evaluated_total ?? 0
const verifiedAutomationTotal = automationQuality?.verified_auto_repair_total ?? 0
const automationQualityAvailable = automationQuality !== null
const unavailableValue = tDashboard('automationDelivery.unavailableValue')
const loadingStatus = tDashboard('automationDelivery.status.loading')
const unavailableStatus = tDashboard('automationDelivery.status.unavailable')
const evaluatedAutomationTotal = automationQuality?.evaluated_total
const verifiedAutomationTotal = automationQuality?.verified_auto_repair_total
const formatAutomationNumber = (value: number | null | undefined, loaded: boolean) => {
if (typeof value === 'number') return value.toLocaleString()
return loaded ? unavailableValue : loadingStatus
}
const autoRemediationRate = (() => {
if (evaluatedAutomationTotal > 0) {
if (typeof evaluatedAutomationTotal === 'number' && evaluatedAutomationTotal > 0 && typeof verifiedAutomationTotal === 'number') {
return `${Math.round((verifiedAutomationTotal / evaluatedAutomationTotal) * 100)}%`
}
return '--'
@@ -585,17 +708,17 @@ export default function Home({ params }: { params: { locale: string } }) {
// 自動處置率數值 (for progress bar)
const autoRemediationPct = (() => {
if (evaluatedAutomationTotal > 0) {
if (typeof evaluatedAutomationTotal === 'number' && evaluatedAutomationTotal > 0 && typeof verifiedAutomationTotal === 'number') {
return Math.round((verifiedAutomationTotal / evaluatedAutomationTotal) * 100)
}
return 0
})()
const autoRemediationTone = automationQuality?.production_claim?.can_claim_full_auto_repair
? '#22C55E'
: evaluatedAutomationTotal > 0
: typeof evaluatedAutomationTotal === 'number' && evaluatedAutomationTotal > 0
? '#F59E0B'
: '#141413'
const autoRemediationDetail = evaluatedAutomationTotal > 0
const autoRemediationDetail = typeof evaluatedAutomationTotal === 'number' && evaluatedAutomationTotal > 0 && typeof verifiedAutomationTotal === 'number'
? tDashboard('autoRepairVerifiedCount', {
verified: verifiedAutomationTotal,
evaluated: evaluatedAutomationTotal,
@@ -606,6 +729,240 @@ export default function Home({ params }: { params: { locale: string } }) {
total: dispositionRate.total.toLocaleString(),
})
: null
const topAutomationGate = automationQuality?.gate_failures?.[0] ?? null
const executionBackend = automationQuality?.execution_backend_summary ?? null
const ansibleRuntime = automationQuality?.ansible_runtime ?? null
const callbackTraceSummary = automationBrief.callbackReplies?.summary ?? null
const missingTraceRecent24h = callbackTraceSummary?.outbound_reply_markup_missing_trace_ref_recent_24h_total ?? 0
const traceRecoveryStatus = callbackTraceSummary?.outbound_reply_markup_trace_ref_gap_recovery_status
?? (automationBriefLoaded ? unavailableValue : loadingStatus)
const traceRecoveryCount = callbackTraceSummary?.outbound_reply_markup_trace_ref_after_gap_total
const aiRouteSelectedProvider = automationBrief.aiRouteStatus?.selected_provider ?? '--'
const aiRouteLaneMode = automationBrief.aiRouteStatus?.lane_mode ?? '--'
const kmStaleTotal = automationBrief.kmStaleCandidates?.total_stale
const kmStaleThreshold = automationBrief.kmStaleCandidates?.threshold_days
const hasCallbackEvidence = automationBrief.callbackReplies !== undefined && automationBrief.callbackReplies !== null
const hasAiRouteStatus = automationBrief.aiRouteStatus !== undefined && automationBrief.aiRouteStatus !== null
const hasKmStaleCandidates = automationBrief.kmStaleCandidates !== undefined && automationBrief.kmStaleCandidates !== null
const canClaimFullAutoRepair = automationQualityAvailable && Boolean(automationQuality?.production_claim?.can_claim_full_auto_repair)
const automationWorkToneStyle: Record<HomepageWorkTone, { bg: string; border: string; color: string }> = {
live: { bg: '#f0faf2', border: '#9bc7a4', color: '#17602a' },
progress: { bg: '#fff7e8', border: '#d9b36f', color: '#8a5a08' },
blocked: { bg: '#fff0ef', border: '#e2a29b', color: '#9f2f25' },
watching: { bg: '#eef5ff', border: '#9bb6d9', color: '#1f5b9b' },
}
const automationDeliveryHeadline = !automationQualityLoaded
? tDashboard('automationDelivery.claimLoading')
: canClaimFullAutoRepair
? tDashboard('automationDelivery.claimReady')
: automationQualityAvailable
? tDashboard('automationDelivery.claimBlocked')
: tDashboard('automationDelivery.claimUnavailable')
const automationDeliveryClaimTone = canClaimFullAutoRepair
? automationWorkToneStyle.live
: automationQualityLoaded && automationQualityAvailable
? automationWorkToneStyle.progress
: automationWorkToneStyle.watching
const automationDeliveryItems: HomepageWorkItemSummary[] = [
{
key: 'cicdTimeline',
title: tDashboard('automationDelivery.delivered.cicdTimeline.title'),
status: tDashboard('automationDelivery.status.live'),
detail: tDashboard('automationDelivery.delivered.cicdTimeline.detail'),
href: `/${locale}/awooop/runs`,
tone: 'live',
},
{
key: 'callbackEvidence',
title: tDashboard('automationDelivery.delivered.callbackEvidence.title'),
status: hasCallbackEvidence
? tDashboard('automationDelivery.status.live')
: automationBriefLoaded
? unavailableStatus
: loadingStatus,
detail: tDashboard('automationDelivery.delivered.callbackEvidence.detail', {
total: formatAutomationNumber(automationBrief.callbackReplies?.total, automationBriefLoaded),
}),
href: `/${locale}/awooop/runs?project_id=awoooi#tg-callback-evidence`,
tone: hasCallbackEvidence ? 'live' : 'watching',
},
{
key: 'callbackTrace',
title: tDashboard('automationDelivery.delivered.callbackTrace.title'),
status: callbackTraceSummary && traceRecoveryStatus === 'recovered_after_gap'
? tDashboard('automationDelivery.status.progress')
: !automationBriefLoaded
? loadingStatus
: callbackTraceSummary
? tDashboard('automationDelivery.status.watching')
: unavailableStatus,
detail: tDashboard('automationDelivery.delivered.callbackTrace.detail', {
status: traceRecoveryStatus,
recovered: formatAutomationNumber(traceRecoveryCount, automationBriefLoaded),
recent24h: formatAutomationNumber(callbackTraceSummary?.outbound_reply_markup_missing_trace_ref_recent_24h_total, automationBriefLoaded),
}),
href: `/${locale}/awooop/work-items?project_id=awoooi`,
tone: traceRecoveryStatus === 'recovered_after_gap' ? 'progress' : 'watching',
},
{
key: 'aiRoute',
title: tDashboard('automationDelivery.delivered.aiRoute.title'),
status: hasAiRouteStatus
? tDashboard('automationDelivery.status.live')
: automationBriefLoaded
? unavailableStatus
: loadingStatus,
detail: tDashboard('automationDelivery.delivered.aiRoute.detail', {
provider: aiRouteSelectedProvider,
lane: aiRouteLaneMode,
}),
href: `/${locale}/awooop/work-items?project_id=awoooi`,
tone: hasAiRouteStatus ? 'live' : 'watching',
},
]
const automationRemainingItems: HomepageWorkItemSummary[] = [
{
key: 'fullAutoRepairClaim',
title: tDashboard('automationDelivery.remaining.fullAutoRepairClaim.title'),
status: !automationQualityLoaded
? loadingStatus
: canClaimFullAutoRepair
? tDashboard('automationDelivery.status.live')
: automationQualityAvailable
? tDashboard('automationDelivery.status.blocked')
: unavailableStatus,
detail: tDashboard('automationDelivery.remaining.fullAutoRepairClaim.detail', {
verified: formatAutomationNumber(verifiedAutomationTotal, automationQualityLoaded),
evaluated: formatAutomationNumber(evaluatedAutomationTotal, automationQualityLoaded),
}),
href: `/${locale}/awooop/work-items?project_id=awoooi`,
tone: canClaimFullAutoRepair ? 'live' : automationQualityAvailable ? 'blocked' : 'watching',
},
{
key: 'qualityGateBacklog',
title: tDashboard('automationDelivery.remaining.qualityGateBacklog.title'),
status: !automationQualityLoaded
? loadingStatus
: topAutomationGate
? tDashboard('automationDelivery.status.blocked')
: automationQualityAvailable
? tDashboard('automationDelivery.status.live')
: unavailableStatus,
detail: tDashboard('automationDelivery.remaining.qualityGateBacklog.detail', {
gate: topAutomationGate?.gate ?? (automationQualityLoaded ? unavailableValue : loadingStatus),
count: formatAutomationNumber(topAutomationGate?.total, automationQualityLoaded),
}),
href: `/${locale}/awooop/runs?project_id=awoooi`,
tone: topAutomationGate ? 'blocked' : automationQualityAvailable ? 'live' : 'watching',
},
{
key: 'ansibleRuntime',
title: tDashboard('automationDelivery.remaining.ansibleRuntime.title'),
status: !automationQualityLoaded
? loadingStatus
: ansibleRuntime?.can_run_check_mode
? tDashboard('automationDelivery.status.live')
: automationQualityAvailable
? tDashboard('automationDelivery.status.blocked')
: unavailableStatus,
detail: tDashboard('automationDelivery.remaining.ansibleRuntime.detail', {
checkMode: formatAutomationNumber(executionBackend?.ansible_check_mode_total, automationQualityLoaded),
pending: formatAutomationNumber(executionBackend?.ansible_pending_check_mode_total, automationQualityLoaded),
blocker: ansibleRuntime?.blockers?.join(' / ') || (automationQualityLoaded ? unavailableValue : loadingStatus),
}),
href: `/${locale}/awooop/work-items?project_id=awoooi`,
tone: ansibleRuntime?.can_run_check_mode ? 'live' : automationQualityAvailable ? 'blocked' : 'watching',
},
{
key: 'kmGovernance',
title: tDashboard('automationDelivery.remaining.kmGovernance.title'),
status: !automationBriefLoaded
? loadingStatus
: typeof kmStaleTotal === 'number' && kmStaleTotal > 0
? tDashboard('automationDelivery.status.progress')
: hasKmStaleCandidates
? tDashboard('automationDelivery.status.live')
: unavailableStatus,
detail: tDashboard('automationDelivery.remaining.kmGovernance.detail', {
stale: formatAutomationNumber(kmStaleTotal, automationBriefLoaded),
days: formatAutomationNumber(kmStaleThreshold, automationBriefLoaded),
}),
href: `/${locale}/awooop/work-items?project_id=awoooi`,
tone: typeof kmStaleTotal === 'number' && kmStaleTotal > 0 ? 'progress' : hasKmStaleCandidates ? 'live' : 'watching',
},
{
key: 'callbackBacklogDecay',
title: tDashboard('automationDelivery.remaining.callbackBacklogDecay.title'),
status: !automationBriefLoaded
? loadingStatus
: callbackTraceSummary && missingTraceRecent24h > 0
? tDashboard('automationDelivery.status.progress')
: callbackTraceSummary
? tDashboard('automationDelivery.status.live')
: unavailableStatus,
detail: tDashboard('automationDelivery.remaining.callbackBacklogDecay.detail', {
recent1h: formatAutomationNumber(callbackTraceSummary?.outbound_reply_markup_missing_trace_ref_recent_1h_total, automationBriefLoaded),
recent24h: formatAutomationNumber(callbackTraceSummary?.outbound_reply_markup_missing_trace_ref_recent_24h_total, automationBriefLoaded),
missing: formatAutomationNumber(callbackTraceSummary?.outbound_reply_markup_missing_trace_ref_total, automationBriefLoaded),
}),
href: `/${locale}/awooop/runs?project_id=awoooi#tg-callback-evidence`,
tone: callbackTraceSummary && missingTraceRecent24h > 0 ? 'progress' : callbackTraceSummary ? 'live' : 'watching',
},
]
const productDiagramCards = [
{
key: 'c4Runtime',
title: tDashboard('automationDiagrams.cards.c4Runtime.title'),
standard: tDashboard('automationDiagrams.cards.c4Runtime.standard'),
detail: tDashboard('automationDiagrams.cards.c4Runtime.detail'),
href: `/${locale}/topology`,
nodes: [
tDashboard('automationDiagrams.cards.c4Runtime.nodes.user'),
tDashboard('automationDiagrams.cards.c4Runtime.nodes.web'),
tDashboard('automationDiagrams.cards.c4Runtime.nodes.api'),
tDashboard('automationDiagrams.cards.c4Runtime.nodes.k8s'),
],
},
{
key: 'incidentFlow',
title: tDashboard('automationDiagrams.cards.incidentFlow.title'),
standard: tDashboard('automationDiagrams.cards.incidentFlow.standard'),
detail: tDashboard('automationDiagrams.cards.incidentFlow.detail'),
href: `/${locale}/awooop/runs?project_id=awoooi`,
nodes: [
tDashboard('automationDiagrams.cards.incidentFlow.nodes.alert'),
tDashboard('automationDiagrams.cards.incidentFlow.nodes.ai'),
tDashboard('automationDiagrams.cards.incidentFlow.nodes.playbook'),
tDashboard('automationDiagrams.cards.incidentFlow.nodes.verify'),
],
},
{
key: 'decisionRules',
title: tDashboard('automationDiagrams.cards.decisionRules.title'),
standard: tDashboard('automationDiagrams.cards.decisionRules.standard'),
detail: tDashboard('automationDiagrams.cards.decisionRules.detail'),
href: `/${locale}/awooop/approvals`,
nodes: [
tDashboard('automationDiagrams.cards.decisionRules.nodes.risk'),
tDashboard('automationDiagrams.cards.decisionRules.nodes.confidence'),
tDashboard('automationDiagrams.cards.decisionRules.nodes.policy'),
tDashboard('automationDiagrams.cards.decisionRules.nodes.approval'),
],
},
{
key: 'evidenceLineage',
title: tDashboard('automationDiagrams.cards.evidenceLineage.title'),
standard: tDashboard('automationDiagrams.cards.evidenceLineage.standard'),
detail: tDashboard('automationDiagrams.cards.evidenceLineage.detail'),
href: `/${locale}/awooop/runs?project_id=awoooi#tg-callback-evidence`,
nodes: [
tDashboard('automationDiagrams.cards.evidenceLineage.nodes.telegram'),
tDashboard('automationDiagrams.cards.evidenceLineage.nodes.db'),
tDashboard('automationDiagrams.cards.evidenceLineage.nodes.trace'),
tDashboard('automationDiagrams.cards.evidenceLineage.nodes.km'),
],
},
]
// ── 5 KPI Cards (Sprint 5R 設計稿批准版) ────────────────────────────────────
@@ -718,6 +1075,262 @@ export default function Home({ params }: { params: { locale: string } }) {
overflow: 'hidden',
}}>
<section style={{
margin: '14px 20px 0',
background: '#fff',
border: '0.5px solid #d8d3c7',
borderRadius: 10,
boxShadow: '0 1px 4px rgba(0,0,0,0.05)',
overflow: 'hidden',
flexShrink: 0,
}}>
<div style={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 16,
padding: '14px 16px',
borderBottom: '0.5px solid #e0ddd4',
background: '#faf9f3',
}}>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.5, color: '#77736a', fontWeight: 700 }}>
{tDashboard('automationDelivery.eyebrow')}
</div>
<h1 style={{ margin: '4px 0 0', fontSize: 22, lineHeight: 1.15, fontWeight: 800, color: '#141413' }}>
{tDashboard('automationDelivery.title')}
</h1>
<p style={{ margin: '6px 0 0', maxWidth: 840, fontSize: 13, lineHeight: 1.6, color: '#5f5b52' }}>
{tDashboard('automationDelivery.subtitle')}
</p>
</div>
<div style={{
minWidth: 220,
border: `0.5px solid ${automationDeliveryClaimTone.border}`,
background: automationDeliveryClaimTone.bg,
borderRadius: 8,
padding: '10px 12px',
}}>
<div style={{ fontSize: 10, textTransform: 'uppercase', letterSpacing: 0.5, color: '#77736a', fontWeight: 700 }}>
{tDashboard('automationDelivery.claimLabel')}
</div>
<div style={{ marginTop: 4, fontSize: 15, fontWeight: 800, color: automationDeliveryClaimTone.color }}>
{automationDeliveryHeadline}
</div>
<div style={{ marginTop: 4, fontSize: 11, color: '#5f5b52', lineHeight: 1.5 }}>
{tDashboard('automationDelivery.claimDetail', {
verified: formatAutomationNumber(verifiedAutomationTotal, automationQualityLoaded),
evaluated: formatAutomationNumber(evaluatedAutomationTotal, automationQualityLoaded),
score: automationQuality?.average_score != null
? automationQuality.average_score.toFixed(1)
: automationQualityLoaded ? unavailableValue : loadingStatus,
})}
</div>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: 14, padding: 14 }}>
<div>
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12, marginBottom: 8 }}>
<h2 style={{ margin: 0, fontSize: 14, fontWeight: 800, color: '#141413' }}>
{tDashboard('automationDelivery.deliveredTitle')}
</h2>
<a href={`/${locale}/awooop/work-items?project_id=awoooi`} style={{ fontSize: 11, fontWeight: 700, color: '#1f5b9b', textDecoration: 'none' }}>
{tDashboard('automationDelivery.openWorkItems')}
</a>
</div>
<div style={{ display: 'grid', gap: 8 }}>
{automationDeliveryItems.map(item => {
const tone = automationWorkToneStyle[item.tone]
return (
<a
key={item.key}
href={item.href}
style={{
display: 'grid',
gridTemplateColumns: '1fr auto',
gap: 10,
padding: '10px 12px',
border: '0.5px solid #e0ddd4',
borderRadius: 8,
background: '#fbfaf6',
color: 'inherit',
textDecoration: 'none',
}}
>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 800, color: '#141413' }}>{item.title}</div>
<div style={{ marginTop: 4, fontSize: 11, lineHeight: 1.5, color: '#5f5b52' }}>{item.detail}</div>
</div>
<span style={{
alignSelf: 'start',
border: `0.5px solid ${tone.border}`,
background: tone.bg,
color: tone.color,
borderRadius: 999,
padding: '2px 8px',
fontSize: 10,
fontWeight: 800,
whiteSpace: 'nowrap',
}}>
{item.status}
</span>
</a>
)
})}
</div>
</div>
<div>
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12, marginBottom: 8 }}>
<h2 style={{ margin: 0, fontSize: 14, fontWeight: 800, color: '#141413' }}>
{tDashboard('automationDelivery.remainingTitle')}
</h2>
<a href={`/${locale}/awooop/runs?project_id=awoooi`} style={{ fontSize: 11, fontWeight: 700, color: '#1f5b9b', textDecoration: 'none' }}>
{tDashboard('automationDelivery.openRuns')}
</a>
</div>
<div style={{ display: 'grid', gap: 8 }}>
{automationRemainingItems.map(item => {
const tone = automationWorkToneStyle[item.tone]
return (
<a
key={item.key}
href={item.href}
style={{
display: 'grid',
gridTemplateColumns: '1fr auto',
gap: 10,
padding: '10px 12px',
border: `0.5px solid ${tone.border}`,
borderRadius: 8,
background: tone.bg,
color: 'inherit',
textDecoration: 'none',
}}
>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 800, color: '#141413' }}>{item.title}</div>
<div style={{ marginTop: 4, fontSize: 11, lineHeight: 1.5, color: '#5f5b52' }}>{item.detail}</div>
</div>
<span style={{
alignSelf: 'start',
border: `0.5px solid ${tone.border}`,
background: '#fff',
color: tone.color,
borderRadius: 999,
padding: '2px 8px',
fontSize: 10,
fontWeight: 800,
whiteSpace: 'nowrap',
}}>
{item.status}
</span>
</a>
)
})}
</div>
</div>
</div>
</section>
<section style={{
margin: '12px 20px 0',
background: '#fff',
border: '0.5px solid #d8d3c7',
borderRadius: 10,
boxShadow: '0 1px 4px rgba(0,0,0,0.05)',
overflow: 'hidden',
flexShrink: 0,
}}>
<div style={{
display: 'flex',
alignItems: 'baseline',
justifyContent: 'space-between',
gap: 12,
padding: '12px 16px',
borderBottom: '0.5px solid #e0ddd4',
background: '#faf9f3',
}}>
<div>
<div style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.5, color: '#77736a', fontWeight: 700 }}>
{tDashboard('automationDiagrams.eyebrow')}
</div>
<h2 style={{ margin: '3px 0 0', fontSize: 16, fontWeight: 800, color: '#141413' }}>
{tDashboard('automationDiagrams.title')}
</h2>
</div>
<a href={`/${locale}/topology`} style={{ fontSize: 11, fontWeight: 700, color: '#1f5b9b', textDecoration: 'none' }}>
{tDashboard('automationDiagrams.openTopology')}
</a>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: 10, padding: 14 }}>
{productDiagramCards.map(card => (
<a
key={card.key}
href={card.href}
style={{
display: 'flex',
flexDirection: 'column',
gap: 10,
minHeight: 188,
border: '0.5px solid #e0ddd4',
borderRadius: 8,
background: '#fbfaf6',
padding: 12,
color: 'inherit',
textDecoration: 'none',
}}
>
<div>
<div style={{ fontSize: 10, fontWeight: 800, color: '#1f5b9b', textTransform: 'uppercase', letterSpacing: 0.5 }}>
{card.standard}
</div>
<div style={{ marginTop: 4, fontSize: 13, fontWeight: 800, color: '#141413' }}>
{card.title}
</div>
<div style={{ marginTop: 4, fontSize: 11, lineHeight: 1.5, color: '#5f5b52' }}>
{card.detail}
</div>
</div>
<div style={{ marginTop: 'auto', display: 'grid', gap: 7 }}>
{card.nodes.map((node, index) => (
<div key={`${card.key}-${node}`} style={{ display: 'grid', gridTemplateColumns: '22px 1fr', gap: 8, alignItems: 'center' }}>
<div style={{
width: 22,
height: 22,
borderRadius: 999,
border: '0.5px solid #9bb6d9',
background: '#eef5ff',
color: '#1f5b9b',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 10,
fontWeight: 800,
fontFamily: "'JetBrains Mono', monospace",
}}>
{index + 1}
</div>
<div style={{
border: '0.5px solid #d8d3c7',
background: '#fff',
borderRadius: 6,
padding: '5px 8px',
fontSize: 11,
fontWeight: 700,
color: '#2e2b26',
}}>
{node}
</div>
</div>
))}
</div>
</a>
))}
</div>
</section>
{/* ── KPI Strip (5 卡片 — Sprint 5R 設計稿) ──────────────────────── */}
<div style={{ display: 'flex', gap: 12, padding: '10px 20px', flexShrink: 0 }}>
{/* 系統健康 */}