From 2239507e0e6c35cf76ecc7d17e9e8d8e2cd2f7b1 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 26 Jun 2026 23:26:04 +0800 Subject: [PATCH] fix(web): expose approval executor handoff readiness --- apps/web/messages/en.json | 20 +++ apps/web/messages/zh-TW.json | 20 +++ .../app/[locale]/awooop/approvals/page.tsx | 161 +++++++++++++++++- 3 files changed, 200 insertions(+), 1 deletion(-) diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index c86fc91a..a3783e09 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -11558,6 +11558,26 @@ "nextAction": "下一步", "reason": "原因" }, + "executorHandoff": { + "title": "Executor handoff readiness", + "subtitle": "把批准後是否能進執行器、缺少的 owner 欄位與 verifier 條件集中顯示;批准此卡不代表 runtime gate 已開。", + "runtime": { + "open": "runtime gate open", + "closed": "runtime gate closed" + }, + "metrics": { + "readiness": "可交接度", + "ready": "已備妥", + "blocked": "卡點", + "status": "狀態" + }, + "nextAction": "下一步", + "blocker": "阻擋原因", + "missingTitle": "缺少的 owner review / 安全路由欄位", + "missingEmpty": "未回報缺欄位;請仍以 runtime gate 與 verifier 為準", + "openWorkItem": "開啟 owner review", + "openRuns": "追蹤 Runs" + }, "evidence": { "executor": "執行器", "ansible": "Ansible", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index c86fc91a..a3783e09 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -11558,6 +11558,26 @@ "nextAction": "下一步", "reason": "原因" }, + "executorHandoff": { + "title": "Executor handoff readiness", + "subtitle": "把批准後是否能進執行器、缺少的 owner 欄位與 verifier 條件集中顯示;批准此卡不代表 runtime gate 已開。", + "runtime": { + "open": "runtime gate open", + "closed": "runtime gate closed" + }, + "metrics": { + "readiness": "可交接度", + "ready": "已備妥", + "blocked": "卡點", + "status": "狀態" + }, + "nextAction": "下一步", + "blocker": "阻擋原因", + "missingTitle": "缺少的 owner review / 安全路由欄位", + "missingEmpty": "未回報缺欄位;請仍以 runtime gate 與 verifier 為準", + "openWorkItem": "開啟 owner review", + "openRuns": "追蹤 Runs" + }, "evidence": { "executor": "執行器", "ansible": "Ansible", diff --git a/apps/web/src/app/[locale]/awooop/approvals/page.tsx b/apps/web/src/app/[locale]/awooop/approvals/page.tsx index 547bf3a4..2089705c 100644 --- a/apps/web/src/app/[locale]/awooop/approvals/page.tsx +++ b/apps/web/src/app/[locale]/awooop/approvals/page.tsx @@ -633,6 +633,159 @@ function Gate5ProjectionBadge() { ); } +function handoffWorkItemHref( + projectId: string, + incidentId: string, + chain: AwoooPStatusChain | null +) { + const promotion = chain?.repair_candidate_promotion; + const href = promotion?.work_item_url + ?? promotion?.contract?.source_work_item_url + ?? null; + if (href && href.startsWith("/")) return href; + + const workItemId = promotion?.work_item_id + ?? promotion?.contract?.source_work_item_id + ?? chain?.automation_handoff?.work_item_id + ?? ""; + const params = new URLSearchParams({ project_id: projectId, incident_id: incidentId }); + if (workItemId) params.set("work_item_id", workItemId); + return `/awooop/work-items?${params.toString()}`; +} + +function ExecutorHandoffReadinessCard({ + projectId, + incidentId, + chain, +}: { + projectId: string; + incidentId: string; + chain: AwoooPStatusChain | null; +}) { + const t = useTranslations("awooop.approvals.incidentFocus.executorHandoff"); + const promotion = chain?.repair_candidate_promotion; + const contract = promotion?.contract; + const closure = chain?.automation_handoff?.closure_readiness; + const runtimeAllowed = Boolean( + promotion?.runtime_execution_authorized + || contract?.runtime_execution_authorized + || closure?.runtime_execution_authorized + || chain?.automation_handoff?.runtime_execution_authorized + ); + const ready = Number(contract?.ready_count ?? closure?.ready_count ?? 0); + const total = Number(contract?.total_count ?? closure?.total_count ?? 0); + const blocked = Number(contract?.blocked_count ?? closure?.blocked_count ?? 0); + const readinessPercent = total > 0 ? Math.min(100, Math.round((ready / total) * 100)) : 0; + const status = promotion?.status ?? contract?.status ?? closure?.status ?? chain?.automation_handoff?.status ?? "not_available"; + const nextAction = chain?.automation_handoff?.next_action + ?? closure?.next_action + ?? promotion?.summary + ?? chain?.operator_outcome?.next_action + ?? chain?.next_step + ?? "--"; + const blocker = closure?.blocked_reason + ?? promotion?.reason + ?? chain?.operator_outcome?.human_action_reason + ?? chain?.blockers?.[0] + ?? "--"; + const blockedFields = [ + ...(contract?.blocked_fields ?? []), + ...(closure?.required_owner_fields ?? []), + ].filter((field, index, fields) => field && fields.indexOf(field) === index).slice(0, 8); + const workItemHref = handoffWorkItemHref(projectId, incidentId, chain); + const runsHref = `/awooop/runs?project_id=${encodeURIComponent(projectId)}&incident_id=${encodeURIComponent(incidentId)}`; + + return ( +
+
+
+
+
+

{t("subtitle")}

+
+ + {runtimeAllowed ? t("runtime.open") : t("runtime.closed")} + +
+ +
+ {[ + [t("metrics.readiness"), `${readinessPercent}%`], + [t("metrics.ready"), `${ready}/${total || "--"}`], + [t("metrics.blocked"), String(blocked)], + [t("metrics.status"), status], + ].map(([label, value]) => ( +
+

{label}

+

+ {value} +

+
+ ))} +
+ +
+
+ +
+
+

{t("nextAction")}

+

{nextAction}

+
+
+

{t("blocker")}

+

{blocker}

+
+
+ +
+

{t("missingTitle")}

+
+ {blockedFields.length > 0 ? blockedFields.map((field) => ( + + {field} + + )) : ( + + {t("missingEmpty")} + + )} +
+
+ +
+ + {t("openWorkItem")} +
+
+
+ ); +} + function approvalDecisionRailToneClass(tone: ApprovalDecisionRailTone) { if (tone === "ok") return "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]"; if (tone === "blocked") return "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]"; @@ -1459,6 +1612,11 @@ function FocusedIncidentApprovalPanel({

{linkedApprovalIds.length > 0 ? t("linkedExplanation") : t("unlinkedExplanation")}

+
@@ -1530,6 +1688,7 @@ export default function ApprovalsPage() { setError(null); setLegacyError(null); const params = new URLSearchParams(); + params.set("project_id", projectId); if (evidenceFilter) params.set("remediation_status", evidenceFilter); const qs = params.toString(); const [platformResult, legacyResult] = await Promise.allSettled([ @@ -1560,7 +1719,7 @@ export default function ApprovalsPage() { } finally { setLoading(false); } - }, [evidenceFilter, t]); + }, [evidenceFilter, projectId, t]); useEffect(() => { fetchApprovals();