fix(web): expose approval executor handoff readiness
Some checks failed
Code Review / ai-code-review (push) Successful in 13s
CD Pipeline / tests (push) Successful in 1m36s
CD Pipeline / post-deploy-checks (push) Has been cancelled
CD Pipeline / build-and-deploy (push) Has been cancelled

This commit is contained in:
Your Name
2026-06-26 23:26:04 +08:00
parent 4b0514def5
commit 2239507e0e
3 changed files with 200 additions and 1 deletions

View File

@@ -11558,6 +11558,26 @@
"nextAction": "下一步", "nextAction": "下一步",
"reason": "原因" "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": { "evidence": {
"executor": "執行器", "executor": "執行器",
"ansible": "Ansible", "ansible": "Ansible",

View File

@@ -11558,6 +11558,26 @@
"nextAction": "下一步", "nextAction": "下一步",
"reason": "原因" "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": { "evidence": {
"executor": "執行器", "executor": "執行器",
"ansible": "Ansible", "ansible": "Ansible",

View File

@@ -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 (
<div className="mt-3 border border-[#d9b36f] bg-[#fffaf0]">
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-[#ead9b4] bg-[#fff7e8] px-3 py-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<GitBranch className="h-4 w-4 text-[#8a5a08]" aria-hidden="true" />
<h4 className="text-sm font-semibold text-[#141413]">{t("title")}</h4>
</div>
<p className="mt-1 text-xs leading-5 text-[#5f5b52]">{t("subtitle")}</p>
</div>
<span className={cn(
"inline-flex shrink-0 items-center gap-1.5 border px-2 py-1 font-mono text-xs font-semibold",
runtimeAllowed
? "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]"
: "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]"
)}>
{runtimeAllowed ? t("runtime.open") : t("runtime.closed")}
</span>
</div>
<div className="grid gap-px bg-[#ead9b4] md:grid-cols-4">
{[
[t("metrics.readiness"), `${readinessPercent}%`],
[t("metrics.ready"), `${ready}/${total || "--"}`],
[t("metrics.blocked"), String(blocked)],
[t("metrics.status"), status],
].map(([label, value]) => (
<div key={label} className="min-w-0 bg-white px-3 py-2">
<p className="text-xs font-semibold text-[#77736a]">{label}</p>
<p className="mt-1 break-words font-mono text-sm font-semibold text-[#141413]" title={String(value)}>
{value}
</p>
</div>
))}
</div>
<div className="p-3">
<div className="h-2 border border-[#d8d3c7] bg-white">
<div
className={cn("h-full", runtimeAllowed ? "bg-[#2f7d72]" : "bg-[#d97757]")}
style={{ width: `${readinessPercent}%` }}
aria-hidden="true"
/>
</div>
<div className="mt-3 grid gap-2 md:grid-cols-2">
<div className="min-w-0 border border-[#ead9b4] bg-white px-3 py-2">
<p className="text-xs font-semibold text-[#77736a]">{t("nextAction")}</p>
<p className="mt-1 break-words font-mono text-xs leading-5 text-[#141413]">{nextAction}</p>
</div>
<div className="min-w-0 border border-[#ead9b4] bg-white px-3 py-2">
<p className="text-xs font-semibold text-[#77736a]">{t("blocker")}</p>
<p className="mt-1 break-words font-mono text-xs leading-5 text-[#141413]">{blocker}</p>
</div>
</div>
<div className="mt-3 border border-[#e2a29b] bg-[#fff0ef] px-3 py-2">
<p className="text-xs font-semibold text-[#9f2f25]">{t("missingTitle")}</p>
<div className="mt-2 flex flex-wrap gap-1.5">
{blockedFields.length > 0 ? blockedFields.map((field) => (
<span key={field} className="border border-[#e2a29b] bg-white px-2 py-0.5 font-mono text-[10px] font-semibold text-[#9f2f25]">
{field}
</span>
)) : (
<span className="border border-[#d8d3c7] bg-white px-2 py-0.5 text-[11px] font-semibold text-[#5f5b52]">
{t("missingEmpty")}
</span>
)}
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Link
href={workItemHref as never}
className="inline-flex items-center gap-1.5 border border-[#d9b36f] bg-white px-3 py-2 text-xs font-semibold text-[#8a5a08] hover:bg-[#fff7e8]"
>
{t("openWorkItem")}
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
</Link>
<Link
href={runsHref as never}
className="inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-white px-3 py-2 text-xs font-semibold text-[#141413] hover:border-[#1f6feb]"
>
{t("openRuns")}
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
</Link>
</div>
</div>
</div>
);
}
function approvalDecisionRailToneClass(tone: ApprovalDecisionRailTone) { function approvalDecisionRailToneClass(tone: ApprovalDecisionRailTone) {
if (tone === "ok") return "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]"; if (tone === "ok") return "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]";
if (tone === "blocked") return "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]"; if (tone === "blocked") return "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]";
@@ -1459,6 +1612,11 @@ function FocusedIncidentApprovalPanel({
<p className="mt-3 text-xs leading-5 text-[#5f5b52]"> <p className="mt-3 text-xs leading-5 text-[#5f5b52]">
{linkedApprovalIds.length > 0 ? t("linkedExplanation") : t("unlinkedExplanation")} {linkedApprovalIds.length > 0 ? t("linkedExplanation") : t("unlinkedExplanation")}
</p> </p>
<ExecutorHandoffReadinessCard
projectId={projectId}
incidentId={incidentId}
chain={chain}
/>
</div> </div>
</div> </div>
@@ -1530,6 +1688,7 @@ export default function ApprovalsPage() {
setError(null); setError(null);
setLegacyError(null); setLegacyError(null);
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set("project_id", projectId);
if (evidenceFilter) params.set("remediation_status", evidenceFilter); if (evidenceFilter) params.set("remediation_status", evidenceFilter);
const qs = params.toString(); const qs = params.toString();
const [platformResult, legacyResult] = await Promise.allSettled([ const [platformResult, legacyResult] = await Promise.allSettled([
@@ -1560,7 +1719,7 @@ export default function ApprovalsPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [evidenceFilter, t]); }, [evidenceFilter, projectId, t]);
useEffect(() => { useEffect(() => {
fetchApprovals(); fetchApprovals();