diff --git a/apps/api/src/api/v1/platform/operator_runs.py b/apps/api/src/api/v1/platform/operator_runs.py index 930a832f..7ddac51e 100644 --- a/apps/api/src/api/v1/platform/operator_runs.py +++ b/apps/api/src/api/v1/platform/operator_runs.py @@ -51,6 +51,7 @@ class RunItem(BaseModel): step_count: int created_at: datetime timeout_at: datetime | None + remediation_summary: dict[str, Any] | None = None class ListRunsResponse(BaseModel): @@ -66,6 +67,7 @@ class ApprovalItem(BaseModel): agent_id: str created_at: datetime timeout_at: datetime | None + remediation_summary: dict[str, Any] | None = None class ListApprovalsResponse(BaseModel): diff --git a/apps/api/src/services/platform_operator_service.py b/apps/api/src/services/platform_operator_service.py index d52deaae..98de6015 100644 --- a/apps/api/src/services/platform_operator_service.py +++ b/apps/api/src/services/platform_operator_service.py @@ -10,6 +10,7 @@ from __future__ import annotations import re import uuid +from collections import defaultdict from datetime import UTC, datetime from typing import Any from uuid import UUID @@ -40,6 +41,7 @@ _DEFAULT_PER_PAGE = 50 _MAX_PER_PAGE = 200 _MAX_EVENTS = 100 _MAX_TIMELINE_ITEMS = 100 +_MAX_LIST_CONTEXT_ROWS = 500 _MAX_STEP_SUMMARY_CHARS = 128 _REMEDIATION_HISTORY_LIMIT = 20 _INCIDENT_ID_RE = re.compile(r"\bINC-\d{8}-[A-Z0-9]{4,}\b") @@ -151,6 +153,14 @@ async def list_runs( result = await db.execute(stmt) rows = list(result.scalars().all()) + inbound_by_run, outbound_by_run = await _load_run_message_context(db, rows) + + remediation_summaries = await _build_run_remediation_summaries( + runs=rows, + inbound_by_run=inbound_by_run, + outbound_by_run=outbound_by_run, + ) + runs = [ { "run_id": r.run_id, @@ -162,6 +172,7 @@ async def list_runs( "step_count": r.step_count, "created_at": r.created_at, "timeout_at": r.timeout_at, + "remediation_summary": remediation_summaries.get(r.run_id), } for r in rows ] @@ -309,6 +320,67 @@ def _collect_run_incident_ids( return incident_ids +async def _load_run_message_context( + db: Any, + runs: list[AwoooPRunState], +) -> tuple[ + dict[UUID, list[AwoooPConversationEvent]], + dict[UUID, list[AwoooPOutboundMessage]], +]: + """Load list-page sidecar events needed to link runs back to incidents.""" + if not runs: + return {}, {} + + run_ids = [run.run_id for run in runs] + run_ids_set = set(run_ids) + trigger_refs = [str(run.trigger_ref) for run in runs if run.trigger_ref] + trigger_ref_to_run = { + str(run.trigger_ref): run.run_id + for run in runs + if run.trigger_ref + } + trigger_event_ids: list[UUID] = [] + for trigger_ref in trigger_refs: + try: + trigger_event_ids.append(uuid.UUID(trigger_ref)) + except ValueError: + continue + + inbound_filters = [AwoooPConversationEvent.run_id.in_(run_ids)] + if trigger_refs: + inbound_filters.append(AwoooPConversationEvent.provider_event_id.in_(trigger_refs)) + if trigger_event_ids: + inbound_filters.append(AwoooPConversationEvent.event_id.in_(trigger_event_ids)) + + inbound_result = await db.execute( + select(AwoooPConversationEvent) + .where(sa_or(*inbound_filters)) + .order_by(AwoooPConversationEvent.received_at.desc()) + .limit(_MAX_LIST_CONTEXT_ROWS) + ) + inbound_by_run: dict[UUID, list[AwoooPConversationEvent]] = defaultdict(list) + for event in inbound_result.scalars().all(): + target_run_id = event.run_id if event.run_id in run_ids_set else None + if target_run_id is None: + target_run_id = trigger_ref_to_run.get(str(event.provider_event_id)) + if target_run_id is None: + target_run_id = trigger_ref_to_run.get(str(event.event_id)) + if target_run_id is not None: + inbound_by_run[target_run_id].append(event) + + outbound_result = await db.execute( + select(AwoooPOutboundMessage) + .where(AwoooPOutboundMessage.run_id.in_(run_ids)) + .order_by(AwoooPOutboundMessage.queued_at.desc()) + .limit(_MAX_LIST_CONTEXT_ROWS) + ) + outbound_by_run: dict[UUID, list[AwoooPOutboundMessage]] = defaultdict(list) + for message in outbound_result.scalars().all(): + outbound_by_run[message.run_id].append(message) + + return dict(inbound_by_run), dict(outbound_by_run) + + def _route_label_from_remediation(item: dict[str, Any]) -> str: """Render remediation MCP route consistently with Telegram / Work Items.""" return "/".join( @@ -341,6 +413,132 @@ def _remediation_timeline_summary(item: dict[str, Any]) -> str: )[:500] +def _run_remediation_list_summary( + *, + run: AwoooPRunState, + incident_ids: list[str], + items: list[dict[str, Any]], + errors: list[dict[str, str]] | None = None, +) -> dict[str, Any]: + """Summarize durable ADR-100 dry-run evidence for list-level UX.""" + sorted_items = sorted( + (item for item in items if isinstance(item, dict)), + key=lambda item: str(item.get("created_at") or ""), + reverse=True, + ) + latest = sorted_items[0] if sorted_items else {} + writes_incident = latest.get("writes_incident_state") + writes_auto_repair = latest.get("writes_auto_repair_result") + route = _route_label_from_remediation(latest) if latest else "--" + write_observed = writes_incident is True or writes_auto_repair is True + is_read_only = ( + bool(latest) + and latest.get("required_scope") == "read" + and writes_incident is False + and writes_auto_repair is False + ) + + if not sorted_items: + status_value = "no_evidence" + elif latest.get("success") is False or latest.get("allowed") is False: + status_value = "blocked" + elif write_observed: + status_value = "write_observed" + elif is_read_only: + status_value = "read_only_dry_run" + else: + status_value = "observed" + + return { + "schema_version": "awooop_run_remediation_summary_v1", + "source": "alert_operation_log", + "incident_ids": incident_ids, + "total": len(sorted_items), + "status": status_value, + "has_dry_run": bool(sorted_items), + "is_read_only": is_read_only, + "human_gate_open": run.state == "waiting_approval", + "latest_at": latest.get("created_at"), + "latest_preview": latest.get("verification_result_preview"), + "latest_mode": latest.get("mode"), + "latest_route": route, + "latest_agent_id": latest.get("agent_id"), + "latest_tool_name": latest.get("tool_name"), + "latest_required_scope": latest.get("required_scope"), + "writes_incident_state": writes_incident, + "writes_auto_repair_result": writes_auto_repair, + "errors": errors or [], + } + + +async def _build_run_remediation_summaries( + *, + runs: list[AwoooPRunState], + inbound_by_run: dict[UUID, list[AwoooPConversationEvent]], + outbound_by_run: dict[UUID, list[AwoooPOutboundMessage]], +) -> dict[UUID, dict[str, Any]]: + """Build remediation summaries for list endpoints without writing state.""" + if not runs: + return {} + + incident_ids_by_run: dict[UUID, list[str]] = {} + all_incident_ids: list[str] = [] + for run in runs: + incident_ids = _collect_run_incident_ids( + run=run, + inbound_events=inbound_by_run.get(run.run_id, []), + outbound_messages=outbound_by_run.get(run.run_id, []), + ) + incident_ids_by_run[run.run_id] = incident_ids + for incident_id in incident_ids: + _append_unique(all_incident_ids, incident_id) + + histories_by_incident: dict[str, list[dict[str, Any]]] = {} + errors_by_incident: dict[str, dict[str, str]] = {} + if all_incident_ids: + from src.services.adr100_remediation_service import Adr100RemediationService + + service = Adr100RemediationService(record_history=False) + for incident_id in all_incident_ids: + try: + history = await service.history( + limit=_REMEDIATION_HISTORY_LIMIT, + incident_id=incident_id, + ) + histories_by_incident[incident_id] = [ + item + for item in history.get("items", []) + if isinstance(item, dict) + ] + except Exception as exc: + logger.warning( + "run_list_remediation_history_fetch_failed", + incident_id=incident_id, + error=str(exc), + ) + errors_by_incident[incident_id] = { + "incident_id": incident_id, + "error": str(exc), + } + + summaries: dict[UUID, dict[str, Any]] = {} + for run in runs: + incident_ids = incident_ids_by_run.get(run.run_id, []) + items: list[dict[str, Any]] = [] + errors: list[dict[str, str]] = [] + for incident_id in incident_ids: + items.extend(histories_by_incident.get(incident_id, [])) + if incident_id in errors_by_incident: + errors.append(errors_by_incident[incident_id]) + summaries[run.run_id] = _run_remediation_list_summary( + run=run, + incident_ids=incident_ids, + items=items, + errors=errors, + ) + return summaries + + def _timeline_sort_key(item: dict[str, Any], fallback_ts: Any) -> str: """Normalize mixed DB datetime / ISO string timestamps for timeline sorting.""" value = item.get("ts") or fallback_ts @@ -816,6 +1014,14 @@ async def list_approvals( result = await db.execute(stmt) rows = list(result.scalars().all()) + inbound_by_run, outbound_by_run = await _load_run_message_context(db, rows) + + remediation_summaries = await _build_run_remediation_summaries( + runs=rows, + inbound_by_run=inbound_by_run, + outbound_by_run=outbound_by_run, + ) + items = [ { "run_id": r.run_id, @@ -823,6 +1029,7 @@ async def list_approvals( "agent_id": r.agent_id, "created_at": r.created_at, "timeout_at": r.timeout_at, + "remediation_summary": remediation_summaries.get(r.run_id), } for r in rows ] diff --git a/apps/api/tests/test_awooop_operator_timeline_labels.py b/apps/api/tests/test_awooop_operator_timeline_labels.py index 180aad90..aef55c60 100644 --- a/apps/api/tests/test_awooop_operator_timeline_labels.py +++ b/apps/api/tests/test_awooop_operator_timeline_labels.py @@ -4,6 +4,7 @@ from types import SimpleNamespace from src.services.platform_operator_service import ( _collect_run_incident_ids, _outbound_timeline_title, + _run_remediation_list_summary, _remediation_timeline_summary, _timeline_sort_key, ) @@ -105,6 +106,58 @@ def test_remediation_timeline_summary_surfaces_route_and_write_flags() -> None: assert "writes_auto_repair=False" in summary +def test_run_remediation_list_summary_marks_read_only_dry_run() -> None: + run = SimpleNamespace(state="waiting_approval") + + summary = _run_remediation_list_summary( + run=run, + incident_ids=["INC-20260514-F85F21"], + items=[ + { + "created_at": "2026-05-14T23:04:00+00:00", + "incident_id": "INC-20260514-F85F21", + "mode": "replay", + "verification_result_preview": "degraded", + "agent_id": "auto_repair_executor", + "tool_name": "ssh_diagnose", + "required_scope": "read", + "writes_incident_state": False, + "writes_auto_repair_result": False, + } + ], + ) + + assert summary["status"] == "read_only_dry_run" + assert summary["has_dry_run"] is True + assert summary["is_read_only"] is True + assert summary["human_gate_open"] is True + assert summary["latest_route"] == "auto_repair_executor/ssh_diagnose/read" + + +def test_run_remediation_list_summary_flags_write_observed() -> None: + run = SimpleNamespace(state="completed") + + summary = _run_remediation_list_summary( + run=run, + incident_ids=["INC-20260514-F85F21"], + items=[ + { + "created_at": "2026-05-14T23:05:00+00:00", + "incident_id": "INC-20260514-F85F21", + "agent_id": "auto_repair_executor", + "tool_name": "state_update", + "required_scope": "write", + "writes_incident_state": True, + "writes_auto_repair_result": False, + } + ], + ) + + assert summary["status"] == "write_observed" + assert summary["is_read_only"] is False + assert summary["writes_incident_state"] is True + + def test_timeline_sort_key_normalizes_datetime_and_iso_string() -> None: fallback = datetime(2026, 5, 14, 10, 0, 0) keys = [ diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index d2bae95f..287a28d7 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -1796,6 +1796,39 @@ "timelineMissing": "Quality summary still reports a Timeline / audit gap" } }, + "listEvidence": { + "column": "AI Evidence", + "count": "{count} dry-runs", + "route": "MCP: {route}", + "emptyShort": "No remediation dry-run linked", + "manualGate": "Next: human approval", + "statuses": { + "noEvidence": "No dry-run yet", + "readOnlyDryRun": "AI dry-run: read-only", + "writeObserved": "Write flag observed", + "blocked": "Dry-run blocked", + "observed": "Evidence linked" + }, + "details": { + "noEvidence": "This row is not linked to ADR-100 remediation dry-run records in alert_operation_log yet.", + "readOnlyDryRun": "AI has run the remediation dry-run and the latest record did not write incident or auto-repair state.", + "writeObserved": "The latest remediation record contains write flags; verify the state-change source before approval.", + "blocked": "The remediation dry-run failed or was blocked by a gate; human review is required.", + "observed": "This row is linked to remediation history; open Run Timeline for the full evidence." + }, + "summary": { + "readOnly": "Read-only dry-run", + "readOnlyDetail": "Latest evidence shows AI trialed the action without writing state", + "manualGate": "Human gate", + "manualGateDetail": "AI is stopped at the approval gate and needs approve / reject", + "writeObserved": "Write flags", + "writeObservedDetail": "Verify whether this is the expected auto-repair result", + "noEvidence": "Missing evidence", + "noEvidenceDetail": "The list row is not linked to ADR-100 dry-run history yet", + "approvalReadOnlyDetail": "Read-only remediation evidence is visible before approval", + "approvalNoEvidenceDetail": "Approval still lacks remediation dry-run evidence; inspect 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 581ddbc0..a0eb3d90 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -1797,6 +1797,39 @@ "timelineMissing": "品質總覽仍指出 Timeline / 稽核記錄缺口" } }, + "listEvidence": { + "column": "AI 證據", + "count": "試跑 {count} 次", + "route": "MCP:{route}", + "emptyShort": "尚未連到補救試跑", + "manualGate": "下一步:人工審批", + "statuses": { + "noEvidence": "尚無試跑", + "readOnlyDryRun": "AI 已試跑:只讀", + "writeObserved": "有寫入旗標", + "blocked": "試跑受阻", + "observed": "有補救證據" + }, + "details": { + "noEvidence": "此列尚未從 alert_operation_log 連到 ADR-100 補救試跑。", + "readOnlyDryRun": "AI 已走補救試跑,且最新紀錄沒有寫入 incident 或 auto-repair 狀態。", + "writeObserved": "最新補救紀錄含寫入旗標,審批前需確認狀態變更來源。", + "blocked": "補救試跑未通過或被 gate 阻擋,需人工確認卡點。", + "observed": "此列已連到補救歷史,請進入 Run Timeline 查看完整證據。" + }, + "summary": { + "readOnly": "只讀試跑", + "readOnlyDetail": "最新證據顯示 AI 已試跑且未寫狀態", + "manualGate": "人工閘門", + "manualGateDetail": "AI 已停在 approval gate,需 approve / reject", + "writeObserved": "寫入旗標", + "writeObservedDetail": "需確認是否為預期自動修復結果", + "noEvidence": "缺補救證據", + "noEvidenceDetail": "列表尚未連到 ADR-100 dry-run history", + "approvalReadOnlyDetail": "審批前已有只讀補救證據可回看", + "approvalNoEvidenceDetail": "審批前仍缺補救試跑證據,需進 Run Timeline 檢查" + } + }, "runDetail": { "back": "返回 Run 監控", "title": "Run 處置脈絡", diff --git a/apps/web/src/app/[locale]/awooop/approvals/page.tsx b/apps/web/src/app/[locale]/awooop/approvals/page.tsx index 5561f5e2..8839e952 100644 --- a/apps/web/src/app/[locale]/awooop/approvals/page.tsx +++ b/apps/web/src/app/[locale]/awooop/approvals/page.tsx @@ -6,6 +6,7 @@ "use client"; import { useState, useEffect, useCallback, useMemo, useRef } from "react"; +import { useTranslations } from "next-intl"; import { ShieldCheck, RefreshCw, @@ -13,6 +14,7 @@ import { Clock, ArrowRight, ListChecks, + SearchCheck, TriangleAlert, } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -22,12 +24,31 @@ import { Link } from "@/i18n/routing"; // Types // ============================================================================= +type RemediationStatus = + | "no_evidence" + | "read_only_dry_run" + | "write_observed" + | "blocked" + | "observed"; + +interface RemediationSummary { + incident_ids?: string[]; + total?: number; + status?: RemediationStatus | string; + human_gate_open?: boolean; + latest_route?: string | null; + latest_preview?: string | null; + writes_incident_state?: boolean | null; + writes_auto_repair_result?: boolean | null; +} + interface Approval { run_id: string; project_id: string; agent_id: string; created_at: string; timeout_at: string | null; + remediation_summary?: RemediationSummary | null; } // ============================================================================= @@ -63,6 +84,97 @@ function formatRemaining(ms: number): string { // Sub Components // ============================================================================= +const REMEDIATION_STATUS_CONFIG: Record< + RemediationStatus, + { + labelKey: string; + detailKey: string; + icon: typeof ShieldCheck; + className: string; + } +> = { + no_evidence: { + labelKey: "statuses.noEvidence", + detailKey: "details.noEvidence", + icon: AlertCircle, + className: "border-[#d8d3c7] bg-[#faf9f3] text-[#5f5b52]", + }, + read_only_dry_run: { + labelKey: "statuses.readOnlyDryRun", + detailKey: "details.readOnlyDryRun", + icon: SearchCheck, + className: "border-[#9bb6d9] bg-[#eef5ff] text-[#1f5b9b]", + }, + write_observed: { + labelKey: "statuses.writeObserved", + detailKey: "details.writeObserved", + icon: TriangleAlert, + className: "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]", + }, + blocked: { + labelKey: "statuses.blocked", + detailKey: "details.blocked", + icon: TriangleAlert, + className: "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]", + }, + observed: { + labelKey: "statuses.observed", + detailKey: "details.observed", + icon: ListChecks, + className: "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]", + }, +}; + +function normalizeRemediationStatus(summary?: RemediationSummary | null): RemediationStatus { + const statusValue = summary?.status; + if ( + statusValue === "read_only_dry_run" || + statusValue === "write_observed" || + statusValue === "blocked" || + statusValue === "observed" + ) { + return statusValue; + } + return "no_evidence"; +} + +function RemediationEvidenceCell({ summary }: { summary?: RemediationSummary | null }) { + const t = useTranslations("awooop.listEvidence"); + const status = normalizeRemediationStatus(summary); + const config = REMEDIATION_STATUS_CONFIG[status]; + const Icon = config.icon; + const total = summary?.total ?? 0; + const route = summary?.latest_route && summary.latest_route !== "--" + ? summary.latest_route + : null; + + return ( +
+ + + {total > 0 ? ( + + {t("count", { count: total })} + {route ? ` · ${t("route", { route })}` : ""} + + ) : ( + {t("emptyShort")} + )} + + {t("manualGate")} + +
+ ); +} + function TimeoutCell({ timeoutAt }: { timeoutAt: string | null }) { const [remaining, setRemaining] = useState( getRemainingMs(timeoutAt) @@ -151,6 +263,9 @@ function ApprovalRow({ approval }: { approval: Approval }) { + + + {formattedDate} @@ -168,10 +283,11 @@ function ApprovalRow({ approval }: { approval: Approval }) { // ============================================================================= export default function ApprovalsPage() { + const tEvidence = useTranslations("awooop.listEvidence"); const [approvals, setApprovals] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [lastRefresh, setLastRefresh] = useState(new Date()); + const [lastRefresh, setLastRefresh] = useState(null); const intervalRef = useRef | null>(null); const fetchApprovals = useCallback(async () => { @@ -211,6 +327,12 @@ export default function ApprovalsPage() { const ms = getRemainingMs(a.timeout_at); return ms !== null && ms <= 0; }).length; + const readOnlyEvidenceCount = approvals.filter( + (approval) => normalizeRemediationStatus(approval.remediation_summary) === "read_only_dry_run" + ).length; + const noEvidenceCount = approvals.filter( + (approval) => normalizeRemediationStatus(approval.remediation_summary) === "no_evidence" + ).length; const queueSummary = useMemo( () => [ { @@ -235,14 +357,21 @@ export default function ApprovalsPage() { className: "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]", }, { - label: "操作來源", - value: "Run", - detail: "審批必須回到 Operator Run", - icon: ListChecks, + label: tEvidence("summary.readOnly"), + value: readOnlyEvidenceCount, + detail: tEvidence("summary.approvalReadOnlyDetail"), + icon: SearchCheck, + className: "border-[#9bb6d9] bg-[#eef5ff] text-[#1f5b9b]", + }, + { + label: tEvidence("summary.noEvidence"), + value: noEvidenceCount, + detail: tEvidence("summary.approvalNoEvidenceDetail"), + icon: AlertCircle, className: "border-[#d8d3c7] bg-white text-[#5f5b52]", }, ], - [approvals.length, criticalCount, expiredCount] + [approvals.length, criticalCount, expiredCount, noEvidenceCount, readOnlyEvidenceCount, tEvidence] ); return ( @@ -263,7 +392,9 @@ export default function ApprovalsPage() {

{loading ? "載入中..." - : `${approvals.length} 筆待審 · 上次更新 ${lastRefresh.toLocaleTimeString("zh-TW")}`} + : `${approvals.length} 筆待審 · 上次更新 ${ + lastRefresh ? lastRefresh.toLocaleTimeString("zh-TW") : "--" + }`}

@@ -281,7 +412,7 @@ export default function ApprovalsPage() { -
+
{queueSummary.map((item) => { const Icon = item.icon; return ( @@ -347,6 +478,9 @@ export default function ApprovalsPage() { 處置 Lane + + {tEvidence("column")} + 建立時間 @@ -359,7 +493,7 @@ export default function ApprovalsPage() { {loading ? ( Array.from({ length: 5 }).map((_, i) => ( - {Array.from({ length: 6 }).map((_, j) => ( + {Array.from({ length: 7 }).map((_, j) => (
diff --git a/apps/web/src/app/[locale]/awooop/runs/page.tsx b/apps/web/src/app/[locale]/awooop/runs/page.tsx index efd492e8..4c1c39a0 100644 --- a/apps/web/src/app/[locale]/awooop/runs/page.tsx +++ b/apps/web/src/app/[locale]/awooop/runs/page.tsx @@ -6,6 +6,7 @@ "use client"; import { useState, useEffect, useCallback, useMemo, useRef } from "react"; +import { useTranslations } from "next-intl"; import { Link } from "@/i18n/routing"; import { Activity, @@ -39,6 +40,32 @@ type RunState = | "timeout"; type RunLane = "intake" | "diagnosis" | "approval" | "execution" | "done" | "manual"; +type RemediationStatus = + | "no_evidence" + | "read_only_dry_run" + | "write_observed" + | "blocked" + | "observed"; + +interface RemediationSummary { + schema_version?: string; + source?: string; + incident_ids?: string[]; + total?: number; + status?: RemediationStatus | string; + has_dry_run?: boolean; + is_read_only?: boolean; + human_gate_open?: boolean; + latest_at?: string | null; + latest_preview?: string | null; + latest_mode?: string | null; + latest_route?: string | null; + latest_agent_id?: string | null; + latest_tool_name?: string | null; + latest_required_scope?: string | null; + writes_incident_state?: boolean | null; + writes_auto_repair_result?: boolean | null; +} interface Run { run_id: string; @@ -49,6 +76,7 @@ interface Run { cost_usd: number | string; step_count: number; created_at: string; + remediation_summary?: RemediationSummary | null; } interface Tenant { @@ -191,6 +219,47 @@ const LANE_CONFIG: Record< }, }; +const REMEDIATION_STATUS_CONFIG: Record< + RemediationStatus, + { + labelKey: string; + detailKey: string; + icon: typeof Activity; + className: string; + } +> = { + no_evidence: { + labelKey: "statuses.noEvidence", + detailKey: "details.noEvidence", + icon: AlertCircle, + className: "border-[#d8d3c7] bg-[#faf9f3] text-[#5f5b52]", + }, + read_only_dry_run: { + labelKey: "statuses.readOnlyDryRun", + detailKey: "details.readOnlyDryRun", + icon: SearchCheck, + className: "border-[#9bb6d9] bg-[#eef5ff] text-[#1f5b9b]", + }, + write_observed: { + labelKey: "statuses.writeObserved", + detailKey: "details.writeObserved", + icon: TriangleAlert, + className: "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]", + }, + blocked: { + labelKey: "statuses.blocked", + detailKey: "details.blocked", + icon: TriangleAlert, + className: "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]", + }, + observed: { + labelKey: "statuses.observed", + detailKey: "details.observed", + icon: ListChecks, + className: "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]", + }, +}; + function getRunLane(state: RunState): RunLane { if (state === "pending") return "intake"; if (state === "waiting_tool") return "diagnosis"; @@ -250,6 +319,58 @@ function RunLaneBadge({ state }: { state: RunState }) { ); } +function normalizeRemediationStatus(summary?: RemediationSummary | null): RemediationStatus { + const statusValue = summary?.status; + if ( + statusValue === "read_only_dry_run" || + statusValue === "write_observed" || + statusValue === "blocked" || + statusValue === "observed" + ) { + return statusValue; + } + return "no_evidence"; +} + +function RemediationEvidenceCell({ summary }: { summary?: RemediationSummary | null }) { + const t = useTranslations("awooop.listEvidence"); + const status = normalizeRemediationStatus(summary); + const config = REMEDIATION_STATUS_CONFIG[status]; + const Icon = config.icon; + const total = summary?.total ?? 0; + const route = summary?.latest_route && summary.latest_route !== "--" + ? summary.latest_route + : null; + + return ( +
+ + + {total > 0 ? ( + + {t("count", { count: total })} + {route ? ` · ${t("route", { route })}` : ""} + + ) : ( + {t("emptyShort")} + )} + {summary?.human_gate_open && ( + + {t("manualGate")} + + )} +
+ ); +} + function RunRow({ run }: { run: Run }) { const formattedDate = run.created_at ? new Date(run.created_at).toLocaleDateString("zh-TW", { @@ -288,6 +409,9 @@ function RunRow({ run }: { run: Run }) { + + + @@ -377,6 +501,7 @@ function GroupedAlertEventsPanel({ events }: { events: PlatformEvent[] }) { // ============================================================================= export default function RunsPage() { + const tEvidence = useTranslations("awooop.listEvidence"); const [runs, setRuns] = useState([]); const [groupedEvents, setGroupedEvents] = useState([]); const [tenants, setTenants] = useState([]); @@ -386,7 +511,7 @@ export default function RunsPage() { const [projectFilter, setProjectFilter] = useState(""); const [statusFilter, setStatusFilter] = useState(""); const [page, setPage] = useState(1); - const [lastRefresh, setLastRefresh] = useState(new Date()); + const [lastRefresh, setLastRefresh] = useState(null); const intervalRef = useRef | null>(null); // 取得租戶清單 @@ -471,6 +596,20 @@ export default function RunsPage() { }); return counts; }, [runs]); + const evidenceSummary = useMemo(() => { + return { + readOnly: runs.filter( + (run) => normalizeRemediationStatus(run.remediation_summary) === "read_only_dry_run" + ).length, + writeObserved: runs.filter( + (run) => normalizeRemediationStatus(run.remediation_summary) === "write_observed" + ).length, + noEvidence: runs.filter( + (run) => normalizeRemediationStatus(run.remediation_summary) === "no_evidence" + ).length, + manualGate: runs.filter((run) => run.remediation_summary?.human_gate_open).length, + }; + }, [runs]); return (
@@ -483,7 +622,9 @@ export default function RunsPage() {

{loading ? "載入中..." - : `共 ${total} 筆 · 上次更新 ${lastRefresh.toLocaleTimeString("zh-TW")}`} + : `共 ${total} 筆 · 上次更新 ${ + lastRefresh ? lastRefresh.toLocaleTimeString("zh-TW") : "--" + }`}

@@ -522,6 +663,62 @@ export default function RunsPage() { })}
+
+ {[ + { + label: tEvidence("summary.readOnly"), + value: evidenceSummary.readOnly, + detail: tEvidence("summary.readOnlyDetail"), + icon: SearchCheck, + className: "border-[#9bb6d9] bg-[#eef5ff] text-[#1f5b9b]", + }, + { + label: tEvidence("summary.manualGate"), + value: evidenceSummary.manualGate, + detail: tEvidence("summary.manualGateDetail"), + icon: ShieldCheck, + className: "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]", + }, + { + label: tEvidence("summary.writeObserved"), + value: evidenceSummary.writeObserved, + detail: tEvidence("summary.writeObservedDetail"), + icon: TriangleAlert, + className: "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]", + }, + { + label: tEvidence("summary.noEvidence"), + value: evidenceSummary.noEvidence, + detail: tEvidence("summary.noEvidenceDetail"), + icon: AlertCircle, + className: "border-[#d8d3c7] bg-white text-[#5f5b52]", + }, + ].map((item) => { + const Icon = item.icon; + return ( +
+
+
+

{item.label}

+
+ {item.value} +
+
+ + +
+

{item.detail}

+
+ ); + })} +
+ {/* Filters */} @@ -598,6 +795,9 @@ export default function RunsPage() { 處置 Lane + + {tEvidence("column")} + Shadow @@ -613,7 +813,7 @@ export default function RunsPage() { {loading ? ( Array.from({ length: 8 }).map((_, i) => ( - {Array.from({ length: 8 }).map((_, j) => ( + {Array.from({ length: 9 }).map((_, j) => (