feat(web): 顯示修復候選草案處置板
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user