fix(web): surface incident truth chain in approvals
This commit is contained in:
@@ -4128,6 +4128,83 @@
|
||||
}
|
||||
},
|
||||
"approvals": {
|
||||
"page": {
|
||||
"title": "審批佇列",
|
||||
"urgentCount": "{count} 緊急",
|
||||
"loading": "載入中...",
|
||||
"pendingRefresh": "{count} 筆待審 · 上次更新 {time}",
|
||||
"autoRefresh": "每 10 秒自動刷新",
|
||||
"refreshNow": "立即刷新",
|
||||
"loadFailed": "無法載入審批資料",
|
||||
"genericLoadFailed": "載入失敗"
|
||||
},
|
||||
"summary": {
|
||||
"pending": "待人工決策",
|
||||
"pendingDetail": "AwoooP {platform} / Legacy HITL {legacy}",
|
||||
"critical": "即將逾時",
|
||||
"criticalDetail": "5 分鐘內必須處置",
|
||||
"expired": "已逾時",
|
||||
"expiredDetail": "不得再自動恢復"
|
||||
},
|
||||
"badges": {
|
||||
"humanGate": "人工閘門"
|
||||
},
|
||||
"columns": {
|
||||
"runId": "執行 ID",
|
||||
"projectId": "專案 ID",
|
||||
"agent": "AI 代理",
|
||||
"route": "處置路線",
|
||||
"created": "建立時間",
|
||||
"remaining": "剩餘時間"
|
||||
},
|
||||
"empty": {
|
||||
"title": "審批佇列為空",
|
||||
"subtitle": "目前沒有待審批的執行項目"
|
||||
},
|
||||
"incidentFocus": {
|
||||
"title": "焦點 Incident 審批真相鏈",
|
||||
"loading": "讀取中",
|
||||
"loadFailed": "焦點 Incident 真相鏈載入失敗;請改從 Work Items 或 Runs 檢查同一筆事件。",
|
||||
"openWorkItems": "Work Items",
|
||||
"openRuns": "Runs",
|
||||
"openTickets": "Tickets",
|
||||
"empty": "無",
|
||||
"flowTitle": "處理流程",
|
||||
"handoffTitle": "審批與人工接手",
|
||||
"timelineEmpty": "尚未取得 Incident timeline。",
|
||||
"linkedExplanation": "此 Incident 已有 approval / timeline 關聯;若下方待審清單為空,代表它可能已完成、過期、拒絕,或已轉成驗證後人工接手。",
|
||||
"unlinkedExplanation": "目前沒有對應 approval id;這代表此 Incident 不是等待批准的狀態,應從 Work Items / Runs 追下一步。",
|
||||
"needsHuman": {
|
||||
"yes": "需要人工",
|
||||
"no": "不需人工"
|
||||
},
|
||||
"metrics": {
|
||||
"approvals": "關聯審批",
|
||||
"stage": "目前階段",
|
||||
"repair": "修復狀態",
|
||||
"verification": "驗證",
|
||||
"handoff": "人工接手"
|
||||
},
|
||||
"handoff": {
|
||||
"approvalIds": "Approval IDs",
|
||||
"pendingRows": "待審列",
|
||||
"pendingRowsValue": "AwoooP {platform} / Legacy {legacy}",
|
||||
"nextAction": "下一步",
|
||||
"reason": "原因"
|
||||
},
|
||||
"evidence": {
|
||||
"executor": "Executor",
|
||||
"ansible": "Ansible",
|
||||
"mcp": "MCP",
|
||||
"mcpValue": "{success}/{total} success;top {tool}",
|
||||
"source": "Source",
|
||||
"sourceValue": "direct {direct} / candidate {candidate} / applied {applied}",
|
||||
"km": "KM",
|
||||
"command": "指令判定",
|
||||
"notification": "通知通道",
|
||||
"events": "Timeline events"
|
||||
}
|
||||
},
|
||||
"legacyHitl": {
|
||||
"title": "既有 HITL 待人工處理",
|
||||
"subtitle": "這批來自 approval_records,不屬於 AwoooP run approval;仍需在前台可見。",
|
||||
|
||||
@@ -4128,6 +4128,83 @@
|
||||
}
|
||||
},
|
||||
"approvals": {
|
||||
"page": {
|
||||
"title": "審批佇列",
|
||||
"urgentCount": "{count} 緊急",
|
||||
"loading": "載入中...",
|
||||
"pendingRefresh": "{count} 筆待審 · 上次更新 {time}",
|
||||
"autoRefresh": "每 10 秒自動刷新",
|
||||
"refreshNow": "立即刷新",
|
||||
"loadFailed": "無法載入審批資料",
|
||||
"genericLoadFailed": "載入失敗"
|
||||
},
|
||||
"summary": {
|
||||
"pending": "待人工決策",
|
||||
"pendingDetail": "AwoooP {platform} / Legacy HITL {legacy}",
|
||||
"critical": "即將逾時",
|
||||
"criticalDetail": "5 分鐘內必須處置",
|
||||
"expired": "已逾時",
|
||||
"expiredDetail": "不得再自動恢復"
|
||||
},
|
||||
"badges": {
|
||||
"humanGate": "人工閘門"
|
||||
},
|
||||
"columns": {
|
||||
"runId": "執行 ID",
|
||||
"projectId": "專案 ID",
|
||||
"agent": "AI 代理",
|
||||
"route": "處置路線",
|
||||
"created": "建立時間",
|
||||
"remaining": "剩餘時間"
|
||||
},
|
||||
"empty": {
|
||||
"title": "審批佇列為空",
|
||||
"subtitle": "目前沒有待審批的執行項目"
|
||||
},
|
||||
"incidentFocus": {
|
||||
"title": "焦點 Incident 審批真相鏈",
|
||||
"loading": "讀取中",
|
||||
"loadFailed": "焦點 Incident 真相鏈載入失敗;請改從 Work Items 或 Runs 檢查同一筆事件。",
|
||||
"openWorkItems": "Work Items",
|
||||
"openRuns": "Runs",
|
||||
"openTickets": "Tickets",
|
||||
"empty": "無",
|
||||
"flowTitle": "處理流程",
|
||||
"handoffTitle": "審批與人工接手",
|
||||
"timelineEmpty": "尚未取得 Incident timeline。",
|
||||
"linkedExplanation": "此 Incident 已有 approval / timeline 關聯;若下方待審清單為空,代表它可能已完成、過期、拒絕,或已轉成驗證後人工接手。",
|
||||
"unlinkedExplanation": "目前沒有對應 approval id;這代表此 Incident 不是等待批准的狀態,應從 Work Items / Runs 追下一步。",
|
||||
"needsHuman": {
|
||||
"yes": "需要人工",
|
||||
"no": "不需人工"
|
||||
},
|
||||
"metrics": {
|
||||
"approvals": "關聯審批",
|
||||
"stage": "目前階段",
|
||||
"repair": "修復狀態",
|
||||
"verification": "驗證",
|
||||
"handoff": "人工接手"
|
||||
},
|
||||
"handoff": {
|
||||
"approvalIds": "Approval IDs",
|
||||
"pendingRows": "待審列",
|
||||
"pendingRowsValue": "AwoooP {platform} / Legacy {legacy}",
|
||||
"nextAction": "下一步",
|
||||
"reason": "原因"
|
||||
},
|
||||
"evidence": {
|
||||
"executor": "Executor",
|
||||
"ansible": "Ansible",
|
||||
"mcp": "MCP",
|
||||
"mcpValue": "{success}/{total} success;top {tool}",
|
||||
"source": "Source",
|
||||
"sourceValue": "direct {direct} / candidate {candidate} / applied {applied}",
|
||||
"km": "KM",
|
||||
"command": "指令判定",
|
||||
"notification": "通知通道",
|
||||
"events": "Timeline events"
|
||||
}
|
||||
},
|
||||
"legacyHitl": {
|
||||
"title": "既有 HITL 待人工處理",
|
||||
"subtitle": "這批來自 approval_records,不屬於 AwoooP run approval;仍需在前台可見。",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
ShieldCheck,
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
AwoooPStatusChainPanel,
|
||||
type AwoooPStatusChain,
|
||||
} from "@/components/awooop/status-chain";
|
||||
import type { IncidentTimelineResponse } from "@/lib/api-client";
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
@@ -122,6 +124,49 @@ function formatLocalTime(value?: string | null): string {
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchJson<T>(url: string, timeoutMs = 12_000): Promise<T | null> {
|
||||
const controller = new AbortController();
|
||||
const timeout = window.setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as T;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
window.clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function timelineStatusClass(status?: string | null) {
|
||||
const normalized = String(status ?? "").toLowerCase();
|
||||
if (normalized.includes("success") || normalized.includes("ok") || normalized.includes("resolved")) {
|
||||
return "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]";
|
||||
}
|
||||
if (normalized.includes("fail") || normalized.includes("block") || normalized.includes("error")) {
|
||||
return "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]";
|
||||
}
|
||||
if (normalized.includes("warn") || normalized.includes("pending") || normalized.includes("investigating")) {
|
||||
return "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]";
|
||||
}
|
||||
return "border-[#d8d3c7] bg-[#faf9f3] text-[#5f5b52]";
|
||||
}
|
||||
|
||||
function approvalMatchesIncident(approval: Approval, incidentId: string) {
|
||||
const summaryIds = approval.remediation_summary?.incident_ids ?? [];
|
||||
const chainIds = approval.awooop_status_chain?.incident_ids ?? [];
|
||||
return summaryIds.includes(incidentId)
|
||||
|| chainIds.includes(incidentId)
|
||||
|| approval.awooop_status_chain?.source_id === incidentId;
|
||||
}
|
||||
|
||||
function uniqueValues(values: Array<string | null | undefined>) {
|
||||
return Array.from(new Set(values.filter((value): value is string => Boolean(value))));
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Sub Components
|
||||
// =============================================================================
|
||||
@@ -464,10 +509,11 @@ function TimeoutCell({ timeoutAt }: { timeoutAt: string | null }) {
|
||||
}
|
||||
|
||||
function DecisionPostureBadge() {
|
||||
const t = useTranslations("awooop.approvals");
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 border border-[#d9b36f] bg-[#fff7e8] px-2 py-0.5 text-xs font-semibold text-[#8a5a08]">
|
||||
<ShieldCheck className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
人工閘門
|
||||
{t("badges.humanGate")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -880,18 +926,230 @@ function OwnerResponseValidationApprovalBoundaryPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
function FocusedIncidentApprovalPanel({
|
||||
projectId,
|
||||
incidentId,
|
||||
chain,
|
||||
timeline,
|
||||
approvals,
|
||||
legacyApprovals,
|
||||
loading,
|
||||
error,
|
||||
}: {
|
||||
projectId: string;
|
||||
incidentId: string;
|
||||
chain: AwoooPStatusChain | null;
|
||||
timeline: IncidentTimelineResponse | null;
|
||||
approvals: Approval[];
|
||||
legacyApprovals: LegacyApproval[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}) {
|
||||
const t = useTranslations("awooop.approvals.incidentFocus");
|
||||
const encodedProjectId = encodeURIComponent(projectId);
|
||||
const encodedIncidentId = encodeURIComponent(incidentId);
|
||||
const platformMatches = approvals.filter((approval) => approvalMatchesIncident(approval, incidentId));
|
||||
const legacyMatches = legacyApprovals.filter((approval) => approval.incident_id === incidentId);
|
||||
const timelineApprovalIds = timeline?.approval_ids ?? [];
|
||||
const linkedApprovalIds = uniqueValues([
|
||||
...timelineApprovalIds,
|
||||
...platformMatches.map((approval) => approval.run_id),
|
||||
...legacyMatches.map((approval) => approval.id),
|
||||
]);
|
||||
const stages = timeline?.timeline?.filter((stage) => stage.status !== "skipped") ?? [];
|
||||
const verifier = timeline?.timeline?.find((stage) => stage.stage === "verifier");
|
||||
const executor = timeline?.timeline?.find((stage) => stage.stage === "executor");
|
||||
const km = timeline?.timeline?.find((stage) => stage.stage === "km");
|
||||
const topMcpTool = chain?.mcp?.top_tools?.[0]?.tool_name ?? "--";
|
||||
const ansible = chain?.execution?.ansible;
|
||||
const outcome = chain?.operator_outcome;
|
||||
const needsHuman = chain?.needs_human ?? outcome?.needs_human ?? false;
|
||||
const title = timeline?.title ?? chain?.source_id ?? incidentId;
|
||||
const sourceCorrelation = chain?.source_refs?.correlation;
|
||||
|
||||
return (
|
||||
<section className="border border-[#e0ddd4] bg-white" aria-busy={loading}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3 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 truncate font-mono text-xs text-[#77736a]">{incidentId}</p>
|
||||
<p className="mt-1 truncate text-xs text-[#5f5b52]" title={title}>{title}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{loading ? (
|
||||
<span className="inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-white px-2 py-1 text-xs font-semibold text-[#5f5b52]">
|
||||
<RefreshCw className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
|
||||
{t("loading")}
|
||||
</span>
|
||||
) : null}
|
||||
<Link
|
||||
href={`/awooop/work-items?project_id=${encodedProjectId}&incident_id=${encodedIncidentId}` 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("openWorkItems")}
|
||||
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Link>
|
||||
<Link
|
||||
href={`/awooop/runs?project_id=${encodedProjectId}&incident_id=${encodedIncidentId}` 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-[#1f6feb]"
|
||||
>
|
||||
{t("openRuns")}
|
||||
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Link>
|
||||
<Link
|
||||
href={`/tickets?project_id=${encodedProjectId}&incident_id=${encodedIncidentId}` 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-[#2f7d72]"
|
||||
>
|
||||
{t("openTickets")}
|
||||
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="flex items-start gap-2 border-b border-[#ead9b4] bg-[#fff7e8] px-4 py-3 text-xs leading-5 text-[#8a5a08]">
|
||||
<TriangleAlert className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-px bg-[#e0ddd4] md:grid-cols-5">
|
||||
{[
|
||||
[t("metrics.approvals"), linkedApprovalIds.length || "--"],
|
||||
[t("metrics.stage"), chain?.current_stage ?? "--"],
|
||||
[t("metrics.repair"), chain?.repair_state ?? "--"],
|
||||
[t("metrics.verification"), verifier?.status ?? chain?.verification ?? "--"],
|
||||
[t("metrics.handoff"), needsHuman ? t("needsHuman.yes") : t("needsHuman.no")],
|
||||
].map(([label, value]) => (
|
||||
<div key={label} className="min-w-0 bg-white px-4 py-3">
|
||||
<p className="text-xs font-semibold text-[#77736a]">{label}</p>
|
||||
<p className="mt-2 truncate font-mono text-sm font-semibold text-[#141413]" title={String(value)}>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<AwoooPStatusChainPanel chain={chain} className="border-x-0 border-t-0" />
|
||||
|
||||
<div className="grid gap-px bg-[#e0ddd4] lg:grid-cols-[1.1fr_0.9fr]">
|
||||
<div className="min-w-0 bg-white p-4">
|
||||
<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("flowTitle")}</h4>
|
||||
</div>
|
||||
{timeline?.ascii_timeline ? (
|
||||
<p className="mt-3 break-words border border-[#eee9dd] bg-[#faf9f3] px-3 py-2 font-mono text-xs leading-6 text-[#5f5b52]">
|
||||
{timeline.ascii_timeline}
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-3 text-sm text-[#77736a]">
|
||||
{loading ? t("loading") : t("timelineEmpty")}
|
||||
</p>
|
||||
)}
|
||||
{stages.length > 0 ? (
|
||||
<div className="mt-3 grid gap-2 md:grid-cols-2">
|
||||
{stages.slice(0, 6).map((stage) => (
|
||||
<div key={stage.stage} className="min-w-0 border border-[#eee9dd] bg-white px-3 py-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-xs font-semibold text-[#77736a]">{stage.label}</span>
|
||||
<span className={cn("shrink-0 border px-2 py-0.5 text-[11px] font-semibold", timelineStatusClass(stage.status))}>
|
||||
{stage.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-xs font-semibold text-[#141413]" title={stage.title}>
|
||||
{stage.title}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</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("handoffTitle")}</h4>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-px border border-[#e0ddd4] bg-[#e0ddd4]">
|
||||
{[
|
||||
[t("handoff.approvalIds"), linkedApprovalIds.slice(0, 4).join(", ") || t("empty")],
|
||||
[t("handoff.pendingRows"), t("handoff.pendingRowsValue", { platform: platformMatches.length, legacy: legacyMatches.length })],
|
||||
[t("handoff.nextAction"), outcome?.next_action ?? chain?.next_step ?? "--"],
|
||||
[t("handoff.reason"), outcome?.human_action_reason ?? "--"],
|
||||
].map(([label, value]) => (
|
||||
<div key={label} className="min-w-0 bg-white px-3 py-2">
|
||||
<p className="text-xs font-semibold text-[#77736a]">{label}</p>
|
||||
<p className="mt-1 truncate font-mono text-xs text-[#141413]" title={String(value)}>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-3 text-xs leading-5 text-[#5f5b52]">
|
||||
{linkedApprovalIds.length > 0 ? t("linkedExplanation") : t("unlinkedExplanation")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-px bg-[#e0ddd4] md:grid-cols-4">
|
||||
{[
|
||||
[t("evidence.executor"), executor?.title ?? chain?.execution?.latest_operation_type ?? "--"],
|
||||
[t("evidence.ansible"), ansible?.latest_playbook_path ?? ansible?.latest_catalog_id ?? "--"],
|
||||
[t("evidence.mcp"), t("evidence.mcpValue", {
|
||||
success: chain?.mcp?.gateway?.success ?? 0,
|
||||
total: chain?.mcp?.gateway?.total ?? 0,
|
||||
tool: topMcpTool,
|
||||
})],
|
||||
[t("evidence.source"), sourceCorrelation
|
||||
? t("evidence.sourceValue", {
|
||||
direct: sourceCorrelation.direct_ref_total ?? 0,
|
||||
candidate: sourceCorrelation.candidate_total ?? 0,
|
||||
applied: sourceCorrelation.applied_link_total ?? 0,
|
||||
})
|
||||
: "--"],
|
||||
[t("evidence.km"), km?.title ?? String(chain?.evidence?.knowledge_entries ?? 0)],
|
||||
[t("evidence.command"), outcome?.execution_result?.summary_zh ?? "--"],
|
||||
[t("evidence.notification"), (outcome?.notification?.channels ?? []).join(", ") || "--"],
|
||||
[t("evidence.events"), timeline ? String(timeline.events.length) : "--"],
|
||||
].map(([label, value]) => (
|
||||
<div key={label} className="min-w-0 bg-white px-4 py-3">
|
||||
<p className="text-xs font-semibold text-[#77736a]">{label}</p>
|
||||
<p className="mt-2 truncate font-mono text-xs text-[#141413]" title={String(value)}>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Main Component
|
||||
// =============================================================================
|
||||
|
||||
export default function ApprovalsPage() {
|
||||
const t = useTranslations("awooop.approvals");
|
||||
const tEvidence = useTranslations("awooop.listEvidence");
|
||||
const tStatusChain = useTranslations("awooop.statusChain");
|
||||
const searchParams = useSearchParams();
|
||||
const queryIncidentId = searchParams.get("incident_id");
|
||||
const projectId = searchParams.get("project_id") ?? "awoooi";
|
||||
const [approvals, setApprovals] = useState<Approval[]>([]);
|
||||
const [legacyApprovals, setLegacyApprovals] = useState<LegacyApproval[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [legacyError, setLegacyError] = useState<string | null>(null);
|
||||
const [incidentChain, setIncidentChain] = useState<AwoooPStatusChain | null>(null);
|
||||
const [incidentTimeline, setIncidentTimeline] = useState<IncidentTimelineResponse | null>(null);
|
||||
const [incidentLoading, setIncidentLoading] = useState(false);
|
||||
const [incidentError, setIncidentError] = useState<string | null>(null);
|
||||
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
|
||||
const [evidenceFilter, setEvidenceFilter] = useState<"" | RemediationStatus>("");
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
@@ -919,7 +1177,7 @@ export default function ApprovalsPage() {
|
||||
setApprovals(Array.isArray(data.items) ? data.items : []);
|
||||
} else {
|
||||
setApprovals([]);
|
||||
setError(platformResult.reason instanceof Error ? platformResult.reason.message : "載入失敗");
|
||||
setError(platformResult.reason instanceof Error ? platformResult.reason.message : t("page.genericLoadFailed"));
|
||||
}
|
||||
|
||||
if (legacyResult.status === "fulfilled") {
|
||||
@@ -928,21 +1186,62 @@ export default function ApprovalsPage() {
|
||||
setLegacyApprovals(Array.isArray(pending) ? pending : []);
|
||||
} else {
|
||||
setLegacyApprovals([]);
|
||||
setLegacyError(legacyResult.reason instanceof Error ? legacyResult.reason.message : "載入失敗");
|
||||
setLegacyError(legacyResult.reason instanceof Error ? legacyResult.reason.message : t("page.genericLoadFailed"));
|
||||
}
|
||||
|
||||
setLastRefresh(new Date());
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "載入失敗");
|
||||
setError(err instanceof Error ? err.message : t("page.genericLoadFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [evidenceFilter]);
|
||||
}, [evidenceFilter, t]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchApprovals();
|
||||
}, [fetchApprovals]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!queryIncidentId) {
|
||||
setIncidentChain(null);
|
||||
setIncidentTimeline(null);
|
||||
setIncidentError(null);
|
||||
setIncidentLoading(false);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
const focusedIncidentId = queryIncidentId;
|
||||
|
||||
async function loadIncidentFocus() {
|
||||
setIncidentLoading(true);
|
||||
setIncidentError(null);
|
||||
const encodedProjectId = encodeURIComponent(projectId);
|
||||
const encodedIncidentId = encodeURIComponent(focusedIncidentId);
|
||||
const [statusChain, timeline] = await Promise.all([
|
||||
fetchJson<AwoooPStatusChain>(
|
||||
`${API_BASE}/api/v1/platform/status-chain?project_id=${encodedProjectId}&incident_id=${encodedIncidentId}`
|
||||
),
|
||||
fetchJson<IncidentTimelineResponse>(
|
||||
`${API_BASE}/api/v1/incidents/${encodedIncidentId}/timeline`
|
||||
),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setIncidentChain(statusChain);
|
||||
setIncidentTimeline(timeline);
|
||||
if (!statusChain && !timeline) {
|
||||
setIncidentError(t("incidentFocus.loadFailed"));
|
||||
}
|
||||
setIncidentLoading(false);
|
||||
}
|
||||
|
||||
loadIncidentFocus();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId, queryIncidentId, t]);
|
||||
|
||||
// 10 秒自動刷新
|
||||
useEffect(() => {
|
||||
intervalRef.current = setInterval(() => {
|
||||
@@ -975,23 +1274,26 @@ export default function ApprovalsPage() {
|
||||
const queueSummary = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: "待人工決策",
|
||||
label: t("summary.pending"),
|
||||
value: totalPendingCount,
|
||||
detail: `AwoooP ${approvals.length} / Legacy HITL ${legacyPendingCount}`,
|
||||
detail: t("summary.pendingDetail", {
|
||||
platform: approvals.length,
|
||||
legacy: legacyPendingCount,
|
||||
}),
|
||||
icon: ShieldCheck,
|
||||
className: "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]",
|
||||
},
|
||||
{
|
||||
label: "即將逾時",
|
||||
label: t("summary.critical"),
|
||||
value: criticalCount,
|
||||
detail: "5 分鐘內必須處置",
|
||||
detail: t("summary.criticalDetail"),
|
||||
icon: Clock,
|
||||
className: "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]",
|
||||
},
|
||||
{
|
||||
label: "已逾時",
|
||||
label: t("summary.expired"),
|
||||
value: expiredCount,
|
||||
detail: "不得再自動恢復",
|
||||
detail: t("summary.expiredDetail"),
|
||||
icon: TriangleAlert,
|
||||
className: "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]",
|
||||
},
|
||||
@@ -1026,6 +1328,7 @@ export default function ApprovalsPage() {
|
||||
noEvidenceCount,
|
||||
readOnlyEvidenceCount,
|
||||
totalPendingCount,
|
||||
t,
|
||||
tEvidence,
|
||||
]
|
||||
);
|
||||
@@ -1038,32 +1341,33 @@ export default function ApprovalsPage() {
|
||||
<ShieldCheck className="w-5 h-5 text-brand-accent" aria-hidden="true" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2">
|
||||
審批佇列
|
||||
{t("page.title")}
|
||||
{criticalCount > 0 && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-bold bg-red-600 text-white animate-pulse">
|
||||
{criticalCount} 緊急
|
||||
{t("page.urgentCount", { count: criticalCount })}
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{loading
|
||||
? "載入中..."
|
||||
: `${approvals.length} 筆待審 · 上次更新 ${
|
||||
lastRefresh ? lastRefresh.toLocaleTimeString("zh-TW") : "--"
|
||||
}`}
|
||||
? t("page.loading")
|
||||
: t("page.pendingRefresh", {
|
||||
count: approvals.length,
|
||||
time: lastRefresh ? lastRefresh.toLocaleTimeString("zh-TW") : "--",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">每 10 秒自動刷新</span>
|
||||
<span className="text-xs text-muted-foreground">{t("page.autoRefresh")}</span>
|
||||
<button
|
||||
onClick={() => { setLoading(true); fetchApprovals(); }}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg transition disabled:opacity-50"
|
||||
aria-label="立即重新整理"
|
||||
aria-label={t("page.refreshNow")}
|
||||
>
|
||||
<RefreshCw className={cn("w-4 h-4", loading && "animate-spin")} aria-hidden="true" />
|
||||
立即刷新
|
||||
{t("page.refreshNow")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1095,6 +1399,18 @@ export default function ApprovalsPage() {
|
||||
})}
|
||||
</section>
|
||||
|
||||
{queryIncidentId ? (
|
||||
<FocusedIncidentApprovalPanel
|
||||
projectId={projectId}
|
||||
incidentId={queryIncidentId}
|
||||
chain={incidentChain}
|
||||
timeline={incidentTimeline}
|
||||
approvals={approvals}
|
||||
legacyApprovals={legacyApprovals}
|
||||
loading={incidentLoading}
|
||||
error={incidentError}
|
||||
/>
|
||||
) : null}
|
||||
<SecurityOwnerResponseGatePanel />
|
||||
<GitHubPrimaryReadinessApprovalBoundaryPanel />
|
||||
<OwnerResponseValidationApprovalBoundaryPanel />
|
||||
@@ -1130,7 +1446,7 @@ export default function ApprovalsPage() {
|
||||
<div className="flex items-start gap-3 border border-[#e2a29b] bg-[#fff0ef] p-4">
|
||||
<AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" aria-hidden="true" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-300">無法載入審批資料</p>
|
||||
<p className="text-sm font-medium text-red-300">{t("page.loadFailed")}</p>
|
||||
<p className="text-xs text-red-400 mt-1">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1140,8 +1456,8 @@ export default function ApprovalsPage() {
|
||||
{!loading && approvals.length === 0 && !error && (
|
||||
<div className="flex flex-col items-center justify-center border border-[#e0ddd4] bg-white py-16">
|
||||
<ShieldCheck className="w-12 h-12 text-green-400 mb-3" aria-hidden="true" />
|
||||
<p className="text-sm font-medium text-foreground mb-1">審批佇列為空</p>
|
||||
<p className="text-xs text-muted-foreground">目前沒有待審批的執行項目</p>
|
||||
<p className="text-sm font-medium text-foreground mb-1">{t("empty.title")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("empty.subtitle")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1149,20 +1465,20 @@ export default function ApprovalsPage() {
|
||||
{(loading || approvals.length > 0) && (
|
||||
<div className="overflow-hidden border border-[#e0ddd4] bg-white shadow-[0_1px_4px_rgba(0,0,0,0.05)]">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full" role="table" aria-label="審批佇列">
|
||||
<table className="w-full" role="table" aria-label={t("page.title")}>
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/50">
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
執行 ID
|
||||
{t("columns.runId")}
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
專案 ID
|
||||
{t("columns.projectId")}
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
AI 代理
|
||||
{t("columns.agent")}
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
處置路線
|
||||
{t("columns.route")}
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{tEvidence("column")}
|
||||
@@ -1174,10 +1490,10 @@ export default function ApprovalsPage() {
|
||||
{tStatusChain("title")}
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
建立時間
|
||||
{t("columns.created")}
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
剩餘時間
|
||||
{t("columns.remaining")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
Reference in New Issue
Block a user