From e8a5bac5f2d5f7334425b47df726d3a2ce6851ea Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 11 Jun 2026 19:12:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E9=A1=AF=E7=A4=BA=E4=BF=AE?= =?UTF-8?q?=E5=BE=A9=E5=80=99=E9=81=B8=E8=8D=89=E6=A1=88=E8=99=95=E7=BD=AE?= =?UTF-8?q?=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/messages/en.json | 57 +++++ apps/web/messages/zh-TW.json | 57 +++++ .../app/[locale]/awooop/work-items/page.tsx | 232 +++++++++++++++++- 3 files changed, 340 insertions(+), 6 deletions(-) diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index af7a70fd..70e98165 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -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", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index af7a70fd..70e98165 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -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", diff --git a/apps/web/src/app/[locale]/awooop/work-items/page.tsx b/apps/web/src/app/[locale]/awooop/work-items/page.tsx index 7ec6c4e5..717d20f7 100644 --- a/apps/web/src/app/[locale]/awooop/work-items/page.tsx +++ b/apps/web/src/app/[locale]/awooop/work-items/page.tsx @@ -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 = { live: { className: "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]", @@ -1104,6 +1128,24 @@ function firstIncidentId(...candidates: Array) { 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 ( +
+
+
+
+
+
+
+
+

+ {t("eyebrow")} +

+

+ {t("title")} +

+

+ {t("subtitle")} +

+
+
+ + {draft.workItemId} + +
+ +
+ {metrics.map(([label, value]) => ( +
+

{label}

+

+ {value} +

+
+ ))} +
+ +
+ {["ingest", "evidence", "draft", "review", "approval"].map((step, index) => ( +
+

+ {String(index + 1).padStart(2, "0")} +

+

+ {t(`flow.${step}.title` as never)} +

+

+ {t(`flow.${step}.detail` as never)} +

+
+ ))} +
+
+ +
+
+
+
+ {REPAIR_CANDIDATE_DRAFT_REQUIRED_FIELDS.map((field) => ( +
+

{field}

+

+ {t(`required.${field}` as never)} +

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

{t("blocker")}

+
+ {REPAIR_CANDIDATE_DRAFT_BLOCKED_OPERATIONS.map((operation) => ( + + {operation} + + ))} +
+

{t("nextStep")}

+
+ +
+
+
+
+
+ + {t("openRuns")} +
+
+
+ {chainRows.map(([label, value]) => ( +
+

{label}

+

+ {value} +

+
+ ))} +
+

+ {t("chainHint")} +

+
+
+
+ ); +} + 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({ 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} /> + + @@ -6045,7 +6265,7 @@ export default function AwoooPWorkItemsPage() {