fix(web): expose approval executor handoff readiness
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
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({
|
||||
<p className="mt-3 text-xs leading-5 text-[#5f5b52]">
|
||||
{linkedApprovalIds.length > 0 ? t("linkedExplanation") : t("unlinkedExplanation")}
|
||||
</p>
|
||||
<ExecutorHandoffReadinessCard
|
||||
projectId={projectId}
|
||||
incidentId={incidentId}
|
||||
chain={chain}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user