feat(awooop): surface remediation evidence in run lists
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 處置脈絡",
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex min-w-[230px] flex-col gap-1">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex w-fit items-center gap-1.5 border px-2 py-0.5 text-xs font-semibold",
|
||||
config.className
|
||||
)}
|
||||
title={t(config.detailKey)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t(config.labelKey)}
|
||||
</span>
|
||||
{total > 0 ? (
|
||||
<span className="text-xs leading-5 text-[#5f5b52]">
|
||||
{t("count", { count: total })}
|
||||
{route ? ` · ${t("route", { route })}` : ""}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs leading-5 text-[#77736a]">{t("emptyShort")}</span>
|
||||
)}
|
||||
<span className="text-xs font-semibold text-[#8a5a08]">
|
||||
{t("manualGate")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimeoutCell({ timeoutAt }: { timeoutAt: string | null }) {
|
||||
const [remaining, setRemaining] = useState<number | null>(
|
||||
getRemainingMs(timeoutAt)
|
||||
@@ -151,6 +263,9 @@ function ApprovalRow({ approval }: { approval: Approval }) {
|
||||
<td className="px-4 py-3">
|
||||
<DecisionPostureBadge />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<RemediationEvidenceCell summary={approval.remediation_summary} />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-sm text-muted-foreground font-mono">
|
||||
{formattedDate}
|
||||
@@ -168,10 +283,11 @@ function ApprovalRow({ approval }: { approval: Approval }) {
|
||||
// =============================================================================
|
||||
|
||||
export default function ApprovalsPage() {
|
||||
const tEvidence = useTranslations("awooop.listEvidence");
|
||||
const [approvals, setApprovals] = useState<Approval[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastRefresh, setLastRefresh] = useState<Date>(new Date());
|
||||
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | 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() {
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{loading
|
||||
? "載入中..."
|
||||
: `${approvals.length} 筆待審 · 上次更新 ${lastRefresh.toLocaleTimeString("zh-TW")}`}
|
||||
: `${approvals.length} 筆待審 · 上次更新 ${
|
||||
lastRefresh ? lastRefresh.toLocaleTimeString("zh-TW") : "--"
|
||||
}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -281,7 +412,7 @@ export default function ApprovalsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="grid gap-px border border-[#e0ddd4] bg-[#e0ddd4] md:grid-cols-2 xl:grid-cols-4">
|
||||
<section className="grid gap-px border border-[#e0ddd4] bg-[#e0ddd4] md:grid-cols-2 xl:grid-cols-5">
|
||||
{queueSummary.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
@@ -347,6 +478,9 @@ export default function ApprovalsPage() {
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
處置 Lane
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{tEvidence("column")}
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
建立時間
|
||||
</th>
|
||||
@@ -359,7 +493,7 @@ export default function ApprovalsPage() {
|
||||
{loading ? (
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<tr key={i} className="border-b border-border">
|
||||
{Array.from({ length: 6 }).map((_, j) => (
|
||||
{Array.from({ length: 7 }).map((_, j) => (
|
||||
<td key={j} className="px-4 py-3">
|
||||
<div className="h-5 bg-muted animate-pulse rounded w-20" />
|
||||
</td>
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex min-w-[230px] flex-col gap-1">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex w-fit items-center gap-1.5 border px-2 py-0.5 text-xs font-semibold",
|
||||
config.className
|
||||
)}
|
||||
title={t(config.detailKey)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t(config.labelKey)}
|
||||
</span>
|
||||
{total > 0 ? (
|
||||
<span className="text-xs leading-5 text-[#5f5b52]">
|
||||
{t("count", { count: total })}
|
||||
{route ? ` · ${t("route", { route })}` : ""}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs leading-5 text-[#77736a]">{t("emptyShort")}</span>
|
||||
)}
|
||||
{summary?.human_gate_open && (
|
||||
<span className="text-xs font-semibold text-[#8a5a08]">
|
||||
{t("manualGate")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 }) {
|
||||
<td className="px-4 py-3">
|
||||
<RunLaneBadge state={run.state} />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<RemediationEvidenceCell summary={run.remediation_summary} />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<ShadowBadge isShadow={run.is_shadow} />
|
||||
</td>
|
||||
@@ -377,6 +501,7 @@ function GroupedAlertEventsPanel({ events }: { events: PlatformEvent[] }) {
|
||||
// =============================================================================
|
||||
|
||||
export default function RunsPage() {
|
||||
const tEvidence = useTranslations("awooop.listEvidence");
|
||||
const [runs, setRuns] = useState<Run[]>([]);
|
||||
const [groupedEvents, setGroupedEvents] = useState<PlatformEvent[]>([]);
|
||||
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||
@@ -386,7 +511,7 @@ export default function RunsPage() {
|
||||
const [projectFilter, setProjectFilter] = useState<string>("");
|
||||
const [statusFilter, setStatusFilter] = useState<string>("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [lastRefresh, setLastRefresh] = useState<Date>(new Date());
|
||||
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | 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 (
|
||||
<div className="space-y-6">
|
||||
@@ -483,7 +622,9 @@ export default function RunsPage() {
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{loading
|
||||
? "載入中..."
|
||||
: `共 ${total} 筆 · 上次更新 ${lastRefresh.toLocaleTimeString("zh-TW")}`}
|
||||
: `共 ${total} 筆 · 上次更新 ${
|
||||
lastRefresh ? lastRefresh.toLocaleTimeString("zh-TW") : "--"
|
||||
}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -522,6 +663,62 @@ export default function RunsPage() {
|
||||
})}
|
||||
</section>
|
||||
|
||||
<section className="grid gap-px border border-[#e0ddd4] bg-[#e0ddd4] md:grid-cols-2 xl:grid-cols-4">
|
||||
{[
|
||||
{
|
||||
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 (
|
||||
<div key={item.label} className="bg-white px-4 py-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-[#77736a]">{item.label}</p>
|
||||
<div className="mt-2 font-mono text-2xl font-semibold text-[#141413]">
|
||||
{item.value}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-8 w-8 items-center justify-center border",
|
||||
item.className
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-xs leading-5 text-[#5f5b52]">{item.detail}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
|
||||
<GroupedAlertEventsPanel events={groupedEvents} />
|
||||
|
||||
{/* Filters */}
|
||||
@@ -598,6 +795,9 @@ export default function RunsPage() {
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
處置 Lane
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{tEvidence("column")}
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Shadow
|
||||
</th>
|
||||
@@ -613,7 +813,7 @@ export default function RunsPage() {
|
||||
{loading ? (
|
||||
Array.from({ length: 8 }).map((_, i) => (
|
||||
<tr key={i} className="border-b border-border">
|
||||
{Array.from({ length: 8 }).map((_, j) => (
|
||||
{Array.from({ length: 9 }).map((_, j) => (
|
||||
<td key={j} className="px-4 py-3">
|
||||
<div className="h-5 bg-muted animate-pulse rounded w-20" />
|
||||
</td>
|
||||
|
||||
Reference in New Issue
Block a user