From 69f2ec5ec943814506b540d3c023cdc47058af0b Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 17 May 2026 23:37:53 +0800 Subject: [PATCH] feat(awooop): add incident evidence headers --- apps/web/messages/en.json | 14 ++ apps/web/messages/zh-TW.json | 14 ++ .../awooop/approvals/[run_id]/page.tsx | 12 ++ .../[locale]/awooop/runs/[run_id]/page.tsx | 12 ++ .../app/[locale]/awooop/work-items/page.tsx | 21 +++ .../awooop/incident-evidence-header.tsx | 136 ++++++++++++++++++ .../src/components/terminal/OmniTerminal.tsx | 12 +- 7 files changed, 216 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/components/awooop/incident-evidence-header.tsx diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 234e9e6d..bbc7e0a2 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -1841,6 +1841,20 @@ "approvalNoEvidenceDetail": "Approval still lacks remediation dry-run evidence; inspect Run Timeline" } }, + "incidentEvidence": { + "title": "Incident Evidence", + "subtitle": "Telegram, Run, Approval, and Work Item share the same remediation evidence", + "empty": "--", + "incidentLabel": "Incident", + "notLinked": "No Incident linked", + "filterTitle": "Show only {incidentId}", + "more": "+{count} more", + "dryRuns": "Dry-run", + "route": "MCP Route", + "writes": "Write flags", + "writeFlags": "incident={incident} / autoRepair={autoRepair}", + "runLink": "Run Timeline" + }, "runDetail": { "back": "Back to Run Monitor", "title": "Run Disposition Timeline", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 0e763cbf..440be43e 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -1842,6 +1842,20 @@ "approvalNoEvidenceDetail": "審批前仍缺補救試跑證據,需進 Run Timeline 檢查" } }, + "incidentEvidence": { + "title": "Incident Evidence", + "subtitle": "Telegram、Run、Approval 與 Work Item 共用同一組補救證據", + "empty": "--", + "incidentLabel": "Incident", + "notLinked": "尚未關聯 Incident", + "filterTitle": "只看 {incidentId}", + "more": "+{count} 筆", + "dryRuns": "Dry-run", + "route": "MCP 路由", + "writes": "寫入旗標", + "writeFlags": "incident={incident} / autoRepair={autoRepair}", + "runLink": "Run Timeline" + }, "runDetail": { "back": "返回 Run 監控", "title": "Run 處置脈絡", diff --git a/apps/web/src/app/[locale]/awooop/approvals/[run_id]/page.tsx b/apps/web/src/app/[locale]/awooop/approvals/[run_id]/page.tsx index c170f65c..c45ed602 100644 --- a/apps/web/src/app/[locale]/awooop/approvals/[run_id]/page.tsx +++ b/apps/web/src/app/[locale]/awooop/approvals/[run_id]/page.tsx @@ -21,6 +21,7 @@ import { } from "lucide-react"; import { Link, useRouter } from "@/i18n/routing"; +import { IncidentEvidenceHeader } from "@/components/awooop/incident-evidence-header"; import { cn } from "@/lib/utils"; interface RunDetail { @@ -375,6 +376,7 @@ export default function ApprovalDecisionPage({ }; const run = detail?.run; + const latestRemediation = detail?.remediation_history?.items?.[0] ?? null; const isWaitingApproval = run?.state === "waiting_approval"; const stateClass = STATE_STYLE[run?.state ?? ""] ?? "border-[#d8d3c7] bg-white text-[#5f5b52]"; @@ -468,6 +470,16 @@ export default function ApprovalDecisionPage({ )} + + { if (!run?.created_at) return t("empty"); const end = run.completed_at || run.heartbeat_at || new Date().toISOString(); @@ -893,6 +895,16 @@ export default function RunDetailPage({ + + buildWorkItems(telemetry, t), [telemetry, t]); + const latestRemediationHistory = telemetry.remediationHistory?.items?.[0] ?? null; + const remediationIncidentIds = useMemo( + () => Array.from( + new Set( + (telemetry.remediationHistory?.items ?? []) + .map((item) => item.incident_id) + .filter(Boolean) + ) + ), + [telemetry.remediationHistory?.items] + ); const summary = useMemo( () => [ { label: t("summary.live"), value: workItems.filter((item) => item.status === "live").length, icon: Activity }, @@ -432,6 +444,15 @@ export default function AwoooPWorkItemsPage() { ))} + +
diff --git a/apps/web/src/components/awooop/incident-evidence-header.tsx b/apps/web/src/components/awooop/incident-evidence-header.tsx new file mode 100644 index 00000000..3efee221 --- /dev/null +++ b/apps/web/src/components/awooop/incident-evidence-header.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { Link } from "@/i18n/routing"; +import { cn } from "@/lib/utils"; +import { Link2, SearchCheck, ShieldCheck } from "lucide-react"; +import { useTranslations } from "next-intl"; + +const INCIDENT_ID_RE = /^INC-\d{8}-[A-Z0-9]{4,}$/; + +function normalizeIncidentIds(incidentIds?: Array) { + return Array.from( + new Set( + (incidentIds ?? []) + .map((incidentId) => String(incidentId || "").trim().toUpperCase()) + .filter((incidentId) => INCIDENT_ID_RE.test(incidentId)) + ) + ); +} + +function boolLabel(value: boolean | null | undefined, emptyLabel: string) { + if (value === true) return "true"; + if (value === false) return "false"; + return emptyLabel; +} + +export interface IncidentEvidenceHeaderProps { + projectId?: string | null; + runId?: string | null; + incidentIds?: Array; + dryRunCount?: number | null; + latestRoute?: string | null; + writesIncidentState?: boolean | null; + writesAutoRepairResult?: boolean | null; + className?: string; +} + +export function IncidentEvidenceHeader({ + projectId, + runId, + incidentIds, + dryRunCount, + latestRoute, + writesIncidentState, + writesAutoRepairResult, + className, +}: IncidentEvidenceHeaderProps) { + const t = useTranslations("awooop.incidentEvidence"); + const normalizedIncidentIds = normalizeIncidentIds(incidentIds); + const visibleIncidentIds = normalizedIncidentIds.slice(0, 3); + const hiddenCount = Math.max(normalizedIncidentIds.length - visibleIncidentIds.length, 0); + const emptyLabel = t("empty"); + const route = latestRoute && latestRoute !== "--" ? latestRoute : emptyLabel; + const safeProjectId = projectId || "awoooi"; + const runHref = runId + ? `/awooop/runs/${runId}?project_id=${encodeURIComponent(safeProjectId)}` + : null; + + return ( +
+
+
+ + +
+

{t("title")}

+

{t("subtitle")}

+
+
+ {runHref && ( + + {t("runLink")} +
+ +
+
+
{t("incidentLabel")}
+ {visibleIncidentIds.length > 0 ? ( +
+ {visibleIncidentIds.map((incidentId) => ( + + {incidentId} + + ))} + {hiddenCount > 0 && ( + + {t("more", { count: hiddenCount })} + + )} +
+ ) : ( +

{t("notLinked")}

+ )} +
+ +
+
+
+

+ {typeof dryRunCount === "number" ? dryRunCount : emptyLabel} +

+
+ +
+
{t("route")}
+

+ {route} +

+
+ +
+
{t("writes")}
+

+ {t("writeFlags", { + incident: boolLabel(writesIncidentState, emptyLabel), + autoRepair: boolLabel(writesAutoRepairResult, emptyLabel), + })} +

+
+
+
+ ); +} diff --git a/apps/web/src/components/terminal/OmniTerminal.tsx b/apps/web/src/components/terminal/OmniTerminal.tsx index c32a9cc7..564f0c89 100644 --- a/apps/web/src/components/terminal/OmniTerminal.tsx +++ b/apps/web/src/components/terminal/OmniTerminal.tsx @@ -23,6 +23,7 @@ import React, { useEffect, useRef, useState } from 'react' import { useTranslations } from 'next-intl' +import { Terminal } from 'lucide-react' import { useTerminalStore, useIsConnected, @@ -99,13 +100,14 @@ export const OmniTerminal = () => { return ( )