feat(awooop): add incident evidence headers
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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 處置脈絡",
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")}>
|
||||||
|
|||||||
136
apps/web/src/components/awooop/incident-evidence-header.tsx
Normal file
136
apps/web/src/components/awooop/incident-evidence-header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user