feat(web): 顯示修復候選草案處置板
All checks were successful
CD Pipeline / tests (push) Successful in 1m39s
Code Review / ai-code-review (push) Successful in 8s
CD Pipeline / build-and-deploy (push) Successful in 4m10s
CD Pipeline / post-deploy-checks (push) Successful in 1m28s

This commit is contained in:
Your Name
2026-06-11 19:12:12 +08:00
parent 7a414ecd34
commit e8a5bac5f2
3 changed files with 340 additions and 6 deletions

View File

@@ -4844,6 +4844,63 @@
"unknown": "未知"
}
},
"repairCandidateDraft": {
"eyebrow": "修復候選草案",
"title": "PlayBook 草案處置板",
"subtitle": "這筆告警已確認不能把通用兜底或診斷型 PlayBook 當成修復命令;下一步是補齊服務專屬修復草案,通過 owner review 與風險閘門後才可能進入審批或執行。",
"statusValue": "等待 PlayBook 草案",
"metrics": {
"status": "狀態",
"incident": "Incident",
"lane": "處置 lane",
"effect": "決策效果"
},
"flow": {
"ingest": {
"title": "告警接收",
"detail": "事件已進入 AwoooP 真相鏈與 Telegram 人工處置面。"
},
"evidence": {
"title": "證據補齊",
"detail": "需要 MCP evidence、目標 selector 與來源告警上下文。"
},
"draft": {
"title": "草案建立",
"detail": "建立服務專屬修復、回滾與 verifier 計畫。"
},
"review": {
"title": "Owner review",
"detail": "確認命令安全、適用條件與 PlayBook trust。"
},
"approval": {
"title": "風險閘門",
"detail": "只有通過審批後才可能進入受控執行。"
}
},
"requiredTitle": "PlayBook 草案必填欄位",
"required": {
"alertname": "告警名稱與觸發條件,避免把不同服務的症狀混用。",
"target_selector": "命名空間、Pod、Deployment、host 或服務選擇器。",
"mcp_evidence_refs": "MCP / Sentry / SigNoz / K8s / log 證據參照。",
"repair_command": "受控修復命令或 Ansible playbook不能是純診斷命令。",
"rollback_command": "修復失敗時的回滾或安全停止方案。",
"verifier_plan": "修復後如何驗證成功、失敗與是否要升級人工。",
"owner_review": "負責人、風險等級、適用條件與批准紀錄。"
},
"guardrailTitle": "阻擋原因與禁止誤讀",
"blocker": "目前缺少可信修復候選;系統只能建立人工草案工作項,不能把 no-action、診斷結果或通用兜底當作已修復。",
"nextStep": "請先補 PlayBook 草案與 MCP evidence再由 owner review 決定是否送審批;在此之前不會自動執行、不會寫入成功修復,也不會更新 KM 為已解決。",
"chainTitle": "真相鏈對照",
"chain": {
"stage": "目前階段",
"repair": "修復狀態",
"next": "真相鏈下一步",
"human": "需要人工"
},
"chainHint": "下方完整 status-chain 與 incident timeline 會用同一個 Incident 查詢;如果仍沒有資料,代表資料鏈路還沒把這筆告警完整串上。",
"openRuns": "打開 Runs",
"openApprovals": "打開審批"
},
"recurrence": {
"title": "重複告警工作項",
"subtitle": "把 run_completed_no_repair、修復失敗與人工閘門接成可追蹤 work item",

View File

@@ -4844,6 +4844,63 @@
"unknown": "未知"
}
},
"repairCandidateDraft": {
"eyebrow": "修復候選草案",
"title": "PlayBook 草案處置板",
"subtitle": "這筆告警已確認不能把通用兜底或診斷型 PlayBook 當成修復命令;下一步是補齊服務專屬修復草案,通過 owner review 與風險閘門後才可能進入審批或執行。",
"statusValue": "等待 PlayBook 草案",
"metrics": {
"status": "狀態",
"incident": "Incident",
"lane": "處置 lane",
"effect": "決策效果"
},
"flow": {
"ingest": {
"title": "告警接收",
"detail": "事件已進入 AwoooP 真相鏈與 Telegram 人工處置面。"
},
"evidence": {
"title": "證據補齊",
"detail": "需要 MCP evidence、目標 selector 與來源告警上下文。"
},
"draft": {
"title": "草案建立",
"detail": "建立服務專屬修復、回滾與 verifier 計畫。"
},
"review": {
"title": "Owner review",
"detail": "確認命令安全、適用條件與 PlayBook trust。"
},
"approval": {
"title": "風險閘門",
"detail": "只有通過審批後才可能進入受控執行。"
}
},
"requiredTitle": "PlayBook 草案必填欄位",
"required": {
"alertname": "告警名稱與觸發條件,避免把不同服務的症狀混用。",
"target_selector": "命名空間、Pod、Deployment、host 或服務選擇器。",
"mcp_evidence_refs": "MCP / Sentry / SigNoz / K8s / log 證據參照。",
"repair_command": "受控修復命令或 Ansible playbook不能是純診斷命令。",
"rollback_command": "修復失敗時的回滾或安全停止方案。",
"verifier_plan": "修復後如何驗證成功、失敗與是否要升級人工。",
"owner_review": "負責人、風險等級、適用條件與批准紀錄。"
},
"guardrailTitle": "阻擋原因與禁止誤讀",
"blocker": "目前缺少可信修復候選;系統只能建立人工草案工作項,不能把 no-action、診斷結果或通用兜底當作已修復。",
"nextStep": "請先補 PlayBook 草案與 MCP evidence再由 owner review 決定是否送審批;在此之前不會自動執行、不會寫入成功修復,也不會更新 KM 為已解決。",
"chainTitle": "真相鏈對照",
"chain": {
"stage": "目前階段",
"repair": "修復狀態",
"next": "真相鏈下一步",
"human": "需要人工"
},
"chainHint": "下方完整 status-chain 與 incident timeline 會用同一個 Incident 查詢;如果仍沒有資料,代表資料鏈路還沒把這筆告警完整串上。",
"openRuns": "打開 Runs",
"openApprovals": "打開審批"
},
"recurrence": {
"title": "重複告警工作項",
"subtitle": "把 run_completed_no_repair、修復失敗與人工閘門接成可追蹤 work item",

View File

@@ -1021,8 +1021,32 @@ type WorkItem = {
href: string;
};
type RepairCandidateDraftFocus = {
workItemId: string;
projectId: string;
incidentId: string | null;
lane: string;
};
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "";
const REPAIR_CANDIDATE_DRAFT_PREFIX = "repair-candidate-draft";
const REPAIR_CANDIDATE_DRAFT_REQUIRED_FIELDS = [
"alertname",
"target_selector",
"mcp_evidence_refs",
"repair_command",
"rollback_command",
"verifier_plan",
"owner_review",
] as const;
const REPAIR_CANDIDATE_DRAFT_BLOCKED_OPERATIONS = [
"auto_execute",
"approve_no_action_as_repair",
"generic_fallback_repair",
] as const;
const statusConfig: Record<WorkStatus, { className: string; icon: typeof Activity }> = {
live: {
className: "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]",
@@ -1104,6 +1128,24 @@ function firstIncidentId(...candidates: Array<string | null | undefined>) {
return candidates.find((candidate) => Boolean(candidate?.trim()))?.trim() ?? null;
}
function parseRepairCandidateDraftWorkItemId(
workItemId: string | null,
queryIncidentId: string | null,
fallbackProjectId: string
): RepairCandidateDraftFocus | null {
if (!workItemId?.startsWith(`${REPAIR_CANDIDATE_DRAFT_PREFIX}:`)) return null;
const [, parsedProjectId, parsedIncidentId, ...laneParts] = workItemId.split(":");
const projectId = parsedProjectId?.trim() || fallbackProjectId;
const incidentId = firstIncidentId(queryIncidentId, parsedIncidentId);
const lane = laneParts.join(":").trim() || "owner_review_playbook_trust_gate";
return {
workItemId,
projectId,
incidentId,
lane,
};
}
function selectStatusChainIncidentId(
focusedIncidentId: string | null,
remediationHistory: RemediationHistoryResponse | null,
@@ -2787,6 +2829,169 @@ function ProductionClaimBanner({
);
}
function RepairCandidateDraftPanel({
draft,
chain,
}: {
draft: RepairCandidateDraftFocus | null;
chain: AwoooPStatusChain | null;
}) {
const t = useTranslations("awooop.workItems.repairCandidateDraft");
if (!draft) return null;
const runsHref = draft.incidentId
? `/awooop/runs?project_id=${encodeURIComponent(draft.projectId)}&incident_id=${encodeURIComponent(draft.incidentId)}`
: `/awooop/runs?project_id=${encodeURIComponent(draft.projectId)}`;
const approvalsHref = draft.incidentId
? `/awooop/approvals?project_id=${encodeURIComponent(draft.projectId)}&incident_id=${encodeURIComponent(draft.incidentId)}`
: `/awooop/approvals?project_id=${encodeURIComponent(draft.projectId)}`;
const metrics = [
[t("metrics.status"), t("statusValue")],
[t("metrics.incident"), draft.incidentId ?? "--"],
[t("metrics.lane"), draft.lane],
[t("metrics.effect"), "none"],
];
const chainRows = [
[t("chain.stage"), chain?.current_stage ?? "--"],
[t("chain.repair"), chain?.repair_state ?? "--"],
[t("chain.next"), chain?.next_step ?? "--"],
[t("chain.human"), String(chain?.needs_human ?? true)],
];
return (
<section className="border border-[#e0ddd4] bg-white">
<div className="grid gap-px bg-[#e0ddd4] lg:grid-cols-[1.15fr_0.85fr]">
<div className="min-w-0 bg-white p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center border border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]">
<TriangleAlert className="h-5 w-5" aria-hidden="true" />
</div>
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-[#9f2f25]">
{t("eyebrow")}
</p>
<h3 className="mt-1 text-lg font-semibold tracking-normal text-[#141413]">
{t("title")}
</h3>
<p className="mt-2 max-w-3xl text-sm leading-6 text-[#5f5b52]">
{t("subtitle")}
</p>
</div>
</div>
<span className="shrink-0 break-all border border-[#d9b36f] bg-[#fff7e8] px-2 py-1 font-mono text-xs font-semibold text-[#8a5a08]">
{draft.workItemId}
</span>
</div>
<div className="mt-4 grid gap-px bg-[#e0ddd4] md:grid-cols-4">
{metrics.map(([label, value]) => (
<div key={label} className="min-w-0 bg-[#faf9f3] px-3 py-2">
<p className="text-xs font-semibold text-[#77736a]">{label}</p>
<p className="mt-1 break-all font-mono text-sm font-semibold text-[#141413]">
{value}
</p>
</div>
))}
</div>
<div className="mt-4 grid gap-2 md:grid-cols-5">
{["ingest", "evidence", "draft", "review", "approval"].map((step, index) => (
<div key={step} className="min-w-0 border border-[#e0ddd4] bg-white px-3 py-2">
<p className="font-mono text-[11px] font-semibold text-[#77736a]">
{String(index + 1).padStart(2, "0")}
</p>
<p className="mt-1 text-xs font-semibold text-[#141413]">
{t(`flow.${step}.title` as never)}
</p>
<p className="mt-1 text-[11px] leading-5 text-[#5f5b52]">
{t(`flow.${step}.detail` as never)}
</p>
</div>
))}
</div>
</div>
<div className="min-w-0 bg-white p-4">
<div className="flex items-center gap-2">
<ListChecks className="h-4 w-4 text-brand-accent" aria-hidden="true" />
<h4 className="text-sm font-semibold text-[#141413]">{t("requiredTitle")}</h4>
</div>
<div className="mt-3 grid gap-2">
{REPAIR_CANDIDATE_DRAFT_REQUIRED_FIELDS.map((field) => (
<div key={field} className="border border-[#e0ddd4] bg-[#faf9f3] px-3 py-2">
<p className="font-mono text-xs font-semibold text-[#141413]">{field}</p>
<p className="mt-1 text-[11px] leading-5 text-[#5f5b52]">
{t(`required.${field}` as never)}
</p>
</div>
))}
</div>
</div>
</div>
<div className="grid gap-px border-t border-[#e0ddd4] bg-[#e0ddd4] lg:grid-cols-[0.95fr_1.05fr]">
<div className="min-w-0 bg-[#fff7e8] p-4">
<div className="flex items-center gap-2">
<ShieldCheck className="h-4 w-4 text-[#8a5a08]" aria-hidden="true" />
<h4 className="text-sm font-semibold text-[#141413]">{t("guardrailTitle")}</h4>
</div>
<p className="mt-2 text-xs leading-5 text-[#5f5b52]">{t("blocker")}</p>
<div className="mt-3 flex flex-wrap gap-2">
{REPAIR_CANDIDATE_DRAFT_BLOCKED_OPERATIONS.map((operation) => (
<span
key={operation}
className="border border-[#d9b36f] bg-white px-2 py-0.5 font-mono text-[11px] font-semibold text-[#8a5a08]"
>
{operation}
</span>
))}
</div>
<p className="mt-3 text-xs leading-5 text-[#5f5b52]">{t("nextStep")}</p>
</div>
<div className="min-w-0 bg-white p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<GitBranch className="h-4 w-4 text-brand-accent" aria-hidden="true" />
<h4 className="text-sm font-semibold text-[#141413]">{t("chainTitle")}</h4>
</div>
<div className="flex flex-wrap gap-2">
<Link
href={runsHref as never}
className="inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-white px-2.5 py-1 text-xs font-semibold text-[#141413] hover:border-[#d97757]"
>
{t("openRuns")}
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
</Link>
<Link
href={approvalsHref as never}
className="inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-white px-2.5 py-1 text-xs font-semibold text-[#141413] hover:border-[#d97757]"
>
{t("openApprovals")}
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
</Link>
</div>
</div>
<div className="mt-3 grid gap-px bg-[#e0ddd4] sm:grid-cols-2">
{chainRows.map(([label, value]) => (
<div key={label} className="min-w-0 bg-[#faf9f3] px-3 py-2">
<p className="text-xs font-semibold text-[#77736a]">{label}</p>
<p className="mt-1 break-words font-mono text-xs text-[#141413]">
{value}
</p>
</div>
))}
</div>
<p className="mt-3 text-xs leading-5 text-[#77736a]">
{t("chainHint")}
</p>
</div>
</div>
</section>
);
}
function WorkItemIncidentAuditPanel({
timeline,
chain,
@@ -5795,6 +6000,16 @@ export default function AwoooPWorkItemsPage() {
const projectId = searchParams.get("project_id") || "awoooi";
const focusedWorkItemId = searchParams.get("work_item_id");
const focusedIncidentId = searchParams.get("incident_id");
const focusedRepairCandidateDraft = useMemo(
() => parseRepairCandidateDraftWorkItemId(
focusedWorkItemId,
focusedIncidentId,
projectId
),
[focusedIncidentId, focusedWorkItemId, projectId]
);
const effectiveFocusedIncidentId =
focusedIncidentId ?? focusedRepairCandidateDraft?.incidentId ?? null;
const [telemetry, setTelemetry] = useState<Telemetry>({
quality: null,
governanceEvents: null,
@@ -5879,7 +6094,7 @@ export default function AwoooPWorkItemsPage() {
]);
const statusChainIncidentId = selectStatusChainIncidentId(
focusedIncidentId,
effectiveFocusedIncidentId,
remediationHistory,
eventRecurrence
);
@@ -5923,7 +6138,7 @@ export default function AwoooPWorkItemsPage() {
});
setLastUpdated(new Date());
setLoading(false);
}, [focusedIncidentId, projectId]);
}, [effectiveFocusedIncidentId, projectId]);
useEffect(() => {
fetchTelemetry();
@@ -5952,10 +6167,10 @@ export default function AwoooPWorkItemsPage() {
new Set([
...remediationIncidentIds,
...recurrenceIncidentIds,
focusedIncidentId,
effectiveFocusedIncidentId,
].filter(Boolean))
),
[focusedIncidentId, recurrenceIncidentIds, remediationIncidentIds]
[effectiveFocusedIncidentId, recurrenceIncidentIds, remediationIncidentIds]
);
const summary = useMemo(
() => [
@@ -6032,12 +6247,17 @@ export default function AwoooPWorkItemsPage() {
onRecorded={fetchTelemetry}
/>
<RepairCandidateDraftPanel
draft={focusedRepairCandidateDraft}
chain={telemetry.statusChain}
/>
<AwoooPStatusChainPanel chain={telemetry.statusChain} />
<WorkItemIncidentAuditPanel
timeline={telemetry.incidentTimeline}
chain={telemetry.statusChain}
focusedIncidentId={focusedIncidentId}
focusedIncidentId={effectiveFocusedIncidentId}
projectId={projectId}
loading={loading}
/>
@@ -6045,7 +6265,7 @@ export default function AwoooPWorkItemsPage() {
<RecurrenceWorkQueuePanel
recurrence={telemetry.eventRecurrence}
focusedWorkItemId={focusedWorkItemId}
focusedIncidentId={focusedIncidentId}
focusedIncidentId={effectiveFocusedIncidentId}
projectId={projectId}
onRecorded={fetchTelemetry}
/>