diff --git a/apps/api/src/services/platform_operator_service.py b/apps/api/src/services/platform_operator_service.py index 602319c4..070e6d90 100644 --- a/apps/api/src/services/platform_operator_service.py +++ b/apps/api/src/services/platform_operator_service.py @@ -1151,6 +1151,67 @@ def _select_status_chain_source_id( return incident_ids[0] if incident_ids else latest_incident_id or None +def _status_chain_mcp_section(truth_chain: dict[str, Any] | None) -> dict[str, Any]: + mcp = truth_chain.get("mcp") if isinstance(truth_chain, dict) else {} + if not isinstance(mcp, dict): + mcp = {} + gateway = mcp.get("awooop_gateway") if isinstance(mcp.get("awooop_gateway"), dict) else {} + legacy = mcp.get("legacy") if isinstance(mcp.get("legacy"), dict) else {} + + top_tools: list[dict[str, Any]] = [] + seen_tools: set[str] = set() + for source, summary in (("gateway", gateway), ("legacy", legacy)): + by_tool = summary.get("by_tool") if isinstance(summary, dict) else [] + if not isinstance(by_tool, list): + continue + for item in by_tool: + if not isinstance(item, dict): + continue + tool_name = str(item.get("tool_name") or "unknown").strip() or "unknown" + key = f"{source}:{tool_name}" + if key in seen_tools: + continue + seen_tools.add(key) + top_tools.append({ + "source": source, + "tool_name": tool_name, + "total": ( + _safe_int(item.get("total")) + or _safe_int(item.get("success")) + + _safe_int(item.get("failed")) + + _safe_int(item.get("blocked")) + ), + "success": _safe_int(item.get("success")), + "failed": _safe_int(item.get("failed")), + "blocked": _safe_int(item.get("blocked")), + "last_error": item.get("last_error"), + }) + if len(top_tools) >= 5: + break + if len(top_tools) >= 5: + break + + return { + "gateway": { + "total": _safe_int(gateway.get("total")), + "success": _safe_int(gateway.get("success")), + "failed": _safe_int(gateway.get("failed")), + "blocked": _safe_int(gateway.get("blocked")), + "first_class_total": _safe_int(gateway.get("first_class_total")), + "legacy_bridge_total": _safe_int(gateway.get("legacy_bridge_total")), + "policy_enforced_total": _safe_int(gateway.get("policy_enforced_total")), + "stage": gateway.get("stage"), + "stage_status": gateway.get("stage_status"), + }, + "legacy": { + "total": _safe_int(legacy.get("total")), + "success": _safe_int(legacy.get("success")), + "failed": _safe_int(legacy.get("failed")), + }, + "top_tools": top_tools, + } + + def _build_awooop_status_chain( *, incident_ids: list[str], @@ -1228,6 +1289,7 @@ def _build_awooop_status_chain( ): needs_human = True + mcp_section = _status_chain_mcp_section(truth_chain) blockers = [ str(item) for item in [ @@ -1269,6 +1331,7 @@ def _build_awooop_status_chain( "incident": latest.get("writes_incident_state"), "auto_repair": latest.get("writes_auto_repair_result"), }, + "mcp": mcp_section, } diff --git a/apps/api/tests/test_awooop_operator_timeline_labels.py b/apps/api/tests/test_awooop_operator_timeline_labels.py index 7612bf6f..2c717411 100644 --- a/apps/api/tests/test_awooop_operator_timeline_labels.py +++ b/apps/api/tests/test_awooop_operator_timeline_labels.py @@ -470,6 +470,40 @@ def test_awooop_status_chain_marks_verified_repair() -> None: }, "blockers": [], }, + "mcp": { + "awooop_gateway": { + "total": 2, + "success": 1, + "failed": 1, + "blocked": 0, + "first_class_total": 2, + "legacy_bridge_total": 0, + "policy_enforced_total": 2, + "stage": "provider_failed_after_gateway", + "stage_status": "failed", + "by_tool": [ + { + "tool_name": "prometheus.query", + "total": 2, + "success": 1, + "failed": 1, + "blocked": 0, + } + ], + }, + "legacy": { + "total": 1, + "success": 1, + "failed": 0, + "by_tool": [ + { + "tool_name": "ssh_host", + "success": 1, + "failed": 0, + } + ], + }, + }, }, remediation_history={ "total": 1, @@ -492,6 +526,11 @@ def test_awooop_status_chain_marks_verified_repair() -> None: assert chain["needs_human"] is False assert chain["next_step"] == "monitor_for_regression" assert chain["evidence"]["latest_route"] == "auto_repair_executor/rollout_restart/write" + assert chain["mcp"]["gateway"]["success"] == 1 + assert chain["mcp"]["gateway"]["failed"] == 1 + assert chain["mcp"]["gateway"]["policy_enforced_total"] == 2 + assert chain["mcp"]["legacy"]["total"] == 1 + assert chain["mcp"]["top_tools"][0]["tool_name"] == "prometheus.query" def test_awooop_status_chain_marks_read_only_manual_gate() -> None: diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 172629f0..cb693616 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -431,6 +431,7 @@ "flowEvidenceOps": "Ops {count}", "flowEvidenceKm": "KM {count}", "flowEvidenceRepair": "Repair {count}", + "flowMcpDetail": "MCP detail: Gateway success {success} / failed {failed} / blocked {blocked}; first-class {firstClass}; legacy {legacy}; tools {tools}", "flowTruthChainCurrent": "{stage} / {status}", "flowComplete": "Complete", "flowStages": { diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 547ca9e6..44e11196 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -432,6 +432,7 @@ "flowEvidenceOps": "操作 {count}", "flowEvidenceKm": "KM {count}", "flowEvidenceRepair": "修復 {count}", + "flowMcpDetail": "MCP 明細:Gateway 成功 {success} / 失敗 {failed} / 阻擋 {blocked};一級治理 {firstClass};Legacy {legacy};工具 {tools}", "flowTruthChainCurrent": "{stage} / {status}", "flowComplete": "已完成", "flowStages": { diff --git a/apps/web/src/components/awooop/status-chain.tsx b/apps/web/src/components/awooop/status-chain.tsx index cedf2c7d..c22a25ae 100644 --- a/apps/web/src/components/awooop/status-chain.tsx +++ b/apps/web/src/components/awooop/status-chain.tsx @@ -35,6 +35,33 @@ export interface AwoooPStatusChain { incident?: boolean | null; auto_repair?: boolean | null; }; + mcp?: { + gateway?: { + total?: number | null; + success?: number | null; + failed?: number | null; + blocked?: number | null; + first_class_total?: number | null; + legacy_bridge_total?: number | null; + policy_enforced_total?: number | null; + stage?: string | null; + stage_status?: string | null; + }; + legacy?: { + total?: number | null; + success?: number | null; + failed?: number | null; + }; + top_tools?: Array<{ + source?: string | null; + tool_name?: string | null; + total?: number | null; + success?: number | null; + failed?: number | null; + blocked?: number | null; + last_error?: string | null; + }>; + }; } function toneClass(chain?: AwoooPStatusChain | null) { diff --git a/apps/web/src/components/incident/incident-card.tsx b/apps/web/src/components/incident/incident-card.tsx index f9f9e26b..5ff2eac9 100644 --- a/apps/web/src/components/incident/incident-card.tsx +++ b/apps/web/src/components/incident/incident-card.tsx @@ -335,6 +335,22 @@ export function IncidentCard({ incident, decision, statusChain, onApprovalChange active: (statusChain?.evidence?.auto_repair_records ?? 0) > 0, }, ] : [] + const mcpTopTools = (statusChain?.mcp?.top_tools ?? []).slice(0, 3).map(tool => { + const toolName = chainValue(tool.tool_name) + return `${toolName} ${tool.success ?? 0}/${tool.failed ?? 0}/${tool.blocked ?? 0}` + }).join(', ') + const mcpGateway = statusChain?.mcp?.gateway + const mcpLegacy = statusChain?.mcp?.legacy + const statusChainMcpDetail = hasTruthChain + ? t('flowMcpDetail', { + success: mcpGateway?.success ?? 0, + failed: mcpGateway?.failed ?? 0, + blocked: mcpGateway?.blocked ?? 0, + firstClass: mcpGateway?.first_class_total ?? 0, + legacy: mcpLegacy?.total ?? 0, + tools: mcpTopTools || '--', + }) + : null const serviceName = incident.affected_services?.[0] ?? '--' const duration = formatDuration(incident.created_at) @@ -606,6 +622,10 @@ export function IncidentCard({ incident, decision, statusChain, onApprovalChange ))} + / + + {statusChainMcpDetail} + )}