feat(awooop): surface automation flow gates
All checks were successful
CD Pipeline / tests (push) Successful in 1m26s
Code Review / ai-code-review (push) Successful in 26s
CD Pipeline / build-and-deploy (push) Successful in 4m12s
CD Pipeline / post-deploy-checks (push) Successful in 2m37s

This commit is contained in:
Your Name
2026-06-01 11:09:55 +08:00
parent 61675911f7
commit fbcef599f9
5 changed files with 749 additions and 0 deletions

View File

@@ -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],

View File

@@ -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()

View File

@@ -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 執行視角理解;這不是已建立執行紀錄,也不會接上執行路由器。",

View File

@@ -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 執行視角理解;這不是已建立執行紀錄,也不會接上執行路由器。",

View File

@@ -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<string, string>;
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<string, string> = {
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<string, string> = {
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<string, string> = {
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 (
<section id="automation-flow-gates" className="border border-[#e0ddd4] bg-white">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[#e0ddd4] bg-[#faf9f3] px-4 py-3">
<div className="flex items-center gap-2">
<Waypoints className="h-4 w-4 text-[#1f5b9b]" aria-hidden="true" />
<div>
<h3 className="text-sm font-semibold text-[#141413]">{t("title")}</h3>
<p className="text-xs text-[#77736a]">{t("subtitle")}</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{cacheLabel ? (
<span className="border border-[#d8d3c7] bg-white px-2 py-0.5 text-xs font-semibold text-[#5f5b52]">
{cacheLabel}
</span>
) : null}
<span
className={cn(
"inline-flex items-center gap-1.5 border px-2 py-0.5 text-xs font-semibold",
automationFlowStatusClass(overallStatus)
)}
>
<OverallIcon className="h-3.5 w-3.5" aria-hidden="true" />
{t(automationFlowStatusLabelKey(overallStatus) as never)}
</span>
</div>
</div>
{error ? (
<div className="px-4 py-4 text-sm text-[#9f2f25]">
{t("error", { error })}
</div>
) : !summary || !flow ? (
<div className="px-4 py-4 text-sm text-[#5f5b52]">
{t("empty")}
</div>
) : (
<>
<div
className={cn(
"border-b px-4 py-3 text-xs leading-5",
claimReady
? "border-[#c8dfcb] bg-[#f4fbf5] text-[#17602a]"
: "border-[#eed2ce] bg-[#fff6f5] text-[#8f2c22]"
)}
>
<div className="flex flex-wrap items-center gap-2 font-semibold">
{claimReady ? (
<ShieldCheck className="h-4 w-4" aria-hidden="true" />
) : (
<AlertCircle className="h-4 w-4" aria-hidden="true" />
)}
<span>{claimReady ? t("claimReady") : t("claimBlocked")}</span>
</div>
<p className="mt-1">{t("claimReason", { reason: claimReasonLabel })}</p>
</div>
<div className="grid gap-px bg-[#e0ddd4] md:grid-cols-2 xl:grid-cols-4">
{[
{
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) => (
<div key={item.label} className="bg-white px-4 py-3">
<p className="text-xs font-semibold text-[#77736a]">{item.label}</p>
<p className="mt-2 font-mono text-2xl font-semibold text-[#141413]">
{item.value}
</p>
<p className="mt-1 text-xs leading-5 text-[#5f5b52]">{item.detail}</p>
</div>
))}
</div>
<div className="grid gap-px bg-[#e0ddd4] md:grid-cols-2 xl:grid-cols-4">
{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 (
<article key={gate.gate} className="bg-white px-4 py-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-xs font-semibold text-[#141413]">{gateLabel}</p>
<p className="mt-1 font-mono text-[11px] text-[#77736a]">
{gate.quality_gates?.join(" / ") || gate.gate}
</p>
</div>
<span
className={cn(
"inline-flex shrink-0 items-center gap-1 border px-2 py-0.5 text-xs font-semibold",
automationFlowStatusClass(gate.status)
)}
>
<StatusIcon className="h-3.5 w-3.5" aria-hidden="true" />
{t(automationFlowStatusLabelKey(gate.status) as never)}
</span>
</div>
<div className="mt-3 h-1.5 bg-[#ece7dc]">
<div
className={cn(
"h-full",
gate.status === "passed"
? "bg-[#4f9d5f]"
: gate.status === "warning"
? "bg-[#c58a24]"
: "bg-[#c65145]"
)}
style={{ width: `${passedPercent}%` }}
/>
</div>
<p className="mt-2 text-xs leading-5 text-[#5f5b52]">
{t("coverage", { percent: passedPercent.toFixed(1) })}
</p>
<p className="mt-1 text-xs leading-5 text-[#5f5b52]">
{t("counts", {
passed: gate.passed_total ?? 0,
warning: gate.warning_total ?? 0,
missing: gate.missing_total ?? 0,
failed: gate.failed_total ?? 0,
})}
</p>
<p className="mt-2 text-xs font-semibold leading-5 text-[#141413]">
{t("nextAction", { action: actionLabel })}
</p>
{example ? (
<div className="mt-2 space-y-1 text-xs leading-5 text-[#77736a]">
<p>
{t("example", {
incidentId: example.incident_id ?? "--",
verdict: example.verdict ?? "--",
})}
</p>
{sourceStatuses ? (
<p className="break-words font-mono">
{t("sourceStatuses", { statuses: sourceStatuses })}
</p>
) : null}
</div>
) : null}
</article>
);
})}
</div>
</>
)}
</section>
);
}
function CallbackReplyEvidencePanel({
events,
total,
@@ -3339,6 +3660,9 @@ export default function RunsPage() {
const [callbackEventsError, setCallbackEventsError] = useState<string | null>(null);
const [aiRouteStatus, setAiRouteStatus] = useState<AiRouteStatusResponse | null>(null);
const [aiRouteStatusError, setAiRouteStatusError] = useState<string | null>(null);
const [automationQualitySummary, setAutomationQualitySummary] =
useState<AutomationQualitySummary | null>(null);
const [automationQualityError, setAutomationQualityError] = useState<string | null>(null);
const [tenants, setTenants] = useState<Tenant[]>([]);
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() {
})}
</section>
<AutomationFlowGatePanel
summary={automationQualitySummary}
error={automationQualityError}
/>
<SecurityRunStateCandidatePanel />
<GitHubRunReadinessBoundaryPanel />