feat(web): show automation product work map
This commit is contained in:
@@ -292,6 +292,121 @@
|
||||
"unknown": "Unknown state"
|
||||
},
|
||||
"topGap": "Largest current gap: {gate}, {count} items."
|
||||
},
|
||||
"automationDelivery": {
|
||||
"eyebrow": "AI Automation Product Surface",
|
||||
"title": "Delivered Work And Remaining Work",
|
||||
"subtitle": "The homepage now summarizes production truth-chain, Telegram callbacks, AI providers, KM, Ansible, and auto-repair quality instead of vague KPIs.",
|
||||
"claimLabel": "Full Auto-Repair Claim",
|
||||
"claimReady": "Full loop can be claimed",
|
||||
"claimBlocked": "Full loop cannot be claimed yet",
|
||||
"claimLoading": "Reading production truth",
|
||||
"claimUnavailable": "Production truth is not responding",
|
||||
"claimDetail": "Verified {verified}/{evaluated}, average score {score}",
|
||||
"unavailableValue": "no response",
|
||||
"deliveredTitle": "Delivered Capabilities",
|
||||
"remainingTitle": "Remaining Gaps",
|
||||
"openWorkItems": "Open Work Items",
|
||||
"openRuns": "Open Runs",
|
||||
"status": {
|
||||
"live": "Live",
|
||||
"progress": "In Progress",
|
||||
"blocked": "Blocked",
|
||||
"watching": "Watching",
|
||||
"loading": "Loading",
|
||||
"unavailable": "No response"
|
||||
},
|
||||
"delivered": {
|
||||
"cicdTimeline": {
|
||||
"title": "CI/CD notifications enter AwoooP Timeline",
|
||||
"detail": "Gitea main deploys, deploy markers, and post-deploy notifications flow through the AWOOI API and AwoooP Run Timeline."
|
||||
},
|
||||
"callbackEvidence": {
|
||||
"title": "Telegram detail / history DB truth chain",
|
||||
"detail": "{total} callback evidence rows are available for Run detail, history, and snapshot lookup."
|
||||
},
|
||||
"callbackTrace": {
|
||||
"title": "Callback trace recovery and backlog action lens",
|
||||
"detail": "Recovery {status}, traced after gap {recovered}, 24h backlog {recent24h}."
|
||||
},
|
||||
"aiRoute": {
|
||||
"title": "AI Provider lane visibility",
|
||||
"detail": "Current lane={lane}, selected provider={provider}; governance order is GCP-A / GCP-B / 111 / Gemini."
|
||||
}
|
||||
},
|
||||
"remaining": {
|
||||
"fullAutoRepairClaim": {
|
||||
"title": "Full auto-repair loop",
|
||||
"detail": "Production quality is verified {verified}/{evaluated}; the system cannot claim full automation before this reaches the gate."
|
||||
},
|
||||
"qualityGateBacklog": {
|
||||
"title": "Auto-repair quality gate backlog",
|
||||
"detail": "Top gap {gate}, {count} rows; execution, auto-repair, approval, or learning evidence must be completed."
|
||||
},
|
||||
"ansibleRuntime": {
|
||||
"title": "Ansible check-mode / apply wiring",
|
||||
"detail": "check-mode {checkMode}, pending check-mode {pending}; blocker={blocker}."
|
||||
},
|
||||
"kmGovernance": {
|
||||
"title": "Stale KM governance",
|
||||
"detail": "{stale} KM rows are older than {days} days; Hermes drafts, owner review, and writeback remain."
|
||||
},
|
||||
"callbackBacklogDecay": {
|
||||
"title": "Callback legacy backlog 24h decay",
|
||||
"detail": "Missing trace total {missing}, 1h {recent1h}, 24h {recent24h}; closes only when 24h reaches zero."
|
||||
}
|
||||
}
|
||||
},
|
||||
"automationDiagrams": {
|
||||
"eyebrow": "Professional Visual Views",
|
||||
"title": "Technical Diagrams For The Product",
|
||||
"openTopology": "Open Topology",
|
||||
"cards": {
|
||||
"c4Runtime": {
|
||||
"standard": "C4 / Deployment",
|
||||
"title": "Product Architecture And Runtime Topology",
|
||||
"detail": "Use C4 layers to explain users, Web, API, K8s, databases, external tools, and model providers.",
|
||||
"nodes": {
|
||||
"user": "Operator / Tenant",
|
||||
"web": "AwoooP Web",
|
||||
"api": "AWOOI API",
|
||||
"k8s": "K8s / Providers"
|
||||
}
|
||||
},
|
||||
"incidentFlow": {
|
||||
"standard": "BPMN / Swimlane",
|
||||
"title": "Alert-To-Repair Flow",
|
||||
"detail": "Use swimlanes to separate Telegram, OpenClaw, Hermes, MCP, Ansible, human approval, and verification ownership.",
|
||||
"nodes": {
|
||||
"alert": "Alert / Sentry / SigNoz",
|
||||
"ai": "AI Analysis",
|
||||
"playbook": "PlayBook / MCP",
|
||||
"verify": "Verify / KM"
|
||||
}
|
||||
},
|
||||
"decisionRules": {
|
||||
"standard": "DMN / Decision Table",
|
||||
"title": "AI Decision And Approval Rules",
|
||||
"detail": "Represent risk, confidence, policy, model routing, and auto-repair eligibility as auditable decision tables.",
|
||||
"nodes": {
|
||||
"risk": "Risk",
|
||||
"confidence": "Confidence",
|
||||
"policy": "Policy",
|
||||
"approval": "Approval"
|
||||
}
|
||||
},
|
||||
"evidenceLineage": {
|
||||
"standard": "Trace / Lineage",
|
||||
"title": "Evidence Chain And Callback Trace",
|
||||
"detail": "Show whether Telegram messages, DB events, Run Timeline, and KM / PlayBook writeback agree.",
|
||||
"nodes": {
|
||||
"telegram": "Telegram",
|
||||
"db": "DB Truth",
|
||||
"trace": "Run Trace",
|
||||
"km": "KM / PlayBook"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"openclaw": {
|
||||
|
||||
@@ -293,6 +293,121 @@
|
||||
"unknown": "狀態未知"
|
||||
},
|
||||
"topGap": "目前最大缺口:{gate},共 {count} 筆。"
|
||||
},
|
||||
"automationDelivery": {
|
||||
"eyebrow": "AI 自動化管理產品面",
|
||||
"title": "目前完成項與待推進項",
|
||||
"subtitle": "首頁直接呈現 production 真相鏈、Telegram callback、AI provider、KM、Ansible 與自動修復品質,不再只放空泛 KPI。",
|
||||
"claimLabel": "完整自動修復宣稱",
|
||||
"claimReady": "可宣稱完整閉環",
|
||||
"claimBlocked": "尚不可宣稱完整閉環",
|
||||
"claimLoading": "正在讀取 production 真相",
|
||||
"claimUnavailable": "production 真相暫時未回應",
|
||||
"claimDetail": "已驗證 {verified}/{evaluated},平均分數 {score}",
|
||||
"unavailableValue": "未回應",
|
||||
"deliveredTitle": "已上線能力",
|
||||
"remainingTitle": "仍待推進缺口",
|
||||
"openWorkItems": "打開 Work Items",
|
||||
"openRuns": "打開 Runs",
|
||||
"status": {
|
||||
"live": "已上線",
|
||||
"progress": "推進中",
|
||||
"blocked": "阻塞",
|
||||
"watching": "觀察",
|
||||
"loading": "讀取中",
|
||||
"unavailable": "未回應"
|
||||
},
|
||||
"delivered": {
|
||||
"cicdTimeline": {
|
||||
"title": "CI/CD 通知進 AwoooP Timeline",
|
||||
"detail": "Gitea main 推版、deploy marker、post-deploy 通知已走 AWOOI API 與 AwoooP Run Timeline。"
|
||||
},
|
||||
"callbackEvidence": {
|
||||
"title": "Telegram 詳情 / 歷史 DB 真相鏈",
|
||||
"detail": "callback evidence 目前 {total} 筆,可從 Runs 反查詳情、歷史與快照。"
|
||||
},
|
||||
"callbackTrace": {
|
||||
"title": "Callback trace 復原與 backlog action lens",
|
||||
"detail": "復原狀態 {status},gap 後 traced {recovered},24h backlog {recent24h}。"
|
||||
},
|
||||
"aiRoute": {
|
||||
"title": "AI Provider lane 可視化",
|
||||
"detail": "目前 lane={lane},selected provider={provider};順序以 GCP-A / GCP-B / 111 / Gemini 為治理方向。"
|
||||
}
|
||||
},
|
||||
"remaining": {
|
||||
"fullAutoRepairClaim": {
|
||||
"title": "完整自動修復閉環",
|
||||
"detail": "production quality 目前 verified {verified}/{evaluated};未達標前不能宣稱全自動完成。"
|
||||
},
|
||||
"qualityGateBacklog": {
|
||||
"title": "自動修復品質閘門缺口",
|
||||
"detail": "最大缺口 {gate},目前 {count} 筆;需補 execution、auto-repair、approval 或 learning evidence。"
|
||||
},
|
||||
"ansibleRuntime": {
|
||||
"title": "Ansible check-mode / apply 接線",
|
||||
"detail": "check-mode {checkMode},待 check-mode {pending};目前 blocker={blocker}。"
|
||||
},
|
||||
"kmGovernance": {
|
||||
"title": "KM 陳舊資料治理",
|
||||
"detail": "超過 {days} 天未更新 KM:{stale} 筆;需 Hermes 產草稿、owner 審核後回寫。"
|
||||
},
|
||||
"callbackBacklogDecay": {
|
||||
"title": "Callback legacy backlog 24h decay",
|
||||
"detail": "缺 trace 總數 {missing},1h {recent1h},24h {recent24h};24h 歸零才算關閉。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"automationDiagrams": {
|
||||
"eyebrow": "專業圖像化視圖",
|
||||
"title": "產品要用哪些圖來呈現",
|
||||
"openTopology": "查看拓樸圖",
|
||||
"cards": {
|
||||
"c4Runtime": {
|
||||
"standard": "C4 / Deployment",
|
||||
"title": "產品架構與 Runtime 拓樸",
|
||||
"detail": "用 C4 分層說明使用者、Web、API、K8s、資料庫、外部工具與模型供應商的關係。",
|
||||
"nodes": {
|
||||
"user": "Operator / Tenant",
|
||||
"web": "AwoooP Web",
|
||||
"api": "AWOOI API",
|
||||
"k8s": "K8s / Providers"
|
||||
}
|
||||
},
|
||||
"incidentFlow": {
|
||||
"standard": "BPMN / Swimlane",
|
||||
"title": "告警到修復流程",
|
||||
"detail": "用泳道圖拆開 Telegram、OpenClaw、Hermes、MCP、Ansible、人工審批與驗證責任。",
|
||||
"nodes": {
|
||||
"alert": "Alert / Sentry / SigNoz",
|
||||
"ai": "AI 分析",
|
||||
"playbook": "PlayBook / MCP",
|
||||
"verify": "驗證 / KM"
|
||||
}
|
||||
},
|
||||
"decisionRules": {
|
||||
"standard": "DMN / Decision Table",
|
||||
"title": "AI 判斷與審批規則",
|
||||
"detail": "把風險、信心分數、政策、模型路由與是否自動修復整理成可稽核決策表。",
|
||||
"nodes": {
|
||||
"risk": "Risk",
|
||||
"confidence": "Confidence",
|
||||
"policy": "Policy",
|
||||
"approval": "Approval"
|
||||
}
|
||||
},
|
||||
"evidenceLineage": {
|
||||
"standard": "Trace / Lineage",
|
||||
"title": "證據鏈與 Callback Trace",
|
||||
"detail": "用 trace lineage 呈現 Telegram 訊息、DB 事件、Run Timeline、KM / PlayBook 回寫是否一致。",
|
||||
"nodes": {
|
||||
"telegram": "Telegram",
|
||||
"db": "DB Truth",
|
||||
"trace": "Run Trace",
|
||||
"km": "KM / PlayBook"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"openclaw": {
|
||||
|
||||
@@ -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 }}>
|
||||
{/* 系統健康 */}
|
||||
|
||||
Reference in New Issue
Block a user