feat(awooop): surface mcp investigation evidence
This commit is contained in:
@@ -107,7 +107,7 @@ async def list_runs(
|
||||
state: str | None = Query(None, description="Run 狀態 filter(可選)"),
|
||||
remediation_status: str | None = Query(
|
||||
None,
|
||||
description="AI 補救證據狀態 filter(no_evidence/read_only_dry_run/write_observed/blocked/observed)",
|
||||
description="AI 證據狀態 filter(no_evidence/mcp_observed/read_only_dry_run/write_observed/blocked/observed)",
|
||||
),
|
||||
incident_id: str | None = Query(None, description="關聯 Incident ID filter(可選)"),
|
||||
page: int = Query(1, ge=1, description="頁碼,從 1 開始"),
|
||||
@@ -152,7 +152,7 @@ async def list_approvals(
|
||||
run_id: str | None = Query(None, description="Run ID(可選,M8 詳情頁查單筆)"),
|
||||
remediation_status: str | None = Query(
|
||||
None,
|
||||
description="AI 補救證據狀態 filter(no_evidence/read_only_dry_run/write_observed/blocked/observed)",
|
||||
description="AI 證據狀態 filter(no_evidence/mcp_observed/read_only_dry_run/write_observed/blocked/observed)",
|
||||
),
|
||||
) -> dict[str, Any]:
|
||||
return await list_approvals_svc(
|
||||
|
||||
@@ -50,6 +50,7 @@ _MAX_STEP_SUMMARY_CHARS = 128
|
||||
_REMEDIATION_HISTORY_LIMIT = 20
|
||||
_INCIDENT_ID_RE = re.compile(r"\bINC-\d{8}-[A-Z0-9]{4,}\b")
|
||||
_REMEDIATION_STATUS_FILTERS = {
|
||||
"mcp_observed",
|
||||
"no_evidence",
|
||||
"read_only_dry_run",
|
||||
"write_observed",
|
||||
@@ -443,6 +444,22 @@ def _route_label_from_remediation(item: dict[str, Any]) -> str:
|
||||
) or "--"
|
||||
|
||||
|
||||
def _route_label_from_legacy_mcp(record: dict[str, Any]) -> str:
|
||||
"""Render self-built/legacy MCP evidence as agent/tool/scope for list UX."""
|
||||
tool = record.get("tool_name")
|
||||
server = record.get("mcp_server")
|
||||
tool_label = ".".join(str(part) for part in (server, tool) if part) or tool
|
||||
return "/".join(
|
||||
str(part)
|
||||
for part in (
|
||||
record.get("agent_role"),
|
||||
tool_label,
|
||||
"read",
|
||||
)
|
||||
if part
|
||||
) or "--"
|
||||
|
||||
|
||||
def _remediation_timeline_status(item: dict[str, Any]) -> str:
|
||||
if item.get("success") is False or item.get("allowed") is False:
|
||||
return "failed"
|
||||
@@ -485,18 +502,31 @@ def _run_remediation_list_summary(
|
||||
run: AwoooPRunState,
|
||||
incident_ids: list[str],
|
||||
items: list[dict[str, Any]],
|
||||
legacy_mcp_records: list[dict[str, Any]] | None = None,
|
||||
errors: list[dict[str, str]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Summarize durable ADR-100 dry-run evidence for list-level UX."""
|
||||
"""Summarize durable ADR-100 dry-run and MCP investigation evidence for list UX."""
|
||||
sorted_items = sorted(
|
||||
(item for item in items if isinstance(item, dict)),
|
||||
key=lambda item: str(item.get("created_at") or ""),
|
||||
reverse=True,
|
||||
)
|
||||
sorted_mcp_records = sorted(
|
||||
(record for record in (legacy_mcp_records or []) if isinstance(record, dict)),
|
||||
key=lambda record: str(record.get("created_at") or ""),
|
||||
reverse=True,
|
||||
)
|
||||
latest = sorted_items[0] if sorted_items else {}
|
||||
latest_mcp = sorted_mcp_records[0] if sorted_mcp_records 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 "--"
|
||||
route = (
|
||||
_route_label_from_remediation(latest)
|
||||
if latest
|
||||
else _route_label_from_legacy_mcp(latest_mcp)
|
||||
if latest_mcp
|
||||
else "--"
|
||||
)
|
||||
write_observed = writes_incident is True or writes_auto_repair is True
|
||||
is_read_only = (
|
||||
bool(latest)
|
||||
@@ -504,9 +534,12 @@ def _run_remediation_list_summary(
|
||||
and writes_incident is False
|
||||
and writes_auto_repair is False
|
||||
)
|
||||
mcp_total = len(sorted_mcp_records)
|
||||
mcp_success = sum(1 for record in sorted_mcp_records if record.get("success") is True)
|
||||
mcp_failed = sum(1 for record in sorted_mcp_records if record.get("success") is False)
|
||||
|
||||
if not sorted_items:
|
||||
status_value = "no_evidence"
|
||||
status_value = "mcp_observed" if mcp_total > 0 else "no_evidence"
|
||||
elif latest.get("success") is False or latest.get("allowed") is False:
|
||||
status_value = "blocked"
|
||||
elif write_observed:
|
||||
@@ -518,22 +551,28 @@ def _run_remediation_list_summary(
|
||||
|
||||
return {
|
||||
"schema_version": "awooop_run_remediation_summary_v1",
|
||||
"source": "alert_operation_log",
|
||||
"source": "alert_operation_log" if sorted_items else "mcp_audit_log" if mcp_total > 0 else "none",
|
||||
"incident_ids": incident_ids,
|
||||
"total": len(sorted_items),
|
||||
"evidence_total": len(sorted_items) + mcp_total,
|
||||
"status": status_value,
|
||||
"has_dry_run": bool(sorted_items),
|
||||
"has_mcp_investigation": mcp_total > 0,
|
||||
"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"),
|
||||
"latest_agent_id": latest.get("agent_id") or latest_mcp.get("agent_role"),
|
||||
"latest_tool_name": latest.get("tool_name") or latest_mcp.get("tool_name"),
|
||||
"latest_required_scope": latest.get("required_scope") or ("read" if latest_mcp else None),
|
||||
"writes_incident_state": writes_incident,
|
||||
"writes_auto_repair_result": writes_auto_repair,
|
||||
"mcp_observation_total": mcp_total,
|
||||
"mcp_observation_success": mcp_success,
|
||||
"mcp_observation_failed": mcp_failed,
|
||||
"latest_mcp_server": latest_mcp.get("mcp_server"),
|
||||
"errors": errors or [],
|
||||
}
|
||||
|
||||
@@ -602,6 +641,7 @@ async def _build_run_remediation_summaries(
|
||||
_append_unique(all_incident_ids, incident_id)
|
||||
|
||||
histories_by_incident: dict[str, list[dict[str, Any]]] = {}
|
||||
legacy_mcp_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
|
||||
@@ -628,20 +668,27 @@ async def _build_run_remediation_summaries(
|
||||
"incident_id": incident_id,
|
||||
"error": str(exc),
|
||||
}
|
||||
legacy_mcp_by_incident = await _fetch_legacy_mcp_by_incident_ids(
|
||||
all_incident_ids,
|
||||
limit=min(max(len(all_incident_ids) * _REMEDIATION_HISTORY_LIMIT, 100), 5_000),
|
||||
)
|
||||
|
||||
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]] = []
|
||||
legacy_mcp_records: list[dict[str, Any]] = []
|
||||
errors: list[dict[str, str]] = []
|
||||
for incident_id in incident_ids:
|
||||
items.extend(histories_by_incident.get(incident_id, []))
|
||||
legacy_mcp_records.extend(legacy_mcp_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,
|
||||
legacy_mcp_records=legacy_mcp_records,
|
||||
errors=errors,
|
||||
)
|
||||
return summaries
|
||||
@@ -744,6 +791,31 @@ def _legacy_mcp_record(row: MCPAuditLog) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
async def _fetch_legacy_mcp_by_incident_ids(
|
||||
incident_ids: list[str],
|
||||
*,
|
||||
limit: int,
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
"""Fetch legacy/self-built MCP rows for list evidence summaries."""
|
||||
if not incident_ids:
|
||||
return {}
|
||||
|
||||
async with get_db_context("awoooi") as db:
|
||||
result = await db.execute(
|
||||
select(MCPAuditLog)
|
||||
.where(MCPAuditLog.incident_id.in_(incident_ids))
|
||||
.order_by(MCPAuditLog.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
rows = list(result.scalars().all())
|
||||
|
||||
by_incident: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||
for row in rows:
|
||||
if row.incident_id:
|
||||
by_incident[row.incident_id].append(_legacy_mcp_record(row))
|
||||
return dict(by_incident)
|
||||
|
||||
|
||||
async def _fetch_run_legacy_mcp_history(
|
||||
incident_ids: list[str],
|
||||
*,
|
||||
|
||||
@@ -163,6 +163,45 @@ def test_run_remediation_list_summary_marks_read_only_dry_run() -> None:
|
||||
assert summary["latest_route"] == "auto_repair_executor/ssh_diagnose/read"
|
||||
|
||||
|
||||
def test_run_remediation_list_summary_marks_mcp_observed_without_dry_run() -> None:
|
||||
run = SimpleNamespace(state="completed")
|
||||
|
||||
summary = _run_remediation_list_summary(
|
||||
run=run,
|
||||
incident_ids=["INC-20260518-792684"],
|
||||
items=[],
|
||||
legacy_mcp_records=[
|
||||
{
|
||||
"created_at": "2026-05-18T04:31:30+00:00",
|
||||
"incident_id": "INC-20260518-792684",
|
||||
"agent_role": "pre_decision_investigator",
|
||||
"mcp_server": "ssh_host",
|
||||
"tool_name": "ssh_diagnose",
|
||||
"success": True,
|
||||
},
|
||||
{
|
||||
"created_at": "2026-05-18T04:31:29+00:00",
|
||||
"incident_id": "INC-20260518-792684",
|
||||
"agent_role": "pre_decision_investigator",
|
||||
"mcp_server": "signoz",
|
||||
"tool_name": "query_logs",
|
||||
"success": False,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
assert summary["status"] == "mcp_observed"
|
||||
assert summary["source"] == "mcp_audit_log"
|
||||
assert summary["total"] == 0
|
||||
assert summary["evidence_total"] == 2
|
||||
assert summary["has_dry_run"] is False
|
||||
assert summary["has_mcp_investigation"] is True
|
||||
assert summary["mcp_observation_total"] == 2
|
||||
assert summary["mcp_observation_success"] == 1
|
||||
assert summary["mcp_observation_failed"] == 1
|
||||
assert summary["latest_route"] == "pre_decision_investigator/ssh_host.ssh_diagnose/read"
|
||||
|
||||
|
||||
def test_run_remediation_list_summary_flags_write_observed() -> None:
|
||||
run = SimpleNamespace(state="completed")
|
||||
|
||||
@@ -188,6 +227,10 @@ def test_run_remediation_list_summary_flags_write_observed() -> None:
|
||||
|
||||
|
||||
def test_remediation_summary_matches_status_filter() -> None:
|
||||
assert _remediation_summary_matches_status(
|
||||
{"status": "mcp_observed"},
|
||||
"mcp_observed",
|
||||
)
|
||||
assert _remediation_summary_matches_status(
|
||||
{"status": "read_only_dry_run"},
|
||||
"read_only_dry_run",
|
||||
|
||||
@@ -1821,8 +1821,9 @@
|
||||
"listEvidence": {
|
||||
"column": "AI Evidence",
|
||||
"count": "{count} dry-runs",
|
||||
"mcpCount": "{count} MCP investigations",
|
||||
"route": "MCP: {route}",
|
||||
"emptyShort": "No remediation dry-run linked",
|
||||
"emptyShort": "No AI evidence linked",
|
||||
"manualGate": "Next: human approval",
|
||||
"filters": {
|
||||
"label": "AI evidence filter",
|
||||
@@ -1838,29 +1839,33 @@
|
||||
},
|
||||
"statuses": {
|
||||
"noEvidence": "No dry-run yet",
|
||||
"mcpObserved": "MCP investigated",
|
||||
"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.",
|
||||
"noEvidence": "This row is not linked to ADR-100 remediation dry-run or MCP investigation evidence yet.",
|
||||
"mcpObserved": "AI has gathered evidence through MCP / self-built MCP, but no remediation dry-run or execution has started.",
|
||||
"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": {
|
||||
"mcpObserved": "MCP investigated",
|
||||
"mcpObservedDetail": "List rows are linked to MCP / self-built MCP investigation evidence",
|
||||
"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",
|
||||
"noEvidence": "Missing AI evidence",
|
||||
"noEvidenceDetail": "The list row is not linked to ADR-100 dry-run or MCP evidence yet",
|
||||
"approvalReadOnlyDetail": "Read-only remediation evidence is visible before approval",
|
||||
"approvalNoEvidenceDetail": "Approval still lacks remediation dry-run evidence; inspect Run Timeline"
|
||||
"approvalNoEvidenceDetail": "Approval still lacks AI evidence; inspect Run Timeline"
|
||||
}
|
||||
},
|
||||
"incidentEvidence": {
|
||||
|
||||
@@ -1822,8 +1822,9 @@
|
||||
"listEvidence": {
|
||||
"column": "AI 證據",
|
||||
"count": "試跑 {count} 次",
|
||||
"mcpCount": "MCP 調查 {count} 次",
|
||||
"route": "MCP:{route}",
|
||||
"emptyShort": "尚未連到補救試跑",
|
||||
"emptyShort": "尚未連到 AI 證據",
|
||||
"manualGate": "下一步:人工審批",
|
||||
"filters": {
|
||||
"label": "AI 證據篩選",
|
||||
@@ -1839,29 +1840,33 @@
|
||||
},
|
||||
"statuses": {
|
||||
"noEvidence": "尚無試跑",
|
||||
"mcpObserved": "MCP 已調查",
|
||||
"readOnlyDryRun": "AI 已試跑:只讀",
|
||||
"writeObserved": "有寫入旗標",
|
||||
"blocked": "試跑受阻",
|
||||
"observed": "有補救證據"
|
||||
},
|
||||
"details": {
|
||||
"noEvidence": "此列尚未從 alert_operation_log 連到 ADR-100 補救試跑。",
|
||||
"noEvidence": "此列尚未連到 ADR-100 補救試跑或 MCP 調查證據。",
|
||||
"mcpObserved": "AI 已透過 MCP / 自建 MCP 收集證據,但尚未進入補救試跑或執行。",
|
||||
"readOnlyDryRun": "AI 已走補救試跑,且最新紀錄沒有寫入 incident 或 auto-repair 狀態。",
|
||||
"writeObserved": "最新補救紀錄含寫入旗標,審批前需確認狀態變更來源。",
|
||||
"blocked": "補救試跑未通過或被 gate 阻擋,需人工確認卡點。",
|
||||
"observed": "此列已連到補救歷史,請進入 Run Timeline 查看完整證據。"
|
||||
},
|
||||
"summary": {
|
||||
"mcpObserved": "MCP 已調查",
|
||||
"mcpObservedDetail": "列表已連到 MCP / 自建 MCP 調查證據",
|
||||
"readOnly": "只讀試跑",
|
||||
"readOnlyDetail": "最新證據顯示 AI 已試跑且未寫狀態",
|
||||
"manualGate": "人工閘門",
|
||||
"manualGateDetail": "AI 已停在 approval gate,需 approve / reject",
|
||||
"writeObserved": "寫入旗標",
|
||||
"writeObservedDetail": "需確認是否為預期自動修復結果",
|
||||
"noEvidence": "缺補救證據",
|
||||
"noEvidenceDetail": "列表尚未連到 ADR-100 dry-run history",
|
||||
"noEvidence": "缺 AI 證據",
|
||||
"noEvidenceDetail": "列表尚未連到 ADR-100 dry-run 或 MCP evidence",
|
||||
"approvalReadOnlyDetail": "審批前已有只讀補救證據可回看",
|
||||
"approvalNoEvidenceDetail": "審批前仍缺補救試跑證據,需進 Run Timeline 檢查"
|
||||
"approvalNoEvidenceDetail": "審批前仍缺 AI 證據,需進 Run Timeline 檢查"
|
||||
}
|
||||
},
|
||||
"incidentEvidence": {
|
||||
|
||||
@@ -28,6 +28,7 @@ import { Link } from "@/i18n/routing";
|
||||
|
||||
type RemediationStatus =
|
||||
| "no_evidence"
|
||||
| "mcp_observed"
|
||||
| "read_only_dry_run"
|
||||
| "write_observed"
|
||||
| "blocked"
|
||||
@@ -36,12 +37,17 @@ type RemediationStatus =
|
||||
interface RemediationSummary {
|
||||
incident_ids?: string[];
|
||||
total?: number;
|
||||
evidence_total?: number;
|
||||
status?: RemediationStatus | string;
|
||||
has_mcp_investigation?: boolean;
|
||||
human_gate_open?: boolean;
|
||||
latest_route?: string | null;
|
||||
latest_preview?: string | null;
|
||||
writes_incident_state?: boolean | null;
|
||||
writes_auto_repair_result?: boolean | null;
|
||||
mcp_observation_total?: number;
|
||||
mcp_observation_success?: number;
|
||||
mcp_observation_failed?: number;
|
||||
}
|
||||
|
||||
interface Approval {
|
||||
@@ -101,6 +107,12 @@ const REMEDIATION_STATUS_CONFIG: Record<
|
||||
icon: AlertCircle,
|
||||
className: "border-[#d8d3c7] bg-[#faf9f3] text-[#5f5b52]",
|
||||
},
|
||||
mcp_observed: {
|
||||
labelKey: "statuses.mcpObserved",
|
||||
detailKey: "details.mcpObserved",
|
||||
icon: SearchCheck,
|
||||
className: "border-[#9bb6d9] bg-[#eef5ff] text-[#1f5b9b]",
|
||||
},
|
||||
read_only_dry_run: {
|
||||
labelKey: "statuses.readOnlyDryRun",
|
||||
detailKey: "details.readOnlyDryRun",
|
||||
@@ -127,6 +139,7 @@ const REMEDIATION_STATUS_CONFIG: Record<
|
||||
},
|
||||
};
|
||||
const REMEDIATION_FILTER_OPTIONS: RemediationStatus[] = [
|
||||
"mcp_observed",
|
||||
"read_only_dry_run",
|
||||
"write_observed",
|
||||
"blocked",
|
||||
@@ -137,6 +150,7 @@ const REMEDIATION_FILTER_OPTIONS: RemediationStatus[] = [
|
||||
function normalizeRemediationStatus(summary?: RemediationSummary | null): RemediationStatus {
|
||||
const statusValue = summary?.status;
|
||||
if (
|
||||
statusValue === "mcp_observed" ||
|
||||
statusValue === "read_only_dry_run" ||
|
||||
statusValue === "write_observed" ||
|
||||
statusValue === "blocked" ||
|
||||
@@ -153,9 +167,14 @@ function RemediationEvidenceCell({ summary }: { summary?: RemediationSummary | n
|
||||
const config = REMEDIATION_STATUS_CONFIG[status];
|
||||
const Icon = config.icon;
|
||||
const total = summary?.total ?? 0;
|
||||
const mcpTotal = summary?.mcp_observation_total ?? 0;
|
||||
const evidenceTotal = summary?.evidence_total ?? total + mcpTotal;
|
||||
const route = summary?.latest_route && summary.latest_route !== "--"
|
||||
? summary.latest_route
|
||||
: null;
|
||||
const countText = status === "mcp_observed"
|
||||
? t("mcpCount", { count: mcpTotal || evidenceTotal })
|
||||
: t("count", { count: total });
|
||||
|
||||
return (
|
||||
<div className="flex min-w-[230px] flex-col gap-1">
|
||||
@@ -169,9 +188,9 @@ function RemediationEvidenceCell({ summary }: { summary?: RemediationSummary | n
|
||||
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t(config.labelKey)}
|
||||
</span>
|
||||
{total > 0 ? (
|
||||
{total > 0 || mcpTotal > 0 ? (
|
||||
<span className="text-xs leading-5 text-[#5f5b52]">
|
||||
{t("count", { count: total })}
|
||||
{countText}
|
||||
{route ? ` · ${t("route", { route })}` : ""}
|
||||
</span>
|
||||
) : (
|
||||
@@ -345,6 +364,9 @@ export default function ApprovalsPage() {
|
||||
const readOnlyEvidenceCount = approvals.filter(
|
||||
(approval) => normalizeRemediationStatus(approval.remediation_summary) === "read_only_dry_run"
|
||||
).length;
|
||||
const mcpObservedCount = approvals.filter(
|
||||
(approval) => normalizeRemediationStatus(approval.remediation_summary) === "mcp_observed"
|
||||
).length;
|
||||
const noEvidenceCount = approvals.filter(
|
||||
(approval) => normalizeRemediationStatus(approval.remediation_summary) === "no_evidence"
|
||||
).length;
|
||||
@@ -371,6 +393,13 @@ export default function ApprovalsPage() {
|
||||
icon: TriangleAlert,
|
||||
className: "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]",
|
||||
},
|
||||
{
|
||||
label: tEvidence("summary.mcpObserved"),
|
||||
value: mcpObservedCount,
|
||||
detail: tEvidence("summary.mcpObservedDetail"),
|
||||
icon: SearchCheck,
|
||||
className: "border-[#9bb6d9] bg-[#eef5ff] text-[#1f5b9b]",
|
||||
},
|
||||
{
|
||||
label: tEvidence("summary.readOnly"),
|
||||
value: readOnlyEvidenceCount,
|
||||
@@ -386,7 +415,15 @@ export default function ApprovalsPage() {
|
||||
className: "border-[#d8d3c7] bg-white text-[#5f5b52]",
|
||||
},
|
||||
],
|
||||
[approvals.length, criticalCount, expiredCount, noEvidenceCount, readOnlyEvidenceCount, tEvidence]
|
||||
[
|
||||
approvals.length,
|
||||
criticalCount,
|
||||
expiredCount,
|
||||
mcpObservedCount,
|
||||
noEvidenceCount,
|
||||
readOnlyEvidenceCount,
|
||||
tEvidence,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -427,7 +464,7 @@ export default function ApprovalsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="grid gap-px border border-[#e0ddd4] bg-[#e0ddd4] md:grid-cols-2 xl:grid-cols-5">
|
||||
<section className="grid gap-px border border-[#e0ddd4] bg-[#e0ddd4] md:grid-cols-2 xl:grid-cols-6">
|
||||
{queueSummary.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
|
||||
@@ -42,6 +42,7 @@ type RunState =
|
||||
type RunLane = "intake" | "diagnosis" | "approval" | "execution" | "done" | "manual";
|
||||
type RemediationStatus =
|
||||
| "no_evidence"
|
||||
| "mcp_observed"
|
||||
| "read_only_dry_run"
|
||||
| "write_observed"
|
||||
| "blocked"
|
||||
@@ -52,8 +53,10 @@ interface RemediationSummary {
|
||||
source?: string;
|
||||
incident_ids?: string[];
|
||||
total?: number;
|
||||
evidence_total?: number;
|
||||
status?: RemediationStatus | string;
|
||||
has_dry_run?: boolean;
|
||||
has_mcp_investigation?: boolean;
|
||||
is_read_only?: boolean;
|
||||
human_gate_open?: boolean;
|
||||
latest_at?: string | null;
|
||||
@@ -65,6 +68,10 @@ interface RemediationSummary {
|
||||
latest_required_scope?: string | null;
|
||||
writes_incident_state?: boolean | null;
|
||||
writes_auto_repair_result?: boolean | null;
|
||||
mcp_observation_total?: number;
|
||||
mcp_observation_success?: number;
|
||||
mcp_observation_failed?: number;
|
||||
latest_mcp_server?: string | null;
|
||||
}
|
||||
|
||||
interface Run {
|
||||
@@ -235,6 +242,12 @@ const REMEDIATION_STATUS_CONFIG: Record<
|
||||
icon: AlertCircle,
|
||||
className: "border-[#d8d3c7] bg-[#faf9f3] text-[#5f5b52]",
|
||||
},
|
||||
mcp_observed: {
|
||||
labelKey: "statuses.mcpObserved",
|
||||
detailKey: "details.mcpObserved",
|
||||
icon: SearchCheck,
|
||||
className: "border-[#9bb6d9] bg-[#eef5ff] text-[#1f5b9b]",
|
||||
},
|
||||
read_only_dry_run: {
|
||||
labelKey: "statuses.readOnlyDryRun",
|
||||
detailKey: "details.readOnlyDryRun",
|
||||
@@ -261,6 +274,7 @@ const REMEDIATION_STATUS_CONFIG: Record<
|
||||
},
|
||||
};
|
||||
const REMEDIATION_FILTER_OPTIONS: RemediationStatus[] = [
|
||||
"mcp_observed",
|
||||
"read_only_dry_run",
|
||||
"write_observed",
|
||||
"blocked",
|
||||
@@ -330,6 +344,7 @@ function RunLaneBadge({ state }: { state: RunState }) {
|
||||
function normalizeRemediationStatus(summary?: RemediationSummary | null): RemediationStatus {
|
||||
const statusValue = summary?.status;
|
||||
if (
|
||||
statusValue === "mcp_observed" ||
|
||||
statusValue === "read_only_dry_run" ||
|
||||
statusValue === "write_observed" ||
|
||||
statusValue === "blocked" ||
|
||||
@@ -346,9 +361,14 @@ function RemediationEvidenceCell({ summary }: { summary?: RemediationSummary | n
|
||||
const config = REMEDIATION_STATUS_CONFIG[status];
|
||||
const Icon = config.icon;
|
||||
const total = summary?.total ?? 0;
|
||||
const mcpTotal = summary?.mcp_observation_total ?? 0;
|
||||
const evidenceTotal = summary?.evidence_total ?? total + mcpTotal;
|
||||
const route = summary?.latest_route && summary.latest_route !== "--"
|
||||
? summary.latest_route
|
||||
: null;
|
||||
const countText = status === "mcp_observed"
|
||||
? t("mcpCount", { count: mcpTotal || evidenceTotal })
|
||||
: t("count", { count: total });
|
||||
|
||||
return (
|
||||
<div className="flex min-w-[230px] flex-col gap-1">
|
||||
@@ -362,9 +382,9 @@ function RemediationEvidenceCell({ summary }: { summary?: RemediationSummary | n
|
||||
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t(config.labelKey)}
|
||||
</span>
|
||||
{total > 0 ? (
|
||||
{total > 0 || mcpTotal > 0 ? (
|
||||
<span className="text-xs leading-5 text-[#5f5b52]">
|
||||
{t("count", { count: total })}
|
||||
{countText}
|
||||
{route ? ` · ${t("route", { route })}` : ""}
|
||||
</span>
|
||||
) : (
|
||||
@@ -677,6 +697,9 @@ export default function RunsPage() {
|
||||
readOnly: runs.filter(
|
||||
(run) => normalizeRemediationStatus(run.remediation_summary) === "read_only_dry_run"
|
||||
).length,
|
||||
mcpObserved: runs.filter(
|
||||
(run) => normalizeRemediationStatus(run.remediation_summary) === "mcp_observed"
|
||||
).length,
|
||||
writeObserved: runs.filter(
|
||||
(run) => normalizeRemediationStatus(run.remediation_summary) === "write_observed"
|
||||
).length,
|
||||
@@ -739,8 +762,15 @@ export default function RunsPage() {
|
||||
})}
|
||||
</section>
|
||||
|
||||
<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">
|
||||
{[
|
||||
{
|
||||
label: tEvidence("summary.mcpObserved"),
|
||||
value: evidenceSummary.mcpObserved,
|
||||
detail: tEvidence("summary.mcpObservedDetail"),
|
||||
icon: SearchCheck,
|
||||
className: "border-[#9bb6d9] bg-[#eef5ff] text-[#1f5b9b]",
|
||||
},
|
||||
{
|
||||
label: tEvidence("summary.readOnly"),
|
||||
value: evidenceSummary.readOnly,
|
||||
|
||||
Reference in New Issue
Block a user