feat(awooop): add incident evidence headers
All checks were successful
Code Review / ai-code-review (push) Successful in 11s
CD Pipeline / tests (push) Successful in 1m7s
CD Pipeline / build-and-deploy (push) Successful in 3m40s
CD Pipeline / post-deploy-checks (push) Successful in 1m28s

This commit is contained in:
Your Name
2026-05-17 23:37:53 +08:00
parent a6699c41f8
commit 69f2ec5ec9
7 changed files with 216 additions and 5 deletions

View File

@@ -1841,6 +1841,20 @@
"approvalNoEvidenceDetail": "Approval still lacks remediation dry-run evidence; inspect Run Timeline" "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": { "runDetail": {
"back": "Back to Run Monitor", "back": "Back to Run Monitor",
"title": "Run Disposition Timeline", "title": "Run Disposition Timeline",

View File

@@ -1842,6 +1842,20 @@
"approvalNoEvidenceDetail": "審批前仍缺補救試跑證據,需進 Run Timeline 檢查" "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": { "runDetail": {
"back": "返回 Run 監控", "back": "返回 Run 監控",
"title": "Run 處置脈絡", "title": "Run 處置脈絡",

View File

@@ -21,6 +21,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { Link, useRouter } from "@/i18n/routing"; import { Link, useRouter } from "@/i18n/routing";
import { IncidentEvidenceHeader } from "@/components/awooop/incident-evidence-header";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface RunDetail { interface RunDetail {
@@ -375,6 +376,7 @@ export default function ApprovalDecisionPage({
}; };
const run = detail?.run; const run = detail?.run;
const latestRemediation = detail?.remediation_history?.items?.[0] ?? null;
const isWaitingApproval = run?.state === "waiting_approval"; const isWaitingApproval = run?.state === "waiting_approval";
const stateClass = STATE_STYLE[run?.state ?? ""] ?? "border-[#d8d3c7] bg-white text-[#5f5b52]"; const stateClass = STATE_STYLE[run?.state ?? ""] ?? "border-[#d8d3c7] bg-white text-[#5f5b52]";
@@ -468,6 +470,16 @@ export default function ApprovalDecisionPage({
</div> </div>
)} )}
<IncidentEvidenceHeader
projectId={run?.project_id || projectId || "awoooi"}
runId={run_id}
incidentIds={detail?.remediation_history?.incident_ids}
dryRunCount={detail?.remediation_history?.total}
latestRoute={remediationRoute(latestRemediation)}
writesIncidentState={latestRemediation?.writes_incident_state}
writesAutoRepairResult={latestRemediation?.writes_auto_repair_result}
/>
<ApprovalRemediationEvidence <ApprovalRemediationEvidence
history={detail?.remediation_history} history={detail?.remediation_history}
locale={locale} locale={locale}

View File

@@ -29,6 +29,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { Link } from "@/i18n/routing"; import { Link } from "@/i18n/routing";
import { IncidentEvidenceHeader } from "@/components/awooop/incident-evidence-header";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface RunDetail { interface RunDetail {
@@ -809,6 +810,7 @@ export default function RunDetailPage({
}, [fetchDetail]); }, [fetchDetail]);
const run = detail?.run; const run = detail?.run;
const latestRemediation = detail?.remediation_history?.items?.[0] ?? null;
const durationText = useMemo(() => { const durationText = useMemo(() => {
if (!run?.created_at) return t("empty"); if (!run?.created_at) return t("empty");
const end = run.completed_at || run.heartbeat_at || new Date().toISOString(); const end = run.completed_at || run.heartbeat_at || new Date().toISOString();
@@ -893,6 +895,16 @@ export default function RunDetailPage({
</div> </div>
</section> </section>
<IncidentEvidenceHeader
projectId={run?.project_id || projectId || "awoooi"}
runId={run_id}
incidentIds={detail?.remediation_history?.incident_ids}
dryRunCount={detail?.remediation_history?.total}
latestRoute={remediationRoute(latestRemediation)}
writesIncidentState={latestRemediation?.writes_incident_state}
writesAutoRepairResult={latestRemediation?.writes_auto_repair_result}
/>
<RunActionPanel run={run} counts={detail?.counts} emptyLabel={t("empty")} /> <RunActionPanel run={run} counts={detail?.counts} emptyLabel={t("empty")} />
<McpGatewayPanel <McpGatewayPanel

View File

@@ -21,6 +21,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { Link } from "@/i18n/routing"; import { Link } from "@/i18n/routing";
import { IncidentEvidenceHeader } from "@/components/awooop/incident-evidence-header";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type WorkStatus = "live" | "in_progress" | "blocked" | "watching"; type WorkStatus = "live" | "in_progress" | "blocked" | "watching";
@@ -374,6 +375,17 @@ export default function AwoooPWorkItemsPage() {
}, [fetchTelemetry]); }, [fetchTelemetry]);
const workItems = useMemo(() => buildWorkItems(telemetry, t), [telemetry, t]); const workItems = useMemo(() => 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( const summary = useMemo(
() => [ () => [
{ label: t("summary.live"), value: workItems.filter((item) => item.status === "live").length, icon: Activity }, { label: t("summary.live"), value: workItems.filter((item) => item.status === "live").length, icon: Activity },
@@ -432,6 +444,15 @@ export default function AwoooPWorkItemsPage() {
))} ))}
</div> </div>
<IncidentEvidenceHeader
projectId="awoooi"
incidentIds={remediationIncidentIds}
dryRunCount={telemetry.remediationHistory?.total}
latestRoute={routeLabel(latestRemediationHistory)}
writesIncidentState={latestRemediationHistory?.writes_incident_state}
writesAutoRepairResult={latestRemediationHistory?.writes_auto_repair_result}
/>
<div className="overflow-hidden border border-[#e0ddd4] bg-white"> <div className="overflow-hidden border border-[#e0ddd4] bg-white">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full" role="table" aria-label={t("tableLabel")}> <table className="w-full" role="table" aria-label={t("tableLabel")}>

View File

@@ -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<string | null | undefined>) {
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<string | null | undefined>;
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 (
<section className={cn("border border-[#e0ddd4] bg-white", className)}>
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-[#e0ddd4] bg-[#faf9f3] px-4 py-3">
<div className="flex min-w-0 items-start gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center border border-[#9bb6d9] bg-[#eef5ff] text-[#1f5b9b]">
<SearchCheck className="h-4 w-4" aria-hidden="true" />
</span>
<div className="min-w-0">
<h3 className="text-sm font-semibold text-[#141413]">{t("title")}</h3>
<p className="mt-1 text-xs leading-5 text-[#77736a]">{t("subtitle")}</p>
</div>
</div>
{runHref && (
<Link
href={runHref 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("runLink")}
<Link2 className="h-3.5 w-3.5" aria-hidden="true" />
</Link>
)}
</div>
<div className="grid gap-px bg-[#e0ddd4] lg:grid-cols-[1.5fr_1fr_1fr_1fr]">
<div className="min-w-0 bg-white px-4 py-3">
<div className="text-xs font-semibold text-[#77736a]">{t("incidentLabel")}</div>
{visibleIncidentIds.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1.5">
{visibleIncidentIds.map((incidentId) => (
<Link
key={incidentId}
href={`/awooop/runs?project_id=${encodeURIComponent(safeProjectId)}&incident_id=${encodeURIComponent(incidentId)}` as never}
className="inline-flex items-center border border-[#d8d3c7] bg-[#faf9f3] px-2 py-1 font-mono text-xs font-semibold text-[#2e2b26] hover:border-[#1f6feb] hover:bg-[#edf4ff] hover:text-[#0f4fa8]"
title={t("filterTitle", { incidentId })}
>
{incidentId}
</Link>
))}
{hiddenCount > 0 && (
<span className="inline-flex items-center border border-[#d8d3c7] bg-white px-2 py-1 font-mono text-xs text-[#5f5b52]">
{t("more", { count: hiddenCount })}
</span>
)}
</div>
) : (
<p className="mt-2 text-sm text-[#77736a]">{t("notLinked")}</p>
)}
</div>
<div className="min-w-0 bg-white px-4 py-3">
<div className="flex items-center gap-1.5 text-xs font-semibold text-[#77736a]">
<ShieldCheck className="h-3.5 w-3.5" aria-hidden="true" />
{t("dryRuns")}
</div>
<p className="mt-2 font-mono text-xl font-semibold text-[#141413]">
{typeof dryRunCount === "number" ? dryRunCount : emptyLabel}
</p>
</div>
<div className="min-w-0 bg-white px-4 py-3">
<div className="text-xs font-semibold text-[#77736a]">{t("route")}</div>
<p className="mt-2 truncate font-mono text-sm text-[#141413]" title={route}>
{route}
</p>
</div>
<div className="min-w-0 bg-white px-4 py-3">
<div className="text-xs font-semibold text-[#77736a]">{t("writes")}</div>
<p className="mt-2 font-mono text-sm text-[#141413]">
{t("writeFlags", {
incident: boolLabel(writesIncidentState, emptyLabel),
autoRepair: boolLabel(writesAutoRepairResult, emptyLabel),
})}
</p>
</div>
</div>
</section>
);
}

View File

@@ -23,6 +23,7 @@
import React, { useEffect, useRef, useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { Terminal } from 'lucide-react'
import { import {
useTerminalStore, useTerminalStore,
useIsConnected, useIsConnected,
@@ -99,13 +100,14 @@ export const OmniTerminal = () => {
return ( return (
<button <button
onClick={openTerminal} onClick={openTerminal}
className="fixed bottom-4 right-4 px-3 py-2 sm:px-4 bg-white/70 backdrop-blur-[20px] border border-gray-200 rounded-md shadow-sm text-nothing-black hover:bg-white transition-all flex items-center gap-2 group" className="fixed bottom-4 right-4 flex h-11 w-11 items-center justify-center rounded-md border border-gray-200 bg-white/80 text-nothing-black shadow-sm backdrop-blur-[20px] transition-all hover:bg-white sm:h-12 sm:w-12"
style={{ zIndex: Z_INDEX.OMNI_TERMINAL }} style={{ zIndex: Z_INDEX.OMNI_TERMINAL }}
aria-label="Open Omni-Terminal" aria-label={t('open')}
title={`${t('open')} (${t('shortcut')})`}
> >
<span className="w-2 h-2 rounded-full bg-[#4A90D9] animate-pulse"></span> <Terminal className="h-5 w-5 text-[#4A90D9]" aria-hidden="true" />
<span className="font-['VT323'] text-base sm:text-lg"> <span className="absolute -bottom-1 -right-1 rounded-sm border border-gray-200 bg-white px-1 font-['VT323'] text-[10px] leading-4 text-[#5f5b52]">
<span className="hidden sm:inline">Omni-Terminal </span>[J] {t('shortcut')}
</span> </span>
</button> </button>
) )