fix(web): connect authorizations to incident truth chain
All checks were successful
CD Pipeline / tests (push) Successful in 1m40s
Code Review / ai-code-review (push) Successful in 13s
CD Pipeline / build-and-deploy (push) Successful in 3m45s
CD Pipeline / post-deploy-checks (push) Successful in 2m12s

This commit is contained in:
Your Name
2026-05-31 21:03:10 +08:00
parent 5d49719bd4
commit 6add97b9d7
7 changed files with 585 additions and 21 deletions

View File

@@ -865,6 +865,23 @@
"signComment": "簽核備註 (選填)",
"enterComment": "輸入備註...",
"noApprovals": "目前沒有待簽核項目",
"csrfLoadFailed": "CSRF Token 載入失敗,簽核功能暫時無法使用",
"csrfLoading": "正在載入安全憑證...",
"loginIdentity": "登入身份",
"resolvedApproved": "已批准",
"resolvedRejected": "已拒絕",
"requiredRoleInline": "需要 {roles}",
"openIncidentTruthChain": "查看 Incident 授權真相鏈",
"securityValidationFailed": "安全驗證失敗,請重新整理頁面後再試",
"signFailed": "簽核失敗,請檢查網路連線或重新整理頁面",
"accessDenied": {
"title": "權限不足",
"riskBadge": "{risk} 風險",
"message": "此操作需要更高權限簽核",
"yourRole": "您的角色",
"requiredRoles": "需要以下角色之一",
"return": "了解,返回"
},
"fetchError": "無法取得授權清單",
"noPendingApprovals": "目前無待授權項目",
"selectApproval": "請選擇一個待授權項目",
@@ -889,6 +906,57 @@
"securityNote": "CRITICAL 風險與 DESTRUCTIVE 資料影響的項目需單獨審核,無法批次核准。"
}
},
"authorizations": {
"incidentFocus": {
"title": "焦點 Incident 授權真相鏈",
"loading": "讀取中",
"statusChainLoadFailed": "status-chain 載入失敗",
"timelineLoadFailed": "timeline 載入失敗",
"pendingLoadFailed": "pending approvals 載入失敗",
"openApprovals": "AwoooP 審批",
"openWorkItems": "Work Items",
"openRuns": "Runs",
"openTickets": "Tickets",
"authorizationTitle": "授權關聯狀態",
"flowTitle": "AI 處理流程",
"timelineEmpty": "尚未取得 Incident timeline。",
"emptyApprovalIds": "目前沒有關聯 approval id",
"notPendingExplanation": "指定 approval id 不在目前待簽清單;它可能已完成、過期、拒絕,或已轉成驗證後人工接手。",
"noPendingExplanation": "此 Incident 目前沒有 pending HITL 列;請依 status-chain 的下一步與 Work Items 追後續處置。",
"openFocusedAuthorization": "開啟此授權焦點",
"signatures": "簽核 {current}/{required}",
"boundary": "此區塊只做 read-only truth-chain 對齊:不新增批准、不拒絕、不觸發執行、不覆寫 pending approval。實際處置仍以 HITL 卡片、AwoooP Work Items 與 verified evidence 為準。",
"states": {
"pending": "仍在待簽",
"timelineLinked": "已在 timeline 關聯,非待簽",
"notPending": "不在待簽清單",
"notPendingLinked": "有歷史關聯,非待簽",
"noApproval": "無授權關聯"
},
"needsHuman": {
"yes": "需要人工",
"no": "不需人工"
},
"metrics": {
"authorization": "授權狀態",
"pendingRows": "待簽列",
"stage": "目前階段",
"verification": "驗證",
"handoff": "人工接手"
},
"evidence": {
"nextAction": "下一步",
"reason": "原因",
"execution": "執行判定",
"ansible": "Ansible",
"mcp": "MCP",
"mcpValue": "{success}/{total} successtop {tool}",
"km": "KM",
"notification": "通知通道",
"events": "Timeline events"
}
}
},
"risk": {
"low": "低風險",
"medium": "中風險",
@@ -4165,6 +4233,7 @@
"title": "焦點 Incident 審批真相鏈",
"loading": "讀取中",
"loadFailed": "焦點 Incident 真相鏈載入失敗;請改從 Work Items 或 Runs 檢查同一筆事件。",
"openAuthorizations": "授權中心",
"openWorkItems": "Work Items",
"openRuns": "Runs",
"openTickets": "Tickets",

View File

@@ -865,6 +865,23 @@
"signComment": "簽核備註 (選填)",
"enterComment": "輸入備註...",
"noApprovals": "目前沒有待簽核項目",
"csrfLoadFailed": "CSRF Token 載入失敗,簽核功能暫時無法使用",
"csrfLoading": "正在載入安全憑證...",
"loginIdentity": "登入身份",
"resolvedApproved": "已批准",
"resolvedRejected": "已拒絕",
"requiredRoleInline": "需要 {roles}",
"openIncidentTruthChain": "查看 Incident 授權真相鏈",
"securityValidationFailed": "安全驗證失敗,請重新整理頁面後再試",
"signFailed": "簽核失敗,請檢查網路連線或重新整理頁面",
"accessDenied": {
"title": "權限不足",
"riskBadge": "{risk} 風險",
"message": "此操作需要更高權限簽核",
"yourRole": "您的角色",
"requiredRoles": "需要以下角色之一",
"return": "了解,返回"
},
"fetchError": "無法取得授權清單",
"noPendingApprovals": "目前無待授權項目",
"selectApproval": "請選擇一個待授權項目",
@@ -889,6 +906,57 @@
"securityNote": "CRITICAL 風險與 DESTRUCTIVE 資料影響的項目需單獨審核,無法批次核准。"
}
},
"authorizations": {
"incidentFocus": {
"title": "焦點 Incident 授權真相鏈",
"loading": "讀取中",
"statusChainLoadFailed": "status-chain 載入失敗",
"timelineLoadFailed": "timeline 載入失敗",
"pendingLoadFailed": "pending approvals 載入失敗",
"openApprovals": "AwoooP 審批",
"openWorkItems": "Work Items",
"openRuns": "Runs",
"openTickets": "Tickets",
"authorizationTitle": "授權關聯狀態",
"flowTitle": "AI 處理流程",
"timelineEmpty": "尚未取得 Incident timeline。",
"emptyApprovalIds": "目前沒有關聯 approval id",
"notPendingExplanation": "指定 approval id 不在目前待簽清單;它可能已完成、過期、拒絕,或已轉成驗證後人工接手。",
"noPendingExplanation": "此 Incident 目前沒有 pending HITL 列;請依 status-chain 的下一步與 Work Items 追後續處置。",
"openFocusedAuthorization": "開啟此授權焦點",
"signatures": "簽核 {current}/{required}",
"boundary": "此區塊只做 read-only truth-chain 對齊:不新增批准、不拒絕、不觸發執行、不覆寫 pending approval。實際處置仍以 HITL 卡片、AwoooP Work Items 與 verified evidence 為準。",
"states": {
"pending": "仍在待簽",
"timelineLinked": "已在 timeline 關聯,非待簽",
"notPending": "不在待簽清單",
"notPendingLinked": "有歷史關聯,非待簽",
"noApproval": "無授權關聯"
},
"needsHuman": {
"yes": "需要人工",
"no": "不需人工"
},
"metrics": {
"authorization": "授權狀態",
"pendingRows": "待簽列",
"stage": "目前階段",
"verification": "驗證",
"handoff": "人工接手"
},
"evidence": {
"nextAction": "下一步",
"reason": "原因",
"execution": "執行判定",
"ansible": "Ansible",
"mcp": "MCP",
"mcpValue": "{success}/{total} successtop {tool}",
"km": "KM",
"notification": "通知通道",
"events": "Timeline events"
}
}
},
"risk": {
"low": "低風險",
"medium": "中風險",
@@ -4165,6 +4233,7 @@
"title": "焦點 Incident 審批真相鏈",
"loading": "讀取中",
"loadFailed": "焦點 Incident 真相鏈載入失敗;請改從 Work Items 或 Runs 檢查同一筆事件。",
"openAuthorizations": "授權中心",
"openWorkItems": "Work Items",
"openRuns": "Runs",
"openTickets": "Tickets",

View File

@@ -7,20 +7,119 @@
* 含 Multi-Sig、權限擋板、SSE 即時更新
*
* @updated 2026-04-01 ogt - 從佔位符升級為完整頁面
* @updated 2026-05-31 Codex - 焦點 Incident 接上 status-chain / timeline / pending approval 真相鏈
*/
import { useEffect, useState } from 'react'
import { useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { AppLayout } from '@/components/layout'
import { IncidentAuthorizationFocus } from '@/components/approval/incident-authorization-focus'
import { LiveApprovalPanel } from '@/components/approval/live-approval-panel'
import type { AwoooPStatusChain } from '@/components/awooop/status-chain'
import { IwoooSReadOnlyBridge } from '@/components/security/iwooos-read-only-bridge'
import type { IncidentTimelineResponse } from '@/lib/api-client'
import type { ApprovalRequest } from '@/stores/approval.store'
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ''
async function fetchJson<T>(url: string): Promise<T | null> {
const response = await fetch(url)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.json()
}
function normalizePendingApprovals(payload: unknown): ApprovalRequest[] {
if (Array.isArray(payload)) return payload as ApprovalRequest[]
if (payload && typeof payload === 'object' && Array.isArray((payload as { approvals?: unknown }).approvals)) {
return (payload as { approvals: ApprovalRequest[] }).approvals
}
return []
}
export default function AuthorizationsPage({
params,
}: {
params: { locale: string }
}) {
const t = useTranslations('authorizations.incidentFocus')
const searchParams = useSearchParams()
const projectId = searchParams.get('project_id') ?? 'awoooi'
const incidentId = searchParams.get('incident_id')
const approvalId = searchParams.get('approval_id')
const [chain, setChain] = useState<AwoooPStatusChain | null>(null)
const [timeline, setTimeline] = useState<IncidentTimelineResponse | null>(null)
const [pendingApprovals, setPendingApprovals] = useState<ApprovalRequest[]>([])
const [focusLoading, setFocusLoading] = useState(false)
const [focusError, setFocusError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
if (!incidentId) {
setChain(null)
setTimeline(null)
setPendingApprovals([])
setFocusError(null)
setFocusLoading(false)
return () => {
cancelled = true
}
}
const focusedIncidentId = incidentId
async function loadFocus() {
setFocusLoading(true)
setFocusError(null)
const encodedProjectId = encodeURIComponent(projectId)
const encodedIncidentId = encodeURIComponent(focusedIncidentId)
const [statusChainResult, timelineResult, pendingResult] = await Promise.allSettled([
fetchJson<AwoooPStatusChain>(
`${API_BASE}/api/v1/platform/status-chain?project_id=${encodedProjectId}&incident_id=${encodedIncidentId}`
),
fetchJson<IncidentTimelineResponse>(`${API_BASE}/api/v1/incidents/${encodedIncidentId}/timeline`),
fetchJson<unknown>(`${API_BASE}/api/v1/approvals/pending`),
])
if (cancelled) return
const nextChain = statusChainResult.status === 'fulfilled' ? statusChainResult.value : null
const nextTimeline = timelineResult.status === 'fulfilled' ? timelineResult.value : null
const nextPending = pendingResult.status === 'fulfilled' ? normalizePendingApprovals(pendingResult.value) : []
setChain(nextChain)
setTimeline(nextTimeline)
setPendingApprovals(nextPending)
const failures = [
statusChainResult.status === 'rejected' ? t('statusChainLoadFailed') : null,
timelineResult.status === 'rejected' ? t('timelineLoadFailed') : null,
pendingResult.status === 'rejected' ? t('pendingLoadFailed') : null,
].filter(Boolean)
setFocusError(failures.length > 0 ? failures.join(' / ') : null)
setFocusLoading(false)
}
loadFocus()
return () => {
cancelled = true
}
}, [incidentId, projectId, t])
return (
<AppLayout locale={params.locale}>
<div className="space-y-4">
{incidentId ? (
<IncidentAuthorizationFocus
projectId={projectId}
incidentId={incidentId}
approvalId={approvalId}
chain={chain}
timeline={timeline}
pendingApprovals={pendingApprovals}
loading={focusLoading}
error={focusError}
/>
) : null}
<IwoooSReadOnlyBridge />
<LiveApprovalPanel />
</div>

View File

@@ -987,6 +987,15 @@ function FocusedIncidentApprovalPanel({
{t("loading")}
</span>
) : null}
<Link
href={`/authorizations?project_id=${encodedProjectId}&incident_id=${encodedIncidentId}${
linkedApprovalIds[0] ? `&approval_id=${encodeURIComponent(linkedApprovalIds[0])}` : ""
}` 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("openAuthorizations")}
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
</Link>
<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]"

View File

@@ -0,0 +1,293 @@
"use client";
import { ArrowRight, GitBranch, KeyRound, ListChecks, RefreshCw, SearchCheck, TriangleAlert } from "lucide-react";
import { useTranslations } from "next-intl";
import {
AwoooPStatusChainPanel,
type AwoooPStatusChain,
} from "@/components/awooop/status-chain";
import { Link } from "@/i18n/routing";
import type { IncidentTimelineResponse } from "@/lib/api-client";
import { cn } from "@/lib/utils";
import type { ApprovalRequest } from "@/stores/approval.store";
function uniqueValues(values: Array<string | null | undefined>) {
return Array.from(new Set(values.filter((value): value is string => Boolean(value))));
}
function timelineStatusClass(status?: string | null) {
if (status === "ok" || status === "success" || status === "completed") {
return "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]";
}
if (status === "warn" || status === "warning" || status === "degraded") {
return "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]";
}
if (status === "fail" || status === "failed" || status === "blocked") {
return "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]";
}
return "border-[#d8d3c7] bg-[#faf9f3] text-[#5f5b52]";
}
function idMatchesIncident(approval: ApprovalRequest, incidentId: string) {
return approval.incident_id === incidentId;
}
function idMatchesApproval(approval: ApprovalRequest, approvalId?: string | null) {
return Boolean(approvalId && approval.id === approvalId);
}
export function IncidentAuthorizationFocus({
projectId,
incidentId,
approvalId,
chain,
timeline,
pendingApprovals,
loading,
error,
}: {
projectId: string;
incidentId: string;
approvalId?: string | null;
chain: AwoooPStatusChain | null;
timeline: IncidentTimelineResponse | null;
pendingApprovals: ApprovalRequest[];
loading: boolean;
error: string | null;
}) {
const t = useTranslations("authorizations.incidentFocus");
const encodedProjectId = encodeURIComponent(projectId);
const encodedIncidentId = encodeURIComponent(incidentId);
const encodedApprovalId = approvalId ? encodeURIComponent(approvalId) : null;
const incidentPendingMatches = pendingApprovals.filter((approval) => idMatchesIncident(approval, incidentId));
const requestedApproval = pendingApprovals.find((approval) => idMatchesApproval(approval, approvalId));
const timelineApprovalIds = timeline?.approval_ids ?? [];
const linkedApprovalIds = uniqueValues([
approvalId,
...timelineApprovalIds,
...incidentPendingMatches.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 ansible = chain?.execution?.ansible;
const outcome = chain?.operator_outcome;
const needsHuman = chain?.needs_human ?? outcome?.needs_human ?? false;
const topMcpTool = chain?.mcp?.top_tools?.[0]?.tool_name ?? "--";
const title = timeline?.title ?? chain?.source_id ?? incidentId;
const requestedState = (() => {
if (approvalId && requestedApproval) return t("states.pending");
if (approvalId && timelineApprovalIds.includes(approvalId)) return t("states.timelineLinked");
if (approvalId) return t("states.notPending");
if (incidentPendingMatches.length > 0) return t("states.pending");
if (linkedApprovalIds.length > 0) return t("states.notPendingLinked");
return t("states.noApproval");
})();
const authorizationHref = `/authorizations?project_id=${encodedProjectId}&incident_id=${encodedIncidentId}${
encodedApprovalId ? `&approval_id=${encodedApprovalId}` : ""
}`;
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]">
<KeyRound className="h-4 w-4" aria-hidden="true" />
</span>
<div className="min-w-0">
<h2 className="text-sm font-semibold text-[#141413]">{t("title")}</h2>
<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/approvals?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("openApprovals")}
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
</Link>
<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.authorization"), requestedState],
[t("metrics.pendingRows"), String(incidentPendingMatches.length)],
[t("metrics.stage"), chain?.current_stage ?? "--"],
[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-[1fr_1fr]">
<div className="min-w-0 bg-white p-4">
<div className="flex items-center gap-2">
<SearchCheck className="h-4 w-4 text-brand-accent" aria-hidden="true" />
<h3 className="text-sm font-semibold text-[#141413]">{t("authorizationTitle")}</h3>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{linkedApprovalIds.length > 0 ? linkedApprovalIds.slice(0, 6).map((id) => (
<Link
key={id}
href={`/authorizations?project_id=${encodedProjectId}&incident_id=${encodedIncidentId}&approval_id=${encodeURIComponent(id)}` as never}
className={cn(
"inline-flex max-w-full items-center gap-1.5 border px-2 py-1 font-mono text-[11px] font-semibold",
id === approvalId
? "border-[#1f6feb] bg-[#eef5ff] text-[#1f5b9b]"
: "border-[#d8d3c7] bg-[#faf9f3] text-[#5f5b52]"
)}
>
<span className="truncate">{id}</span>
</Link>
)) : (
<span className="text-sm text-[#77736a]">{t("emptyApprovalIds")}</span>
)}
</div>
{incidentPendingMatches.length > 0 ? (
<div className="mt-3 grid gap-2">
{incidentPendingMatches.slice(0, 3).map((approval) => (
<div key={approval.id} 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 font-mono text-xs font-semibold text-[#141413]">{approval.id}</span>
<span className={cn("shrink-0 border px-2 py-0.5 text-[11px] font-semibold", timelineStatusClass(approval.status))}>
{approval.status}
</span>
</div>
<p className="mt-1 truncate text-xs text-[#5f5b52]" title={approval.action}>{approval.action}</p>
<p className="mt-1 font-mono text-[11px] text-[#77736a]">
{t("signatures", {
current: approval.current_signatures,
required: approval.required_signatures,
})}
</p>
</div>
))}
</div>
) : (
<p className="mt-3 text-xs leading-5 text-[#5f5b52]">
{approvalId ? t("notPendingExplanation") : t("noPendingExplanation")}
</p>
)}
<Link
href={authorizationHref as never}
className="mt-3 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("openFocusedAuthorization")}
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
</Link>
</div>
<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" />
<h3 className="text-sm font-semibold text-[#141413]">{t("flowTitle")}</h3>
</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>
<div className="grid gap-px bg-[#e0ddd4] md:grid-cols-4">
{[
[t("evidence.nextAction"), outcome?.next_action ?? chain?.next_step ?? "--"],
[t("evidence.reason"), outcome?.human_action_reason ?? "--"],
[t("evidence.execution"), outcome?.execution_result?.summary_zh ?? executor?.title ?? "--"],
[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.km"), km?.title ?? String(chain?.evidence?.knowledge_entries ?? 0)],
[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>
<div className="flex items-start gap-2 border-t border-[#e0ddd4] bg-[#faf9f3] px-4 py-3 text-xs leading-5 text-[#5f5b52]">
<ListChecks className="mt-0.5 h-4 w-4 shrink-0 text-brand-accent" aria-hidden="true" />
<p>{t("boundary")}</p>
</div>
</section>
);
}

View File

@@ -17,6 +17,7 @@
import { useState, useCallback, useMemo, useEffect } from 'react'
import { useTranslations } from 'next-intl'
import { Link } from '@/i18n/routing'
import { useApprovalStore, usePendingApprovals, toFrontendApproval } from '@/stores/approval.store'
import { useCSRF } from '@/hooks/useCSRF'
import { Z_INDEX, CURRENT_USER } from '@/lib/constants'
@@ -31,7 +32,7 @@ import {
import { toast } from '@/components/ui/toast'
import { StatusOrb } from '@/components/ui/status-orb'
import { cn } from '@/lib/utils'
import { ShieldX, Lock, AlertTriangle } from 'lucide-react'
import { AlertTriangle, CheckCircle2, ExternalLink, KeyRound, Lock, ShieldX, XCircle } from 'lucide-react'
// =============================================================================
// Types
@@ -104,6 +105,7 @@ export function LiveApprovalPanel({
}: LiveApprovalPanelProps) {
const t = useTranslations('approval')
const tCommon = useTranslations('common')
const tRisk = useTranslations('risk')
const { signApproval, rejectApproval, error } = useApprovalStore()
const pendingApprovals = usePendingApprovals()
@@ -155,7 +157,7 @@ export function LiveApprovalPanel({
// Phase 20: CSRF 保護 - 必須有 Token 才能簽核
if (!csrfToken) {
console.error('[HITL] CSRF token not available, cannot sign')
toast.error('安全驗證失敗,請重新整理頁面後再試')
toast.error(t('securityValidationFailed'))
return
}
@@ -189,7 +191,7 @@ export function LiveApprovalPanel({
} else {
setSigningStates((prev) => ({ ...prev, [id]: 'error' }))
// 🔴 Phase 22 P0: 新增錯誤 Toast 提示
toast.error('簽核失敗,請檢查網路連線或重新整理頁面')
toast.error(t('signFailed'))
setTimeout(() => {
setSigningStates((prev) => {
const next = { ...prev }
@@ -199,7 +201,7 @@ export function LiveApprovalPanel({
}, 3000)
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- currentUser is stable within render
}, [signApproval, signerId, signerName, currentUser, csrfToken])
}, [signApproval, signerId, signerName, currentUser, csrfToken, t])
// Handle reject
const handleReject = useCallback((id: string) => {
@@ -213,7 +215,7 @@ export function LiveApprovalPanel({
// Phase 20: CSRF 保護 - 必須有 Token 才能拒絕
if (!csrfToken) {
console.error('[HITL] CSRF token not available, cannot reject')
toast.error('安全驗證失敗,請重新整理頁面後再試')
toast.error(t('securityValidationFailed'))
return
}
@@ -231,7 +233,7 @@ export function LiveApprovalPanel({
setRejectModalId(null)
setRejectReason('')
}, [rejectModalId, rejectReason, rejectApproval, signerId, signerName, csrfToken])
}, [rejectModalId, rejectReason, rejectApproval, signerId, signerName, csrfToken, t])
// Convert to frontend format
const approvals: ApprovalRequest[] = pendingApprovals.map(toFrontendApproval)
@@ -261,8 +263,9 @@ export function LiveApprovalPanel({
{/* Phase 20: CSRF Error State */}
{csrfError && (
<div className="p-4 bg-status-warning/10 border border-status-warning/30 rounded-lg">
<p className="text-sm text-status-warning font-body">
CSRF Token 使
<p className="flex items-center gap-2 text-sm text-status-warning font-body">
<AlertTriangle className="h-4 w-4 shrink-0" aria-hidden="true" />
{t('csrfLoadFailed')}
</p>
</div>
)}
@@ -270,8 +273,9 @@ export function LiveApprovalPanel({
{/* Phase 20: CSRF Loading State */}
{csrfLoading && (
<div className="p-4 bg-claw-blue/10 border border-claw-blue/30 rounded-lg">
<p className="text-sm text-claw-blue font-body">
🔐 ...
<p className="flex items-center gap-2 text-sm text-claw-blue font-body">
<KeyRound className="h-4 w-4 shrink-0" aria-hidden="true" />
{t('csrfLoading')}
</p>
</div>
)}
@@ -292,7 +296,7 @@ export function LiveApprovalPanel({
<div className="flex items-center gap-2 px-4 py-2 bg-nothing-gray-100 rounded-lg w-fit">
<Lock className="w-4 h-4 text-nothing-gray-500" />
<span className="font-body text-xs text-nothing-gray-600">
: <span className="font-bold text-nothing-gray-800">{currentUser.name}</span>
{t('loginIdentity')}: <span className="font-bold text-nothing-gray-800">{currentUser.name}</span>
</span>
<span className={cn(
'px-2 py-0.5 rounded text-[10px] font-body font-bold uppercase',
@@ -326,12 +330,17 @@ export function LiveApprovalPanel({
{/* 🔴 已解決狀態橫幅 */}
{isResolved && (
<div className={cn(
'absolute -top-3 left-1/2 -translate-x-1/2 z-10 px-4 py-1 rounded-full font-body text-xs font-bold uppercase tracking-wider shadow-lg',
'absolute -top-3 left-1/2 -translate-x-1/2 z-10 flex items-center gap-1.5 px-4 py-1 rounded-full font-body text-xs font-bold tracking-wider shadow-lg',
resolvedStatus === 'approved'
? 'bg-status-healthy text-white'
: 'bg-status-critical text-white'
)}>
{resolvedStatus === 'approved' ? '✓ 已批准' : '✗ 已拒絕'}
{resolvedStatus === 'approved' ? (
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />
) : (
<XCircle className="h-3.5 w-3.5" aria-hidden="true" />
)}
{resolvedStatus === 'approved' ? t('resolvedApproved') : t('resolvedRejected')}
</div>
)}
<ApprovalCard
@@ -347,10 +356,20 @@ export function LiveApprovalPanel({
{!canSignApproval(currentUser.role, approval.riskLevel) && (
<div className="absolute top-2 right-2 flex items-center gap-1 px-2 py-1 bg-status-critical/10 border border-status-critical/30 rounded text-[10px] font-body text-status-critical">
<Lock className="w-3 h-3" />
{getRequiredRolesDisplay(approval.riskLevel)}
{t('requiredRoleInline', { roles: getRequiredRolesDisplay(approval.riskLevel) })}
</div>
)}
{backendApproval?.incident_id && (
<Link
href={`/authorizations?project_id=awoooi&incident_id=${encodeURIComponent(backendApproval.incident_id)}&approval_id=${encodeURIComponent(backendApproval.id)}` as never}
className="mt-2 inline-flex items-center gap-1.5 border border-nothing-gray-200 bg-white px-2.5 py-1 text-xs font-body font-semibold text-nothing-gray-700 hover:border-claw-blue hover:text-claw-blue"
>
<ExternalLink className="h-3.5 w-3.5" aria-hidden="true" />
{t('openIncidentTruthChain')}
</Link>
)}
{/* Success Overlay */}
{signingStates[approval.id] === 'success' && (
<div className="absolute inset-0 flex items-center justify-center bg-status-healthy/20 rounded-xl">
@@ -432,29 +451,29 @@ export function LiveApprovalPanel({
{/* Title */}
<h3 className="font-dot-matrix text-2xl text-status-critical mb-2">
ACCESS DENIED
{t('accessDenied.title')}
</h3>
{/* Risk Level Badge */}
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-status-critical/10 border border-status-critical/30 mb-4">
<AlertTriangle className="w-4 h-4 text-status-critical" />
<span className="font-body text-sm text-status-critical font-semibold uppercase">
{accessDeniedModal.riskLevel} RISK
{t('accessDenied.riskBadge', { risk: tRisk(accessDeniedModal.riskLevel) })}
</span>
</div>
{/* Message */}
<p className="text-nothing-gray-600 font-body text-sm mb-2">
{t('accessDenied.message')}
</p>
<p className="text-nothing-gray-500 font-body text-xs mb-6">
: <span className="text-status-warning font-bold">{currentUser.role.toUpperCase()}</span>
{t('accessDenied.yourRole')}: <span className="text-status-warning font-bold">{currentUser.role.toUpperCase()}</span>
</p>
{/* Required Roles */}
<div className="bg-nothing-gray-50 rounded-lg p-4 mb-6">
<p className="text-[10px] text-nothing-gray-500 font-body uppercase tracking-wider mb-2">
{t('accessDenied.requiredRoles')}
</p>
<div className="flex flex-wrap justify-center gap-2">
{accessDeniedModal.requiredRoles.split(' / ').map((role) => (
@@ -473,7 +492,7 @@ export function LiveApprovalPanel({
onClick={() => setAccessDeniedModal(null)}
className="w-full px-6 py-3 rounded-xl font-body text-sm font-semibold bg-nothing-gray-100 text-nothing-gray-700 hover:bg-nothing-gray-200 transition-colors"
>
{t('accessDenied.return')}
</button>
</div>
</GlassCard>

View File

@@ -19,7 +19,7 @@ import type { ApprovalRequest as FrontendApprovalRequest } from '@/components/ap
// =============================================================================
export type ApprovalStatus = 'pending' | 'approved' | 'rejected' | 'expired'
export type RiskLevel = 'low' | 'medium' | 'critical'
export type RiskLevel = 'low' | 'medium' | 'high' | 'critical'
export type DataImpact = 'none' | 'read_only' | 'write' | 'destructive'
export interface BlastRadius {
@@ -62,6 +62,11 @@ export interface ApprovalRequest {
fingerprint?: string | null
hit_count?: number
last_seen_at?: string | null
incident_id?: string | null
matched_playbook_id?: string | null
telegram_message_id?: number | null
telegram_chat_id?: number | null
metadata?: Record<string, unknown> | null
}
export interface SignResponse {
@@ -698,6 +703,7 @@ export function toFrontendApproval(backend: ApprovalRequest): FrontendApprovalRe
action: backend.action,
description: backend.description,
riskLevel: backend.risk_level === 'critical' ? 'critical' :
backend.risk_level === 'high' ? 'high' :
backend.risk_level === 'medium' ? 'medium' : 'low',
blastRadius: {
affectedPods: backend.blast_radius.affected_pods,