diff --git a/apps/api/src/api/v1/platform/operator_runs.py b/apps/api/src/api/v1/platform/operator_runs.py
index 43586bd6..fe0826d9 100644
--- a/apps/api/src/api/v1/platform/operator_runs.py
+++ b/apps/api/src/api/v1/platform/operator_runs.py
@@ -108,8 +108,9 @@ async def list_runs(
)
async def list_approvals(
project_id: str | None = Query(None, description="租戶 ID(可選)"),
+ run_id: str | None = Query(None, description="Run ID(可選,M8 詳情頁查單筆)"),
) -> dict[str, Any]:
- return await list_approvals_svc(project_id=project_id)
+ return await list_approvals_svc(project_id=project_id, run_id=run_id)
@router.post(
diff --git a/apps/api/src/repositories/metrics_repository.py b/apps/api/src/repositories/metrics_repository.py
index 03099329..3108b1a4 100644
--- a/apps/api/src/repositories/metrics_repository.py
+++ b/apps/api/src/repositories/metrics_repository.py
@@ -60,13 +60,17 @@ class MetricsDBRepository(IMetricsRepository):
cutoff = datetime.now(UTC) - timedelta(hours=hours)
# Query: 統計 executed vs total (approved + executed + execution_failed)
+ # 2026-05-06 ogt + Codex:
+ # approval_records.status 目前實際寫入的是大寫 enum
+ # (APPROVED / EXECUTION_SUCCESS / EXECUTION_FAILED)。舊查詢只看
+ # lowercase executed,導致 AI Success 在報表層永遠趨近 0。
query = text("""
SELECT
- COUNT(CASE WHEN status = 'executed' THEN 1 END) as executed_count,
+ COUNT(CASE WHEN UPPER(status::text) = 'EXECUTION_SUCCESS' THEN 1 END) as executed_count,
COUNT(*) as total_count
FROM approval_records
WHERE created_at >= :cutoff
- AND status IN ('approved', 'executed', 'execution_failed')
+ AND UPPER(status::text) IN ('APPROVED', 'EXECUTION_SUCCESS', 'EXECUTION_FAILED')
""")
result = await session.execute(query, {"cutoff": cutoff})
@@ -127,11 +131,11 @@ class MetricsDBRepository(IMetricsRepository):
trend_query = text("""
SELECT
date_trunc('hour', created_at) as hour_bucket,
- COUNT(CASE WHEN status = 'executed' THEN 1 END) * 100.0 /
+ COUNT(CASE WHEN UPPER(status::text) = 'EXECUTION_SUCCESS' THEN 1 END) * 100.0 /
NULLIF(COUNT(*), 0) as hourly_rate
FROM approval_records
WHERE created_at >= :cutoff
- AND status IN ('approved', 'executed', 'execution_failed')
+ AND UPPER(status::text) IN ('APPROVED', 'EXECUTION_SUCCESS', 'EXECUTION_FAILED')
GROUP BY hour_bucket
ORDER BY hour_bucket DESC
LIMIT :limit
diff --git a/apps/api/src/services/auto_repair_service.py b/apps/api/src/services/auto_repair_service.py
index fb44ecbd..11a08f24 100644
--- a/apps/api/src/services/auto_repair_service.py
+++ b/apps/api/src/services/auto_repair_service.py
@@ -457,6 +457,8 @@ class AutoRepairService:
except Exception as _db_e:
logger.error("auto_repair_db_write_failed", error=str(_db_e))
+ self._record_auto_repair_metric(playbook, success=True)
+
# 2026-04-07 Claude Code: Sprint 4 B1/B2 — 記錄處置類型
# P0-1 Fix: 統一使用 AnomalyCounter.hash_signature()
try:
@@ -630,6 +632,8 @@ class AutoRepairService:
except Exception as _db_e:
logger.error("auto_repair_db_write_failed", error=str(_db_e))
+ self._record_auto_repair_metric(playbook, success=False)
+
# 2026-04-04 Claude Code: Phase 25 P1 — 失敗修復後 fire-and-forget 生成 ANTI_PATTERN
# 2026-04-05 Claude Code: I1 修正 — 補齊 _pending_tasks GC 防護(對稱化)
try:
@@ -700,6 +704,35 @@ class AutoRepairService:
return max_risk
+ def _record_auto_repair_metric(self, playbook: Playbook, success: bool) -> None:
+ """把實際 auto-repair 執行寫入 Prometheus 指標。
+
+ 2026-05-06 ogt + Codex:DB 已有 auto_repair_executions,但
+ core.metrics.record_auto_repair() 長期零 caller,導致治理/心跳用
+ Prometheus 看起來像「飛輪沒做事」。label 使用 action_type,避免
+ playbook_id 造成高基數。
+ """
+ try:
+ from src.core.metrics import record_auto_repair
+
+ first_step = playbook.repair_steps[0] if playbook.repair_steps else None
+ action = first_step.action_type.value if first_step else "unknown"
+ max_risk = self._get_max_risk_level(playbook)
+ tier = {
+ RiskLevel.LOW: 1,
+ RiskLevel.MEDIUM: 2,
+ RiskLevel.HIGH: 3,
+ RiskLevel.CRITICAL: 4,
+ }.get(max_risk, 0)
+ record_auto_repair(action=action, tier=tier, success=success)
+ except Exception as e:
+ logger.warning(
+ "auto_repair_metric_record_failed",
+ playbook_id=playbook.playbook_id,
+ success=success,
+ error=str(e),
+ )
+
def _is_host_or_backup_incident(self, incident: Incident) -> bool:
"""主機/備份類事件只能走 SSH/只讀診斷,不允許 K8s rollout 類修復。"""
diff --git a/apps/api/src/services/flywheel_stats_service.py b/apps/api/src/services/flywheel_stats_service.py
index 1ea75078..1d51d471 100644
--- a/apps/api/src/services/flywheel_stats_service.py
+++ b/apps/api/src/services/flywheel_stats_service.py
@@ -237,6 +237,31 @@ class FlywheelStatsService:
except (json.JSONDecodeError, KeyError):
continue
+ # 2026-05-06 ogt + Codex:
+ # 執行成功率的 source of truth 是 auto_repair_executions。
+ # Redis playbook success_count/failure_count 會因回寫鏈路中斷而落後,
+ # 造成 governance / heartbeat 判定「飛輪沒有執行」。
+ try:
+ async with get_db_context() as db:
+ row = await db.execute(
+ text("""
+ SELECT
+ COUNT(*) FILTER (WHERE success IS TRUE) AS success,
+ COUNT(*) AS total
+ FROM auto_repair_executions
+ WHERE created_at >= NOW() - interval '24 hours'
+ """)
+ )
+ repair_stats = row.one()
+ db_total_exec = int(repair_stats.total or 0)
+ if db_total_exec >= FLYWHEEL_MIN_SAMPLE:
+ db_total_success = int(repair_stats.success or 0)
+ return count, db_total_success / db_total_exec
+ if db_total_exec > 0:
+ return count, None
+ except Exception:
+ logger.warning("flywheel_stats_auto_repair_execution_query_failed")
+
if total_exec < FLYWHEEL_MIN_SAMPLE:
# 樣本不足(含 Redis 空),回 None 通知呼叫方跳過 W-3 告警判斷
return count, None
diff --git a/apps/api/src/services/heartbeat_report_service.py b/apps/api/src/services/heartbeat_report_service.py
index 4cc58ad8..106e6f1e 100644
--- a/apps/api/src/services/heartbeat_report_service.py
+++ b/apps/api/src/services/heartbeat_report_service.py
@@ -15,7 +15,7 @@ HeartbeatReportService — ADR-073 心跳監控重構
import asyncio
import html
from dataclasses import dataclass, field
-from datetime import datetime, timedelta
+from datetime import datetime
from typing import Optional
import httpx
@@ -420,8 +420,8 @@ class HeartbeatReportService:
try:
# KM 向量化率(DB 查詢)
from src.db.base import get_db_context
- from src.db.models import IncidentRecord, KnowledgeEntryRecord
- from sqlalchemy import func, select
+ from src.db.models import KnowledgeEntryRecord
+ from sqlalchemy import func, select, text as sa_text
async with get_db_context() as db:
# KM 總數
km_total = await db.scalar(select(func.count()).select_from(KnowledgeEntryRecord))
@@ -436,20 +436,22 @@ class HeartbeatReportService:
stats.km_vectorized = vec_result.scalar() or 0
# 24h 修復統計
- since = datetime.utcnow() - timedelta(hours=24)
- outcomes = await db.execute(
- select(IncidentRecord.outcome).where(
- IncidentRecord.created_at >= since,
- IncidentRecord.outcome.isnot(None),
- )
- )
- outcome_list = [r[0] for r in outcomes.all() if r[0]]
- stats.attempt_24h = len(outcome_list)
- stats.success_24h = sum(
- 1 for o in outcome_list
- if isinstance(o, dict) and o.get("execution_success")
- or isinstance(o, str) and "success" in o.lower()
+ # 2026-05-06 ogt + Codex:
+ # incidents.outcome 已不是自動修復 source of truth。實際執行紀錄
+ # 寫在 auto_repair_executions;舊查詢會讓心跳報告顯示 0/15,
+ # 造成「全系統正常」但飛輪 KPI 失真的假象。
+ repair_result = await db.execute(
+ sa_text("""
+ SELECT
+ COUNT(*) FILTER (WHERE success IS TRUE) AS success,
+ COUNT(*) AS total
+ FROM auto_repair_executions
+ WHERE created_at >= NOW() - interval '24 hours'
+ """)
)
+ repair_row = repair_result.one()
+ stats.success_24h = int(repair_row.success or 0)
+ stats.attempt_24h = int(repair_row.total or 0)
# 最後學習活動
last_km = await db.scalar(
@@ -865,9 +867,10 @@ def report_to_telegram_html(report: HeartbeatReport) -> str:
lines.append("☸️ Kubernetes Pods")
for i, pod in enumerate(report.pods):
prefix = "└─" if i == len(report.pods) - 1 else "├─"
- ready_icon = "✅" if pod.ready else "❌"
+ ready_icon = "✅" if pod.ready or pod.status in ("Succeeded", "Completed") else "❌"
restart_str = f" (重啟×{pod.restarts})" if pod.restarts > 0 else ""
- lines.append(f"{prefix} {ready_icon} {html.escape(pod.name[:35])}{restart_str}")
+ status_str = "" if pod.ready else f" {html.escape(pod.status)}"
+ lines.append(f"{prefix} {ready_icon} {html.escape(pod.name[:35])}{restart_str}{status_str}")
# --- Scanner 狀態 ---
if report.scanners.last_runs:
diff --git a/apps/api/src/services/platform_operator_service.py b/apps/api/src/services/platform_operator_service.py
index f91c9031..b72a2d2f 100644
--- a/apps/api/src/services/platform_operator_service.py
+++ b/apps/api/src/services/platform_operator_service.py
@@ -9,8 +9,6 @@ ADR-106(AwoooP Agent Platform)
from __future__ import annotations
import uuid
-from datetime import datetime
-from decimal import Decimal
from typing import Any
from uuid import UUID
@@ -153,8 +151,21 @@ async def list_runs(
# Approvals
# =============================================================================
-async def list_approvals(project_id: str | None) -> dict[str, Any]:
- """列出所有 waiting_approval 狀態的 runs。"""
+async def list_approvals(
+ project_id: str | None,
+ run_id: str | None = None,
+) -> dict[str, Any]:
+ """列出 waiting_approval runs,可依 project_id 或 run_id 篩選。"""
+ run_uuid: UUID | None = None
+ if run_id:
+ try:
+ run_uuid = uuid.UUID(run_id)
+ except ValueError as exc:
+ raise HTTPException(
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+ detail=f"run_id 格式錯誤: {exc}",
+ ) from exc
+
async with get_db_context("awoooi") as db:
stmt = (
select(AwoooPRunState)
@@ -163,6 +174,8 @@ async def list_approvals(project_id: str | None) -> dict[str, Any]:
)
if project_id is not None:
stmt = stmt.where(AwoooPRunState.project_id == project_id)
+ if run_uuid is not None:
+ stmt = stmt.where(AwoooPRunState.run_id == run_uuid)
count_stmt = select(func.count()).select_from(stmt.subquery())
total_result = await db.execute(count_stmt)
diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json
index de008cc5..efc8fa39 100644
--- a/apps/web/messages/en.json
+++ b/apps/web/messages/en.json
@@ -67,7 +67,8 @@
"operations": "Operations",
"securityCompliance": "Security & Compliance",
"classicAICenter": "Classic AI Center",
- "governance": "AI Governance"
+ "governance": "AI Governance",
+ "awooop": "AwoooP"
},
"locale": {
"switch": "Switch Language",
@@ -1480,4 +1481,4 @@
"retry": "Retry"
}
}
-}
\ No newline at end of file
+}
diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json
index c3011b41..4e27c776 100644
--- a/apps/web/messages/zh-TW.json
+++ b/apps/web/messages/zh-TW.json
@@ -67,7 +67,8 @@
"operations": "營運",
"securityCompliance": "安全合規",
"classicAICenter": "經典 AI 中心",
- "governance": "AI 治理"
+ "governance": "AI 治理",
+ "awooop": "AwoooP"
},
"locale": {
"switch": "切換語系",
@@ -1481,4 +1482,4 @@
"retry": "重試"
}
}
-}
\ No newline at end of file
+}
diff --git a/apps/web/src/app/[locale]/awooop/approvals/page.tsx b/apps/web/src/app/[locale]/awooop/approvals/page.tsx
index 2755f72b..2508f7b8 100644
--- a/apps/web/src/app/[locale]/awooop/approvals/page.tsx
+++ b/apps/web/src/app/[locale]/awooop/approvals/page.tsx
@@ -114,7 +114,7 @@ function ApprovalRow({ approval }: { approval: Approval }) {
@@ -232,7 +232,7 @@ export default function ApprovalsPage() {
{/* Error State */}
{error && (
-
+
無法載入審批資料
@@ -243,7 +243,7 @@ export default function ApprovalsPage() {
{/* Empty State — 所有審批已處理 */}
{!loading && approvals.length === 0 && !error && (
-
+
審批佇列為空
目前沒有待審批的 Run
@@ -252,7 +252,7 @@ export default function ApprovalsPage() {
{/* Table */}
{(loading || approvals.length > 0) && (
-
+
diff --git a/apps/web/src/app/[locale]/awooop/contracts/page.tsx b/apps/web/src/app/[locale]/awooop/contracts/page.tsx
index 20528087..25960035 100644
--- a/apps/web/src/app/[locale]/awooop/contracts/page.tsx
+++ b/apps/web/src/app/[locale]/awooop/contracts/page.tsx
@@ -19,20 +19,25 @@ import { cn } from "@/lib/utils";
// Types
// =============================================================================
-type ContractStatus = "draft" | "published" | "active";
+type ContractStatus = "draft" | "published" | "active" | "revoked";
interface Contract {
- id: string;
+ contract_id: string;
contract_family: string;
project_id: string;
- status: ContractStatus;
+ lifecycle_status: ContractStatus;
body_hash: string;
created_at: string;
}
interface Tenant {
project_id: string;
- name: string;
+ display_name: string;
+}
+
+interface ContractsResponse {
+ contracts?: Contract[];
+ items?: Contract[];
}
// =============================================================================
@@ -47,21 +52,27 @@ const STATUS_CONFIG: Record<
> = {
draft: {
label: "草稿",
- bg: "bg-gray-800",
- text: "text-gray-300",
- border: "border-gray-600",
+ bg: "bg-[#f4f1e8]",
+ text: "text-[#5f5b52]",
+ border: "border-[#d8d3c7]",
},
published: {
label: "已發佈",
- bg: "bg-blue-900/40",
- text: "text-blue-300",
- border: "border-blue-600/40",
+ bg: "bg-[#eef5ff]",
+ text: "text-[#1f5b9b]",
+ border: "border-[#9bb6d9]",
},
active: {
label: "生效中",
- bg: "bg-green-900/40",
- text: "text-green-300",
- border: "border-green-600/40",
+ bg: "bg-[#f0faf2]",
+ text: "text-[#17602a]",
+ border: "border-[#9bc7a4]",
+ },
+ revoked: {
+ label: "已撤銷",
+ bg: "bg-[#fff0ef]",
+ text: "text-[#9f2f25]",
+ border: "border-[#e2a29b]",
},
};
@@ -109,7 +120,7 @@ function ContractRow({ contract }: { contract: Contract }) {
|
-
+
|
@@ -140,7 +151,10 @@ export default function ContractsPage() {
useEffect(() => {
fetch(`${API_BASE}/api/v1/platform/tenants`)
.then((r) => r.json())
- .then((data) => setTenants(Array.isArray(data.items) ? data.items : []))
+ .then((data) => {
+ const rows = Array.isArray(data.tenants) ? data.tenants : data.items;
+ setTenants(Array.isArray(rows) ? rows : []);
+ })
.catch(() => {});
}, []);
@@ -154,8 +168,9 @@ export default function ContractsPage() {
`${API_BASE}/api/v1/platform/contracts?${params.toString()}`
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
- const data = await res.json();
- setContracts(Array.isArray(data.items) ? data.items : []);
+ const data: ContractsResponse = await res.json();
+ const rows = Array.isArray(data.contracts) ? data.contracts : data.items;
+ setContracts(Array.isArray(rows) ? rows : []);
} catch (err) {
setError(err instanceof Error ? err.message : "載入失敗");
} finally {
@@ -192,7 +207,7 @@ export default function ContractsPage() {
{/* Filters */}
-
+
篩選:
@@ -205,7 +220,7 @@ export default function ContractsPage() {
{tenants.map((t) => (
))}
@@ -225,7 +240,7 @@ export default function ContractsPage() {
)}
{/* Table */}
-
+
@@ -269,7 +284,7 @@ export default function ContractsPage() {
) : (
contracts.map((contract) => (
-
+
))
)}
diff --git a/apps/web/src/app/[locale]/awooop/layout.tsx b/apps/web/src/app/[locale]/awooop/layout.tsx
index 261afe0a..cffff0f4 100644
--- a/apps/web/src/app/[locale]/awooop/layout.tsx
+++ b/apps/web/src/app/[locale]/awooop/layout.tsx
@@ -6,8 +6,9 @@
"use client";
+import { AppLayout } from "@/components/layout";
import { Link, usePathname } from "@/i18n/routing";
-import { Building2, FileText, Activity, ShieldCheck } from "lucide-react";
+import { Activity, BrainCircuit, Building2, ClipboardList, FileText, ShieldCheck } from "lucide-react";
import { cn } from "@/lib/utils";
// =============================================================================
@@ -15,6 +16,11 @@ import { cn } from "@/lib/utils";
// =============================================================================
const navItems = [
+ {
+ label: "工作鏈路",
+ href: "/awooop/work-items" as const,
+ icon: ClipboardList,
+ },
{
label: "租戶管理",
href: "/awooop/tenants" as const,
@@ -43,64 +49,72 @@ const navItems = [
export default function AwoooPLayout({
children,
+ params,
}: {
children: React.ReactNode;
+ params: { locale: string };
}) {
const pathname = usePathname();
return (
-
- {/* Console Header */}
-
-
-
-
- AwoooP Operator Console
-
-
- Agent 平台管理員後台 — 租戶 · 合約 · Run · 審批
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+ AwoooP Operator Console
+
+
+ Control Plane
+
+ Shadow First
+
+
+
+
+
OPERATOR
+
+
- {/* Tab Navigation */}
-
+ {children}
-
- {/* Page Content */}
-
- {children}
-
-
+
);
}
diff --git a/apps/web/src/app/[locale]/awooop/page.tsx b/apps/web/src/app/[locale]/awooop/page.tsx
index a22bf99d..b071d814 100644
--- a/apps/web/src/app/[locale]/awooop/page.tsx
+++ b/apps/web/src/app/[locale]/awooop/page.tsx
@@ -2,8 +2,12 @@
// WOOO AIOps - AwoooP Console 入口重導向
// =============================================================================
-import { redirect } from "@/i18n/routing";
+import { redirect } from "next/navigation";
-export default function AwoooPPage() {
- redirect("/awooop/tenants");
+export default function AwoooPPage({
+ params,
+}: {
+ params: { locale: string };
+}) {
+ redirect(`/${params.locale}/awooop/work-items`);
}
diff --git a/apps/web/src/app/[locale]/awooop/runs/page.tsx b/apps/web/src/app/[locale]/awooop/runs/page.tsx
index 3d18cbc7..5ee8b5b1 100644
--- a/apps/web/src/app/[locale]/awooop/runs/page.tsx
+++ b/apps/web/src/app/[locale]/awooop/runs/page.tsx
@@ -23,16 +23,14 @@ import { cn } from "@/lib/utils";
// =============================================================================
type RunState =
- | "CREATED"
- | "QUEUED"
- | "POLICY_RESOLVED"
- | "RUNNING"
- | "WAITING_TOOL"
- | "WAITING_APPROVAL"
- | "RESUMED"
- | "COMPLETED"
- | "FAILED"
- | "CANCELLED";
+ | "pending"
+ | "running"
+ | "waiting_tool"
+ | "waiting_approval"
+ | "completed"
+ | "failed"
+ | "cancelled"
+ | "timeout";
interface Run {
run_id: string;
@@ -40,18 +38,19 @@ interface Run {
agent_id: string;
state: RunState;
is_shadow: boolean;
- token_usage_input: number | null;
- token_usage_output: number | null;
+ cost_usd: number | string;
+ step_count: number;
created_at: string;
}
interface Tenant {
project_id: string;
- name: string;
+ display_name: string;
}
interface RunsResponse {
- items: Run[];
+ runs?: Run[];
+ items?: Run[];
total: number;
page: number;
per_page: number;
@@ -69,66 +68,54 @@ const STATE_CONFIG: Record<
RunState,
{ label: string; bg: string; text: string; border: string; pulse?: boolean }
> = {
- CREATED: {
- label: "已建立",
- bg: "bg-gray-800",
- text: "text-gray-300",
- border: "border-gray-600",
+ pending: {
+ label: "待執行",
+ bg: "bg-[#f4f1e8]",
+ text: "text-[#5f5b52]",
+ border: "border-[#d8d3c7]",
},
- QUEUED: {
- label: "排隊中",
- bg: "bg-gray-800",
- text: "text-gray-400",
- border: "border-gray-600",
- },
- POLICY_RESOLVED: {
- label: "策略已解析",
- bg: "bg-blue-900/40",
- text: "text-blue-300",
- border: "border-blue-600/40",
- },
- RUNNING: {
+ running: {
label: "執行中",
- bg: "bg-green-900/40",
- text: "text-green-300",
- border: "border-green-600/40",
+ bg: "bg-[#f0faf2]",
+ text: "text-[#17602a]",
+ border: "border-[#9bc7a4]",
pulse: true,
},
- WAITING_TOOL: {
+ waiting_tool: {
label: "等待工具",
- bg: "bg-yellow-900/40",
- text: "text-yellow-300",
- border: "border-yellow-600/40",
+ bg: "bg-[#fff7e8]",
+ text: "text-[#8a5a08]",
+ border: "border-[#d9b36f]",
},
- WAITING_APPROVAL: {
+ waiting_approval: {
label: "等待審批",
- bg: "bg-yellow-900/40",
- text: "text-yellow-300",
- border: "border-yellow-600/40",
+ bg: "bg-[#fff7e8]",
+ text: "text-[#8a5a08]",
+ border: "border-[#d9b36f]",
},
- RESUMED: {
- label: "已恢復",
- bg: "bg-purple-900/40",
- text: "text-purple-300",
- border: "border-purple-600/40",
- },
- COMPLETED: {
+ completed: {
label: "已完成",
- bg: "bg-green-900/40",
- text: "text-green-400",
- border: "border-green-600/40",
+ bg: "bg-[#f0faf2]",
+ text: "text-[#17602a]",
+ border: "border-[#9bc7a4]",
},
- FAILED: {
+ failed: {
label: "失敗",
- bg: "bg-red-900/40",
- text: "text-red-300",
- border: "border-red-600/40",
+ bg: "bg-[#fff0ef]",
+ text: "text-[#9f2f25]",
+ border: "border-[#e2a29b]",
},
- CANCELLED: {
+ cancelled: {
label: "已取消",
- bg: "bg-red-900/30",
- text: "text-red-400",
- border: "border-red-700/40",
+ bg: "bg-[#fff0ef]",
+ text: "text-[#9f2f25]",
+ border: "border-[#e2a29b]",
+ },
+ timeout: {
+ label: "已超時",
+ bg: "bg-[#fff0ef]",
+ text: "text-[#9f2f25]",
+ border: "border-[#e2a29b]",
},
};
@@ -137,7 +124,7 @@ const STATE_CONFIG: Record<
// =============================================================================
function RunStateBadge({ state }: { state: RunState }) {
- const config = STATE_CONFIG[state] ?? STATE_CONFIG.CREATED;
+ const config = STATE_CONFIG[state] ?? STATE_CONFIG.pending;
return (
--;
return (
-
+
Shadow
);
@@ -174,8 +161,7 @@ function RunRow({ run }: { run: Run }) {
})
: "--";
- const totalTokens =
- (run.token_usage_input ?? 0) + (run.token_usage_output ?? 0);
+ const cost = Number(run.cost_usd ?? 0);
return (
@@ -202,12 +188,12 @@ function RunRow({ run }: { run: Run }) {
- {totalTokens > 0 ? (
+ {run.step_count > 0 || cost > 0 ? (
<>
- {totalTokens.toLocaleString()}
+ {run.step_count.toLocaleString()} steps
- ({run.token_usage_input ?? 0}↑ {run.token_usage_output ?? 0}↓)
+ (${cost.toFixed(4)})
>
) : (
@@ -244,7 +230,10 @@ export default function RunsPage() {
useEffect(() => {
fetch(`${API_BASE}/api/v1/platform/tenants`)
.then((r) => r.json())
- .then((data) => setTenants(Array.isArray(data.items) ? data.items : []))
+ .then((data) => {
+ const rows = Array.isArray(data.tenants) ? data.tenants : data.items;
+ setTenants(Array.isArray(rows) ? rows : []);
+ })
.catch(() => {});
}, []);
@@ -253,7 +242,7 @@ export default function RunsPage() {
setError(null);
const params = new URLSearchParams();
if (projectFilter) params.set("project_id", projectFilter);
- if (statusFilter) params.set("status", statusFilter);
+ if (statusFilter) params.set("state", statusFilter);
params.set("page", String(page));
params.set("per_page", String(PER_PAGE));
@@ -262,7 +251,8 @@ export default function RunsPage() {
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data: RunsResponse = await res.json();
- setRuns(Array.isArray(data.items) ? data.items : []);
+ const rows = Array.isArray(data.runs) ? data.runs : data.items;
+ setRuns(Array.isArray(rows) ? rows : []);
setTotal(data.total ?? 0);
setLastRefresh(new Date());
} catch (err) {
@@ -320,7 +310,7 @@ export default function RunsPage() {
{/* Filters */}
-
+
篩選:
@@ -335,7 +325,7 @@ export default function RunsPage() {
{tenants.map((t) => (
))}
@@ -373,7 +363,7 @@ export default function RunsPage() {
)}
{/* Table */}
-
+
@@ -394,7 +384,7 @@ export default function RunsPage() {
Shadow
|
- Token 用量
+ 成本 / Steps
|
建立時間
diff --git a/apps/web/src/app/[locale]/awooop/tenants/page.tsx b/apps/web/src/app/[locale]/awooop/tenants/page.tsx
index 24232e62..509f7fa9 100644
--- a/apps/web/src/app/[locale]/awooop/tenants/page.tsx
+++ b/apps/web/src/app/[locale]/awooop/tenants/page.tsx
@@ -21,6 +21,7 @@ import { cn } from "@/lib/utils";
// =============================================================================
type MigrationMode =
+ | "legacy_awoooi_default"
| "shadow"
| "canary"
| "read_only"
@@ -29,14 +30,15 @@ type MigrationMode =
interface Tenant {
project_id: string;
- name: string;
+ display_name: string;
migration_mode: MigrationMode;
- budget_limit_usd: number | null;
- is_suspended: boolean;
+ budget_limit_usd: number | string | null;
+ is_active: boolean;
}
interface ApiResponse {
- items: Tenant[];
+ tenants?: Tenant[];
+ items?: Tenant[];
total: number;
}
@@ -50,35 +52,41 @@ const MIGRATION_MODE_CONFIG: Record<
MigrationMode,
{ label: string; bg: string; text: string; border: string }
> = {
+ legacy_awoooi_default: {
+ label: "Legacy",
+ bg: "bg-white",
+ text: "text-[#5f5b52]",
+ border: "border-[#d8d3c7]",
+ },
shadow: {
label: "Shadow",
- bg: "bg-gray-800",
- text: "text-gray-300",
- border: "border-gray-600",
+ bg: "bg-[#f4f1e8]",
+ text: "text-[#5f5b52]",
+ border: "border-[#d8d3c7]",
},
canary: {
label: "Canary",
- bg: "bg-yellow-900/40",
- text: "text-yellow-300",
- border: "border-yellow-600/40",
+ bg: "bg-[#fff7e8]",
+ text: "text-[#8a5a08]",
+ border: "border-[#d9b36f]",
},
read_only: {
label: "Read Only",
- bg: "bg-blue-900/40",
- text: "text-blue-300",
- border: "border-blue-600/40",
+ bg: "bg-[#eef5ff]",
+ text: "text-[#1f5b9b]",
+ border: "border-[#9bb6d9]",
},
suggest: {
label: "Suggest",
- bg: "bg-purple-900/40",
- text: "text-purple-300",
- border: "border-purple-600/40",
+ bg: "bg-[#f6f0ff]",
+ text: "text-[#6541a5]",
+ border: "border-[#baa7de]",
},
auto_remediate: {
label: "Auto Remediate",
- bg: "bg-green-900/40",
- text: "text-green-300",
- border: "border-green-600/40",
+ bg: "bg-[#f0faf2]",
+ text: "text-[#17602a]",
+ border: "border-[#9bc7a4]",
},
};
@@ -104,12 +112,12 @@ function MigrationModeBadge({ mode }: { mode: MigrationMode }) {
function SuspendedBadge({ suspended }: { suspended: boolean }) {
return suspended ? (
-
+
停用
) : (
-
+
正常
@@ -117,6 +125,9 @@ function SuspendedBadge({ suspended }: { suspended: boolean }) {
}
function TenantRow({ tenant }: { tenant: Tenant }) {
+ const budget =
+ tenant.budget_limit_usd == null ? null : Number(tenant.budget_limit_usd);
+
return (
|
@@ -125,7 +136,7 @@ function TenantRow({ tenant }: { tenant: Tenant }) {
|
- {tenant.name || "--"}
+ {tenant.display_name || "--"}
|
@@ -135,7 +146,7 @@ function TenantRow({ tenant }: { tenant: Tenant }) {
{tenant.budget_limit_usd != null ? (
<>
- {tenant.budget_limit_usd.toLocaleString("en-US", {
+ {budget?.toLocaleString("en-US", {
minimumFractionDigits: 2,
})}
>
@@ -145,7 +156,7 @@ function TenantRow({ tenant }: { tenant: Tenant }) {
|
-
+
|
);
@@ -166,7 +177,8 @@ export default function TenantsPage() {
const res = await fetch(`${API_BASE}/api/v1/platform/tenants`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data: ApiResponse = await res.json();
- setTenants(Array.isArray(data.items) ? data.items : []);
+ const rows = Array.isArray(data.tenants) ? data.tenants : data.items;
+ setTenants(Array.isArray(rows) ? rows : []);
} catch (err) {
setError(err instanceof Error ? err.message : "載入失敗");
} finally {
@@ -214,7 +226,7 @@ export default function TenantsPage() {
)}
{/* Table */}
-
+
diff --git a/apps/web/src/app/[locale]/awooop/work-items/page.tsx b/apps/web/src/app/[locale]/awooop/work-items/page.tsx
new file mode 100644
index 00000000..25e88aef
--- /dev/null
+++ b/apps/web/src/app/[locale]/awooop/work-items/page.tsx
@@ -0,0 +1,254 @@
+// =============================================================================
+// WOOO AIOps - AwoooP 工作鏈路
+// =============================================================================
+// 將 AwoooP 實施項目對齊到 Operator Console 可觀測面。
+
+"use client";
+
+import {
+ Activity,
+ ArrowRight,
+ ClipboardList,
+ Database,
+ Gauge,
+ GitBranch,
+ Network,
+ ShieldCheck,
+} from "lucide-react";
+import { Link } from "@/i18n/routing";
+import { cn } from "@/lib/utils";
+
+type WorkStatus = "live" | "in_progress" | "blocked" | "watching";
+
+type WorkItem = {
+ phase: string;
+ title: string;
+ status: WorkStatus;
+ surface: string;
+ source: string;
+ gate: string;
+ href: "/awooop/tenants" | "/awooop/contracts" | "/awooop/runs" | "/awooop/approvals";
+};
+
+const statusConfig: Record = {
+ live: {
+ label: "已接線",
+ className: "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]",
+ },
+ in_progress: {
+ label: "推進中",
+ className: "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]",
+ },
+ blocked: {
+ label: "阻塞",
+ className: "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]",
+ },
+ watching: {
+ label: "觀察期",
+ className: "border-[#9bb6d9] bg-[#eef5ff] text-[#1f5b9b]",
+ },
+};
+
+const workItems: WorkItem[] = [
+ {
+ phase: "P0",
+ title: "AI 路由以 GCP-A/GCP-B/111 Ollama 優先",
+ status: "live",
+ surface: "Run 監控",
+ source: "ai_routing_decision / ollama_failover_decision",
+ gate: "Gemini 僅能作為 fallback",
+ href: "/awooop/runs",
+ },
+ {
+ phase: "P0",
+ title: "飛輪 KPI 改讀 auto_repair_executions",
+ status: "in_progress",
+ surface: "工作鏈路 / 系統報告",
+ source: "auto_repair_executions",
+ gate: "修復率不得再讀 incidents.outcome",
+ href: "/awooop/runs",
+ },
+ {
+ phase: "P0",
+ title: "審批與 Run State 對齊",
+ status: "live",
+ surface: "審批佇列",
+ source: "awooop_run_state",
+ gate: "waiting_approval 才能 decide",
+ href: "/awooop/approvals",
+ },
+ {
+ phase: "P1",
+ title: "Contract Lifecycle",
+ status: "watching",
+ surface: "合約儀表板",
+ source: "awooop_contract_revisions",
+ gate: "draft → published → active",
+ href: "/awooop/contracts",
+ },
+ {
+ phase: "P1",
+ title: "Tenant Migration State",
+ status: "watching",
+ surface: "租戶管理",
+ source: "awooop_projects",
+ gate: "shadow gate 需量化",
+ href: "/awooop/tenants",
+ },
+ {
+ phase: "P1",
+ title: "MCP Gateway 與 Context Firewall",
+ status: "watching",
+ surface: "Run 監控",
+ source: "mcp_gateway audit / redaction",
+ gate: "tool call 必須帶 project_id",
+ href: "/awooop/runs",
+ },
+ {
+ phase: "P2",
+ title: "Communication Hub",
+ status: "watching",
+ surface: "Run 監控 / 審批佇列",
+ source: "conversation_event / outbound_message",
+ gate: "Telegram 先 mirror 再切流",
+ href: "/awooop/runs",
+ },
+ {
+ phase: "P2",
+ title: "Operator Console 正式接入主站",
+ status: "in_progress",
+ surface: "AwoooP",
+ source: "apps/web/src/app/[locale]/awooop",
+ gate: "/zh-TW/awooop 不得再 redirect 異常",
+ href: "/awooop/tenants",
+ },
+];
+
+function StatusBadge({ status }: { status: WorkStatus }) {
+ const config = statusConfig[status];
+ return (
+
+ {config.label}
+
+ );
+}
+
+const summary = [
+ { label: "Live", value: workItems.filter((i) => i.status === "live").length, icon: Activity },
+ { label: "In Progress", value: workItems.filter((i) => i.status === "in_progress").length, icon: GitBranch },
+ { label: "Watching", value: workItems.filter((i) => i.status === "watching").length, icon: Gauge },
+ { label: "Blocked", value: workItems.filter((i) => i.status === "blocked").length, icon: ShieldCheck },
+];
+
+export default function AwoooPWorkItemsPage() {
+ return (
+
+
+
+
+
+
+ 工作鏈路
+
+
+ {workItems.length} 個控制點
+
+
+
+
+
+
+ {summary.map((item) => {
+ const Icon = item.icon;
+ return (
+
+
+ {item.label}
+
+
+
+ {item.value}
+
+
+ );
+ })}
+
+
+
+
+
+
+
+ |
+ Phase
+ |
+
+ 工作項目
+ |
+
+ 狀態
+ |
+
+ 操作面
+ |
+
+ Source
+ |
+
+ Gate
+ |
+
+ Link
+ |
+
+
+
+ {workItems.map((item) => (
+
+ |
+ {item.phase}
+ |
+
+ {item.title}
+ |
+
+
+ |
+
+ {item.surface}
+ |
+
+
+
+ {item.source}
+
+ |
+
+
+
+ {item.gate}
+
+ |
+
+
+ 開啟
+
+
+ |
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/layout/sidebar.tsx b/apps/web/src/components/layout/sidebar.tsx
index dbb2632d..1939f56a 100644
--- a/apps/web/src/components/layout/sidebar.tsx
+++ b/apps/web/src/components/layout/sidebar.tsx
@@ -87,6 +87,7 @@ const NAV_SECTIONS: NavSection[] = [
{ id: 'security-compliance', href: '/security-compliance', labelKey: 'securityCompliance',Icon: Shield },
{ id: 'knowledge', href: '/knowledge', labelKey: 'knowledge', Icon: BookOpen },
{ id: 'governance', href: '/governance', labelKey: 'governance', Icon: ShieldCheck },
+ { id: 'awooop', href: '/awooop', labelKey: 'awooop', Icon: BrainCircuit },
],
},
{
diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md
index 4dbb971a..db69a708 100644
--- a/docs/LOGBOOK.md
+++ b/docs/LOGBOOK.md
@@ -3338,3 +3338,53 @@ bash scripts/reboot-recovery/full-stack-cold-start-check.sh --watch --interval 1
- `WARN`:不可釋放 runner/CD/AI full execution,需清掉或明確接受警告。
- `GREEN`:只代表可進入下一階段;高負載 crawler / Snuba / ClickHouse merge / runner/CD 仍需最後釋放。
- Stateful DB / ClickHouse / Kafka / Harbor / Sentry 資料層不可由 AI 自動破壞性修復。
+
+---
+
+## 2026-05-06(台北)— AwoooP Operator Console 與飛輪 KPI 對齊
+
+**觸發**:00:30 系統報告顯示「全系統正常」,但飛輪狀態為 `修復 0/15 (0%)`,使用者指出 AI 自動化幾乎沒有做;同步要求 AwoooP 工作項目必須與前端頁面、邏輯、操作面對齊。
+
+### 已修正
+
+| 範圍 | 結果 |
+|------|------|
+| 心跳報告 | `HeartbeatReportService._get_flywheel_stats()` 改讀 `auto_repair_executions`,不再用已失準的 `incidents.outcome` 推估修復率 |
+| 飛輪 Prometheus KPI | `FlywheelStatsService._playbook_stats()` 優先以 `auto_repair_executions` 計算 24h execution success rate,Redis playbook counter 僅作 fallback |
+| AI Success | `MetricsDBRepository` 改用 `UPPER(status::text)` 對齊實際 `APPROVED / EXECUTION_SUCCESS / EXECUTION_FAILED` 狀態值 |
+| Auto-repair metric | `AutoRepairService.execute_auto_repair()` 成功/失敗都呼叫 `record_auto_repair()`,修正 Prometheus 指標零 caller 問題 |
+| K8s Pod 報告 | Completed/Succeeded CronJob pod 不再顯示為紅色失敗;Telegram 報告會顯示 phase |
+| AwoooP 前端 | `/zh-TW/awooop` redirect 修正,Console 接入主 `AppLayout` 與 sidebar;新增 `工作鏈路` 頁映射 P0/P1/P2 工作項目、source of truth、gate 與操作面 |
+| AwoooP API | `GET /api/v1/platform/approvals?run_id=` 支援 M8 詳情頁查單筆 waiting approval |
+
+### 驗證
+
+```bash
+DATABASE_URL='postgresql+asyncpg://test:test@localhost:5432/test' \
+ apps/api/.venv/bin/python -m py_compile \
+ apps/api/src/repositories/metrics_repository.py \
+ apps/api/src/services/heartbeat_report_service.py \
+ apps/api/src/services/auto_repair_service.py \
+ apps/api/src/services/flywheel_stats_service.py \
+ apps/api/src/api/v1/platform/operator_runs.py \
+ apps/api/src/services/platform_operator_service.py
+
+DATABASE_URL='postgresql+asyncpg://test:test@localhost:5432/test' \
+ apps/api/.venv/bin/python -m ruff check --select E9,F401,F821 \
+ apps/api/src/repositories/metrics_repository.py \
+ apps/api/src/services/heartbeat_report_service.py \
+ apps/api/src/services/auto_repair_service.py \
+ apps/api/src/services/flywheel_stats_service.py \
+ apps/api/src/api/v1/platform/operator_runs.py \
+ apps/api/src/services/platform_operator_service.py
+# All checks passed!
+
+pnpm --filter @awoooi/web typecheck
+# tsc --noEmit passed
+```
+
+### 後續
+
+- 仍需處理 `approval_records.matched_playbook_id = NULL` 問題,否則執行結果無法完整回寫 Playbook trust。
+- 仍需攔截 AI action hallucination(alertname 被當 deployment/host、namespace 亂填)進入 approval 前的路徑。
+- AwoooP Console 下一步應接入真實 run step journal / trace view,而不是只列 run state。
| | | |