From fbcef599f9ccde95bf350ef55bfc4752f7760ab1 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 1 Jun 2026 11:09:55 +0800 Subject: [PATCH] feat(awooop): surface automation flow gates --- .../services/awooop_truth_chain_service.py | 179 +++++++++ .../tests/test_awooop_truth_chain_service.py | 101 +++++ apps/web/messages/en.json | 57 +++ apps/web/messages/zh-TW.json | 57 +++ .../web/src/app/[locale]/awooop/runs/page.tsx | 355 ++++++++++++++++++ 5 files changed, 749 insertions(+) diff --git a/apps/api/src/services/awooop_truth_chain_service.py b/apps/api/src/services/awooop_truth_chain_service.py index 434e6849..c6ee9dbb 100644 --- a/apps/api/src/services/awooop_truth_chain_service.py +++ b/apps/api/src/services/awooop_truth_chain_service.py @@ -681,6 +681,184 @@ def _automation_quality_score_bucket(score: int) -> str: return "red" +_AUTOMATION_FLOW_GATE_DEFINITIONS: tuple[dict[str, Any], ...] = ( + { + "gate": "alert_intake", + "quality_gates": ("source_persisted", "outbound_recorded"), + "allow_not_applicable": False, + "next_action": "repair_alert_intake_or_outbound_mirror", + }, + { + "gate": "mcp_investigation", + "quality_gates": ("evidence_collected", "mcp_gateway_observed"), + "allow_not_applicable": False, + "next_action": "route_incident_to_mcp_gateway_and_evidence_collectors", + }, + { + "gate": "approval_policy", + "quality_gates": ("approval_state",), + "allow_not_applicable": True, + "next_action": "resolve_pending_or_expired_human_gate", + }, + { + "gate": "execution_recorded", + "quality_gates": ("execution_recorded",), + "allow_not_applicable": False, + "next_action": "record_effective_execution_or_mark_manual_no_action", + }, + { + "gate": "repair_recorded", + "quality_gates": ("auto_repair_recorded",), + "allow_not_applicable": False, + "next_action": "write_auto_repair_execution_or_blocker_reason", + }, + { + "gate": "verification_recorded", + "quality_gates": ("verification_recorded",), + "allow_not_applicable": False, + "next_action": "run_post_execution_verification", + }, + { + "gate": "knowledge_recorded", + "quality_gates": ("learning_recorded",), + "allow_not_applicable": False, + "next_action": "write_km_or_learning_evidence", + }, + { + "gate": "operator_visible", + "quality_gates": ("outbound_recorded", "timeline_recorded"), + "allow_not_applicable": False, + "next_action": "repair_timeline_or_operator_notification_visibility", + }, +) + + +def _quality_gate_status_map(quality: dict[str, Any]) -> dict[str, str]: + statuses: dict[str, str] = {} + for row in quality.get("gates") or []: + if not isinstance(row, dict): + continue + name = str(row.get("name") or "") + if not name: + continue + statuses[name] = str(row.get("status") or "missing") + return statuses + + +def _automation_flow_gate_record_status( + definition: dict[str, Any], + gate_statuses: dict[str, str], +) -> tuple[str, dict[str, str]]: + aliases = definition.get("source_gate_aliases") or {} + source_statuses: dict[str, str] = {} + normalized: list[str] = [] + for source_gate in definition["quality_gates"]: + mapped_gate = str(aliases.get(source_gate, source_gate)) + raw_status = gate_statuses.get(mapped_gate, "missing") + status = str(raw_status or "missing") + source_statuses[mapped_gate] = status + if status == "not_applicable" and not definition.get("allow_not_applicable"): + status = "missing" + normalized.append(status) + + if any(status == "failed" for status in normalized): + return "failed", source_statuses + if any(status == "missing" for status in normalized): + return "missing", source_statuses + if any(status == "warning" for status in normalized): + return "warning", source_statuses + return "passed", source_statuses + + +def _automation_flow_gate_summary(records: list[dict[str, Any]]) -> dict[str, Any]: + applicable_records = [ + record + for record in records + if isinstance(record.get("automation_quality"), dict) + and record["automation_quality"].get("applicable") is True + ] + evaluated_total = len(applicable_records) + gate_rows: list[dict[str, Any]] = [] + + for definition in _AUTOMATION_FLOW_GATE_DEFINITIONS: + counts = {"passed": 0, "warning": 0, "missing": 0, "failed": 0} + examples: list[dict[str, Any]] = [] + for record in applicable_records: + quality = record["automation_quality"] + incident = record.get("incident") if isinstance(record.get("incident"), dict) else {} + truth_status = ( + record.get("truth_status") + if isinstance(record.get("truth_status"), dict) + else {} + ) + status, source_statuses = _automation_flow_gate_record_status( + definition, + _quality_gate_status_map(quality), + ) + counts[status] += 1 + if status != "passed" and len(examples) < 5: + examples.append({ + "incident_id": incident.get("incident_id"), + "alertname": incident.get("alertname"), + "verdict": quality.get("verdict"), + "truth_stage": truth_status.get("current_stage"), + "source_statuses": source_statuses, + "blockers": list(quality.get("blockers") or [])[:6], + }) + + blocked_total = counts["failed"] + counts["missing"] + if evaluated_total == 0: + status = "no_data" + elif blocked_total > 0: + status = "blocked" + elif counts["warning"] > 0: + status = "warning" + else: + status = "passed" + passed_percent = ( + round((counts["passed"] / evaluated_total) * 100, 1) + if evaluated_total + else 0.0 + ) + gate_rows.append({ + "gate": definition["gate"], + "status": status, + "passed_total": counts["passed"], + "warning_total": counts["warning"], + "missing_total": counts["missing"], + "failed_total": counts["failed"], + "evaluated_total": evaluated_total, + "passed_percent": passed_percent, + "quality_gates": list(definition["quality_gates"]), + "next_action": definition["next_action"], + "examples": examples, + }) + + blocked_gates = [ + row["gate"] + for row in gate_rows + if row["status"] in {"blocked", "no_data"} + ] + warning_gates = [row["gate"] for row in gate_rows if row["status"] == "warning"] + if evaluated_total == 0: + overall_status = "no_data" + elif blocked_gates: + overall_status = "blocked" + elif warning_gates: + overall_status = "warning" + else: + overall_status = "passed" + + return { + "schema_version": "automation_flow_gate_summary_v1", + "evaluated_total": evaluated_total, + "overall_status": overall_status, + "blocked_gates": blocked_gates, + "warning_gates": warning_gates, + "gates": gate_rows, + } + + def _int_value(value: Any, fallback: int = 0) -> int: try: return int(value) @@ -999,6 +1177,7 @@ def summarize_automation_quality_records( "score_buckets": score_buckets, "by_verdict": by_verdict, "gate_failures": failing_gates, + "automation_flow_gates": _automation_flow_gate_summary(records), "execution_backend_summary": _execution_backend_summary(records), "ansible_runtime": ansible_runtime, "examples": examples[:25], diff --git a/apps/api/tests/test_awooop_truth_chain_service.py b/apps/api/tests/test_awooop_truth_chain_service.py index dd752a92..bdf656e2 100644 --- a/apps/api/tests/test_awooop_truth_chain_service.py +++ b/apps/api/tests/test_awooop_truth_chain_service.py @@ -899,6 +899,107 @@ def test_automation_quality_summary_denies_full_claim_when_unverified() -> None: assert summary["examples"][1]["score_bucket"] == "yellow" +def test_automation_quality_summary_builds_operator_flow_gates() -> None: + def quality( + *, + incident_id: str, + verdict: str, + score: int, + gates: list[dict[str, str]], + blockers: list[str] | None = None, + ) -> dict[str, object]: + return { + "incident": { + "incident_id": incident_id, + "alertname": "container restart loop", + "severity": "P3", + "status": "INVESTIGATING", + }, + "truth_status": { + "current_stage": "execution_succeeded", + "stage_status": "success", + "needs_human": verdict != "auto_repaired_verified", + }, + "automation_quality": { + "applicable": True, + "verdict": verdict, + "score": score, + "facts": { + "automation_operation_records": 1, + "effective_execution_records": 1 if verdict == "auto_repaired_verified" else 0, + "auto_repair_execution_records": 1 if verdict == "auto_repaired_verified" else 0, + }, + "gates": gates, + "blockers": blockers or [], + }, + "execution": { + "automation_operation_log": [], + "auto_repair_executions": [], + "ansible": {"considered": False, "records": [], "candidate_catalog": {"candidates": []}}, + }, + } + + passed_gates = [ + {"name": "source_persisted", "status": "passed"}, + {"name": "outbound_recorded", "status": "passed"}, + {"name": "evidence_collected", "status": "passed"}, + {"name": "mcp_gateway_observed", "status": "passed"}, + {"name": "approval_state", "status": "not_applicable"}, + {"name": "execution_recorded", "status": "passed"}, + {"name": "auto_repair_recorded", "status": "passed"}, + {"name": "verification_recorded", "status": "passed"}, + {"name": "learning_recorded", "status": "passed"}, + {"name": "timeline_recorded", "status": "passed"}, + ] + blocked_gates = [ + {"name": "source_persisted", "status": "passed"}, + {"name": "outbound_recorded", "status": "passed"}, + {"name": "evidence_collected", "status": "missing"}, + {"name": "mcp_gateway_observed", "status": "missing"}, + {"name": "approval_state", "status": "warning"}, + {"name": "execution_recorded", "status": "missing"}, + {"name": "auto_repair_recorded", "status": "missing"}, + {"name": "verification_recorded", "status": "not_applicable"}, + {"name": "learning_recorded", "status": "not_applicable"}, + {"name": "timeline_recorded", "status": "missing"}, + ] + + summary = summarize_automation_quality_records( + project_id="awoooi", + window_hours=24, + limit=2, + records=[ + quality( + incident_id="INC-PASS", + verdict="auto_repaired_verified", + score=100, + gates=passed_gates, + ), + quality( + incident_id="INC-BLOCKED", + verdict="manual_required_no_action", + score=40, + gates=blocked_gates, + blockers=["mcp_gateway_observed", "execution_recorded"], + ), + ], + ) + + flow = summary["automation_flow_gates"] + gates = {row["gate"]: row for row in flow["gates"]} + + assert flow["schema_version"] == "automation_flow_gate_summary_v1" + assert flow["overall_status"] == "blocked" + assert gates["alert_intake"]["status"] == "passed" + assert gates["mcp_investigation"]["missing_total"] == 1 + assert gates["approval_policy"]["warning_total"] == 1 + assert gates["execution_recorded"]["missing_total"] == 1 + assert gates["verification_recorded"]["missing_total"] == 1 + assert gates["knowledge_recorded"]["missing_total"] == 1 + assert gates["operator_visible"]["missing_total"] == 1 + assert gates["mcp_investigation"]["examples"][0]["incident_id"] == "INC-BLOCKED" + + def test_ansible_runtime_readiness_reports_check_mode_blockers() -> None: readiness = _ansible_runtime_readiness() diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 26e0978b..31c18153 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -4171,6 +4171,63 @@ } }, "runs": { + "automationFlow": { + "title": "AI automation flow gates", + "subtitle": "24h window: alert intake, MCP investigation, approval / policy, execution, repair, verification, KM, and operator visibility.", + "empty": "No automation flow records are ready for evaluation yet.", + "error": "AI automation flow gates failed to load: {error}", + "cacheHit": "Cache hit {age}s / TTL {ttl}s", + "cacheMiss": "Fresh aggregation / TTL {ttl}s", + "claimReady": "Full auto-repair claim is allowed", + "claimBlocked": "Full auto-repair claim is blocked", + "claimReason": "Reason: {reason}", + "coverage": "{percent}% passed", + "counts": "pass {passed} / warn {warning} / miss {missing} / fail {failed}", + "nextAction": "Next: {action}", + "example": "Example: {incidentId} / {verdict}", + "sourceStatuses": "Source gates: {statuses}", + "statuses": { + "passed": "Passed", + "warning": "Warning", + "blocked": "Blocked", + "no_data": "No data", + "unknown": "Unknown" + }, + "claimReasons": { + "all_evaluated_incidents_auto_repaired_verified": "Every evaluated incident has auto-repair and verification evidence", + "some_incidents_are_not_auto_repaired_verified": "Some incidents still lack auto-repair or verification evidence" + }, + "metrics": { + "evaluated": "Evaluated incidents", + "evaluatedDetail": "{incidents} truth-chain samples in the 24h window.", + "verifiedRepair": "Verified auto-repairs", + "verifiedRepairDetail": "Only successful repairs with post-execution verification are counted.", + "blockedGates": "Blocked gates", + "blockedGatesDetail": "Any missing / failed gate blocks a full automation claim.", + "warningGates": "Warning gates", + "warningGatesDetail": "Observable, but still needs more context or human judgment." + }, + "gates": { + "alert_intake": "Alert intake / notification mirror", + "mcp_investigation": "MCP investigation and evidence", + "approval_policy": "Approval / safety policy", + "execution_recorded": "Execution record", + "repair_recorded": "Auto-repair record", + "verification_recorded": "Post-execution verification", + "knowledge_recorded": "KM / learning write-back", + "operator_visible": "Operator visibility" + }, + "actions": { + "repair_alert_intake_or_outbound_mirror": "Repair alert intake or outbound mirror", + "route_incident_to_mcp_gateway_and_evidence_collectors": "Route the incident through MCP Gateway and evidence collectors", + "resolve_pending_or_expired_human_gate": "Resolve pending / expired human gates", + "record_effective_execution_or_mark_manual_no_action": "Record effective execution, or explicitly mark manual no-action", + "write_auto_repair_execution_or_blocker_reason": "Write auto-repair execution or blocker reason", + "run_post_execution_verification": "Run post-execution verification and store the result", + "write_km_or_learning_evidence": "Write KM / learning evidence", + "repair_timeline_or_operator_notification_visibility": "Repair timeline or operator notification visibility" + } + }, "securityRunStateCandidate": { "title": "IwoooS 執行狀態只讀候選", "subtitle": "執行監控只顯示資安鏡像可以被 AwoooP 執行視角理解;這不是已建立執行紀錄,也不會接上執行路由器。", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 26e0978b..9d80b46e 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -4171,6 +4171,63 @@ } }, "runs": { + "automationFlow": { + "title": "AI 自動化流程 Gate", + "subtitle": "24h 視窗:告警入庫、MCP 調查、審批 / 政策、執行、修復、驗證、KM 與 Operator 可見性。", + "empty": "尚無可評估的自動化流程資料。", + "error": "AI 自動化流程 Gate 載入失敗:{error}", + "cacheHit": "快取命中 {age}s / TTL {ttl}s", + "cacheMiss": "剛重新聚合 / TTL {ttl}s", + "claimReady": "可以宣稱全自動修復", + "claimBlocked": "不可宣稱全自動修復", + "claimReason": "原因:{reason}", + "coverage": "{percent}% 通過", + "counts": "pass {passed} / warn {warning} / miss {missing} / fail {failed}", + "nextAction": "下一步:{action}", + "example": "例:{incidentId} / {verdict}", + "sourceStatuses": "來源 Gate:{statuses}", + "statuses": { + "passed": "Passed", + "warning": "Warning", + "blocked": "Blocked", + "no_data": "No data", + "unknown": "Unknown" + }, + "claimReasons": { + "all_evaluated_incidents_auto_repaired_verified": "所有已評估事件都有自動修復與驗證證據", + "some_incidents_are_not_auto_repaired_verified": "仍有事件缺少自動修復或驗證證據" + }, + "metrics": { + "evaluated": "已評估事件", + "evaluatedDetail": "24h 視窗共 {incidents} 件 truth-chain 樣本。", + "verifiedRepair": "已驗證自動修復", + "verifiedRepairDetail": "只有修復成功且完成事後驗證才計入。", + "blockedGates": "Blocked Gate", + "blockedGatesDetail": "有 missing / failed 就不能宣稱完整自動化。", + "warningGates": "Warning Gate", + "warningGatesDetail": "仍可觀測,但需要補脈絡或人工判斷。" + }, + "gates": { + "alert_intake": "告警入庫 / 通知鏡像", + "mcp_investigation": "MCP 調查與 evidence", + "approval_policy": "審批 / 安全政策", + "execution_recorded": "執行紀錄", + "repair_recorded": "自動修復紀錄", + "verification_recorded": "事後驗證", + "knowledge_recorded": "KM / 學習回寫", + "operator_visible": "Operator 可見性" + }, + "actions": { + "repair_alert_intake_or_outbound_mirror": "修復告警入庫或 outbound mirror", + "route_incident_to_mcp_gateway_and_evidence_collectors": "把事件導入 MCP Gateway 與 evidence collectors", + "resolve_pending_or_expired_human_gate": "處理 pending / expired 人工 gate", + "record_effective_execution_or_mark_manual_no_action": "記錄有效執行,或明確標成人工 no-action", + "write_auto_repair_execution_or_blocker_reason": "寫入 auto-repair execution 或 blocker reason", + "run_post_execution_verification": "執行事後驗證並保存結果", + "write_km_or_learning_evidence": "回寫 KM / learning evidence", + "repair_timeline_or_operator_notification_visibility": "修復 timeline 或 operator notification 可見性" + } + }, "securityRunStateCandidate": { "title": "IwoooS 執行狀態只讀候選", "subtitle": "執行監控只顯示資安鏡像可以被 AwoooP 執行視角理解;這不是已建立執行紀錄,也不會接上執行路由器。", diff --git a/apps/web/src/app/[locale]/awooop/runs/page.tsx b/apps/web/src/app/[locale]/awooop/runs/page.tsx index 1f13bcca..3b808a39 100644 --- a/apps/web/src/app/[locale]/awooop/runs/page.tsx +++ b/apps/web/src/app/[locale]/awooop/runs/page.tsx @@ -81,6 +81,7 @@ type RecurrenceRepairStatus = | "run_completed_no_repair" | "source_correlation_review" | "no_repair_record"; +type AutomationFlowStatus = "passed" | "warning" | "blocked" | "no_data" | string; interface RemediationSummary { schema_version?: string; @@ -213,6 +214,52 @@ interface OperatorSummaryCacheInfo { expires_at?: string; } +interface AutomationFlowGateExample { + incident_id?: string | null; + alertname?: string | null; + verdict?: string | null; + truth_stage?: string | null; + source_statuses?: Record; + blockers?: string[]; +} + +interface AutomationFlowGate { + gate: string; + status: AutomationFlowStatus; + passed_total: number; + warning_total: number; + missing_total: number; + failed_total: number; + evaluated_total: number; + passed_percent: number; + quality_gates?: string[]; + next_action?: string; + examples?: AutomationFlowGateExample[]; +} + +interface AutomationFlowGateSummary { + schema_version?: "automation_flow_gate_summary_v1" | string; + evaluated_total: number; + overall_status: AutomationFlowStatus; + blocked_gates: string[]; + warning_gates: string[]; + gates: AutomationFlowGate[]; +} + +interface AutomationQualitySummary { + schema_version?: "automation_quality_summary_v1" | string; + incident_total: number; + evaluated_total: number; + verified_auto_repair_total: number; + average_score: number; + production_claim?: { + can_claim_full_auto_repair?: boolean; + reason?: string | null; + } | null; + automation_flow_gates?: AutomationFlowGateSummary | null; + cache?: OperatorSummaryCacheInfo | null; +} + interface Run { run_id: string; project_id: string; @@ -2776,6 +2823,280 @@ function CallbackReplyAuditSummaryPanel({ ); } +const AUTOMATION_FLOW_GATE_LABEL_KEYS: Record = { + alert_intake: "alert_intake", + mcp_investigation: "mcp_investigation", + approval_policy: "approval_policy", + execution_recorded: "execution_recorded", + repair_recorded: "repair_recorded", + verification_recorded: "verification_recorded", + knowledge_recorded: "knowledge_recorded", + operator_visible: "operator_visible", +}; + +const AUTOMATION_FLOW_ACTION_KEYS: Record = { + repair_alert_intake_or_outbound_mirror: "repair_alert_intake_or_outbound_mirror", + route_incident_to_mcp_gateway_and_evidence_collectors: + "route_incident_to_mcp_gateway_and_evidence_collectors", + resolve_pending_or_expired_human_gate: "resolve_pending_or_expired_human_gate", + record_effective_execution_or_mark_manual_no_action: + "record_effective_execution_or_mark_manual_no_action", + write_auto_repair_execution_or_blocker_reason: + "write_auto_repair_execution_or_blocker_reason", + run_post_execution_verification: "run_post_execution_verification", + write_km_or_learning_evidence: "write_km_or_learning_evidence", + repair_timeline_or_operator_notification_visibility: + "repair_timeline_or_operator_notification_visibility", +}; + +const AUTOMATION_FLOW_CLAIM_REASON_KEYS: Record = { + all_evaluated_incidents_auto_repaired_verified: + "all_evaluated_incidents_auto_repaired_verified", + some_incidents_are_not_auto_repaired_verified: + "some_incidents_are_not_auto_repaired_verified", +}; + +function automationFlowStatusClass(status?: AutomationFlowStatus | null) { + if (status === "passed") { + return "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]"; + } + if (status === "warning") { + return "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]"; + } + if (status === "blocked") { + return "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]"; + } + return "border-[#d8d3c7] bg-[#faf9f3] text-[#5f5b52]"; +} + +function automationFlowStatusLabelKey(status?: AutomationFlowStatus | null) { + if ( + status === "passed" || + status === "warning" || + status === "blocked" || + status === "no_data" + ) { + return `statuses.${status}`; + } + return "statuses.unknown"; +} + +function automationFlowStatusIcon(status?: AutomationFlowStatus | null) { + if (status === "passed") return ShieldCheck; + if (status === "warning") return TriangleAlert; + if (status === "blocked") return AlertCircle; + return SearchCheck; +} + +function AutomationFlowGatePanel({ + summary, + error, +}: { + summary: AutomationQualitySummary | null; + error: string | null; +}) { + const t = useTranslations("awooop.runs.automationFlow"); + const flow = summary?.automation_flow_gates ?? null; + const overallStatus = flow?.overall_status ?? "no_data"; + const OverallIcon = automationFlowStatusIcon(overallStatus); + const cacheAge = Math.max(0, Math.round(summary?.cache?.age_seconds ?? 0)); + const cacheLabel = summary?.cache + ? summary.cache.status === "hit" + ? t("cacheHit", { age: cacheAge, ttl: summary.cache.ttl_seconds ?? 0 }) + : t("cacheMiss", { age: cacheAge, ttl: summary.cache.ttl_seconds ?? 0 }) + : null; + const claimReady = Boolean(summary?.production_claim?.can_claim_full_auto_repair); + const claimReason = summary?.production_claim?.reason ?? "unknown"; + const claimReasonKey = AUTOMATION_FLOW_CLAIM_REASON_KEYS[claimReason]; + const claimReasonLabel = claimReasonKey + ? t(`claimReasons.${claimReasonKey}` as never) + : claimReason; + + return ( +
+
+
+
+
+ {cacheLabel ? ( + + {cacheLabel} + + ) : null} + + +
+
+ + {error ? ( +
+ {t("error", { error })} +
+ ) : !summary || !flow ? ( +
+ {t("empty")} +
+ ) : ( + <> +
+
+ {claimReady ? ( +
+

{t("claimReason", { reason: claimReasonLabel })}

+
+ +
+ {[ + { + label: t("metrics.evaluated"), + value: flow.evaluated_total, + detail: t("metrics.evaluatedDetail", { + incidents: summary.incident_total ?? 0, + }), + }, + { + label: t("metrics.verifiedRepair"), + value: summary.verified_auto_repair_total ?? 0, + detail: t("metrics.verifiedRepairDetail"), + }, + { + label: t("metrics.blockedGates"), + value: flow.blocked_gates?.length ?? 0, + detail: t("metrics.blockedGatesDetail"), + }, + { + label: t("metrics.warningGates"), + value: flow.warning_gates?.length ?? 0, + detail: t("metrics.warningGatesDetail"), + }, + ].map((item) => ( +
+

{item.label}

+

+ {item.value} +

+

{item.detail}

+
+ ))} +
+ +
+ {flow.gates.map((gate) => { + const StatusIcon = automationFlowStatusIcon(gate.status); + const gateLabelKey = AUTOMATION_FLOW_GATE_LABEL_KEYS[gate.gate]; + const gateLabel = gateLabelKey + ? t(`gates.${gateLabelKey}` as never) + : gate.gate; + const actionKey = gate.next_action + ? AUTOMATION_FLOW_ACTION_KEYS[gate.next_action] + : null; + const actionLabel = actionKey + ? t(`actions.${actionKey}` as never) + : gate.next_action ?? "--"; + const passedPercent = Math.max( + 0, + Math.min(100, Number.isFinite(gate.passed_percent) ? gate.passed_percent : 0) + ); + const example = gate.examples?.[0]; + const sourceStatuses = Object.entries(example?.source_statuses ?? {}) + .map(([key, value]) => `${key}:${value}`) + .join(", "); + + return ( +
+
+
+

{gateLabel}

+

+ {gate.quality_gates?.join(" / ") || gate.gate} +

+
+ + +
+
+
+
+

+ {t("coverage", { percent: passedPercent.toFixed(1) })} +

+

+ {t("counts", { + passed: gate.passed_total ?? 0, + warning: gate.warning_total ?? 0, + missing: gate.missing_total ?? 0, + failed: gate.failed_total ?? 0, + })} +

+

+ {t("nextAction", { action: actionLabel })} +

+ {example ? ( +
+

+ {t("example", { + incidentId: example.incident_id ?? "--", + verdict: example.verdict ?? "--", + })} +

+ {sourceStatuses ? ( +

+ {t("sourceStatuses", { statuses: sourceStatuses })} +

+ ) : null} +
+ ) : null} +
+ ); + })} +
+ + )} +
+ ); +} + function CallbackReplyEvidencePanel({ events, total, @@ -3339,6 +3660,9 @@ export default function RunsPage() { const [callbackEventsError, setCallbackEventsError] = useState(null); const [aiRouteStatus, setAiRouteStatus] = useState(null); const [aiRouteStatusError, setAiRouteStatusError] = useState(null); + const [automationQualitySummary, setAutomationQualitySummary] = + useState(null); + const [automationQualityError, setAutomationQualityError] = useState(null); const [tenants, setTenants] = useState([]); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(true); @@ -3491,6 +3815,32 @@ export default function RunsPage() { setCallbackEventsLoading(false); } + try { + const qualityParams = new URLSearchParams(); + qualityParams.set("project_id", projectFilter || "awoooi"); + qualityParams.set("hours", "24"); + qualityParams.set("limit", "30"); + if (options?.refresh) { + qualityParams.set("refresh", "true"); + } + const qualityRes = await fetch( + `${API_BASE}/api/v1/platform/truth-chain/quality/summary?${qualityParams.toString()}` + ); + if (qualityRes.ok) { + const qualityData: AutomationQualitySummary = await qualityRes.json(); + setAutomationQualitySummary(qualityData); + setAutomationQualityError(null); + } else { + setAutomationQualitySummary(null); + setAutomationQualityError(`HTTP ${qualityRes.status}`); + } + } catch (qualityError) { + setAutomationQualitySummary(null); + setAutomationQualityError( + qualityError instanceof Error ? qualityError.message : "automation quality failed" + ); + } + try { const routeStatusRes = await fetch( `${API_BASE}/api/v1/platform/ai-route-status?workload_type=deep_rca` @@ -3714,6 +4064,11 @@ export default function RunsPage() { })} + +