fix(awooop): align console with flywheel execution metrics
Some checks failed
Code Review / ai-code-review (push) Has been cancelled
CD Pipeline / tests (push) Successful in 2m22s
CD Pipeline / build-and-deploy (push) Successful in 3m54s
CD Pipeline / post-deploy-checks (push) Successful in 1m17s

This commit is contained in:
Your Name
2026-05-06 00:44:53 +08:00
parent 20ef0c1455
commit a2c4b3d47e
17 changed files with 629 additions and 208 deletions

View File

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

View File

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

View File

@@ -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 + CodexDB 已有 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 類修復。"""

View File

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

View File

@@ -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("☸️ <b>Kubernetes Pods</b>")
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" <code>{html.escape(pod.status)}</code>"
lines.append(f"{prefix} {ready_icon} {html.escape(pod.name[:35])}{restart_str}{status_str}")
# --- Scanner 狀態 ---
if report.scanners.last_runs:

View File

@@ -9,8 +9,6 @@ ADR-106AwoooP 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)

View File

@@ -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"
}
}
}
}

View File

@@ -67,7 +67,8 @@
"operations": "營運",
"securityCompliance": "安全合規",
"classicAICenter": "經典 AI 中心",
"governance": "AI 治理"
"governance": "AI 治理",
"awooop": "AwoooP"
},
"locale": {
"switch": "切換語系",
@@ -1481,4 +1482,4 @@
"retry": "重試"
}
}
}
}

View File

@@ -114,7 +114,7 @@ function ApprovalRow({ approval }: { approval: Approval }) {
<tr
className={cn(
"border-b border-border hover:bg-accent/30 transition-colors",
isCritical && "bg-red-900/10 hover:bg-red-900/20"
isCritical && "bg-[#fff0ef] hover:bg-[#ffe4e1]"
)}
>
<td className="px-4 py-3">
@@ -232,7 +232,7 @@ export default function ApprovalsPage() {
{/* Error State */}
{error && (
<div className="flex items-start gap-3 p-4 bg-red-900/20 border border-red-800/40 rounded-lg">
<div className="flex items-start gap-3 border border-[#e2a29b] bg-[#fff0ef] p-4">
<AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" aria-hidden="true" />
<div>
<p className="text-sm font-medium text-red-300"></p>
@@ -243,7 +243,7 @@ export default function ApprovalsPage() {
{/* Empty State — 所有審批已處理 */}
{!loading && approvals.length === 0 && !error && (
<div className="flex flex-col items-center justify-center py-16 bg-card border border-border rounded-xl">
<div className="flex flex-col items-center justify-center border border-[#e0ddd4] bg-white py-16">
<ShieldCheck className="w-12 h-12 text-green-400 mb-3" aria-hidden="true" />
<p className="text-sm font-medium text-foreground mb-1"></p>
<p className="text-xs text-muted-foreground"> Run</p>
@@ -252,7 +252,7 @@ export default function ApprovalsPage() {
{/* Table */}
{(loading || approvals.length > 0) && (
<div className="bg-card border border-border rounded-xl overflow-hidden shadow-sm">
<div className="overflow-hidden border border-[#e0ddd4] bg-white shadow-[0_1px_4px_rgba(0,0,0,0.05)]">
<div className="overflow-x-auto">
<table className="w-full" role="table" aria-label="審批佇列">
<thead>

View File

@@ -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 }) {
</span>
</td>
<td className="px-4 py-3">
<StatusBadge status={contract.status} />
<StatusBadge status={contract.lifecycle_status} />
</td>
<td className="px-4 py-3">
<span className="font-mono text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded">
@@ -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() {
</div>
{/* Filters */}
<div className="flex items-center gap-3 p-4 bg-card border border-border rounded-xl">
<div className="flex items-center gap-3 border border-[#e0ddd4] bg-white p-4">
<Filter className="w-4 h-4 text-muted-foreground flex-shrink-0" aria-hidden="true" />
<span className="text-sm text-muted-foreground"></span>
<div className="relative">
@@ -205,7 +220,7 @@ export default function ContractsPage() {
<option value=""></option>
{tenants.map((t) => (
<option key={t.project_id} value={t.project_id}>
{t.name || t.project_id}
{t.display_name || t.project_id}
</option>
))}
</select>
@@ -225,7 +240,7 @@ export default function ContractsPage() {
)}
{/* Table */}
<div className="bg-card border border-border rounded-xl overflow-hidden shadow-sm">
<div className="overflow-hidden border border-[#e0ddd4] bg-white shadow-[0_1px_4px_rgba(0,0,0,0.05)]">
<div className="overflow-x-auto">
<table className="w-full" role="table" aria-label="合約清單">
<thead>
@@ -269,7 +284,7 @@ export default function ContractsPage() {
</tr>
) : (
contracts.map((contract) => (
<ContractRow key={contract.id} contract={contract} />
<ContractRow key={contract.contract_id + contract.body_hash} contract={contract} />
))
)}
</tbody>

View File

@@ -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 (
<div className="min-h-full flex flex-col">
{/* Console Header */}
<div className="bg-card border-b border-border px-6 py-4">
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-xl font-bold text-foreground tracking-tight">
AwoooP Operator Console
</h1>
<p className="text-xs text-muted-foreground mt-0.5">
Agent · · Run ·
</p>
</div>
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-brand-accent/10 text-brand-accent border border-brand-accent/20">
<span className="w-1.5 h-1.5 rounded-full bg-brand-accent animate-pulse" />
<AppLayout locale={params.locale} showBackground={false}>
<div className="min-h-[calc(100vh-116px)] bg-[#f7f5ee] border border-[#e0ddd4]">
<div className="border-b border-[#e0ddd4] bg-[#faf9f3] px-5 py-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<span className="flex h-9 w-9 items-center justify-center border border-[#d8d3c7] bg-white text-[#141413]">
<BrainCircuit className="h-4 w-4" aria-hidden="true" />
</span>
<div>
<h1 className="text-lg font-semibold tracking-normal text-[#141413]">
AwoooP Operator Console
</h1>
<div className="mt-1 flex items-center gap-2 text-xs text-[#77736a]">
<span className="font-mono">Control Plane</span>
<span className="h-1 w-1 rounded-full bg-[#d97757]" />
<span className="font-mono">Shadow First</span>
</div>
</div>
</div>
<span className="inline-flex items-center gap-2 border border-[#d8d3c7] bg-white px-3 py-1.5 text-xs font-semibold text-[#141413]">
<span className="h-1.5 w-1.5 rounded-full bg-[#22c55e]" />
OPERATOR
</span>
</div>
<nav
className="mt-4 flex flex-wrap gap-1"
role="navigation"
aria-label="AwoooP 主要導航"
>
{navItems.map((item) => {
const Icon = item.icon;
const isActive =
pathname === item.href ||
pathname?.startsWith(item.href + "/");
return (
<Link
key={item.href}
href={item.href}
aria-current={isActive ? "page" : undefined}
className={cn(
"inline-flex items-center gap-2 border px-3 py-2 text-sm font-medium transition-colors",
isActive
? "border-[#d97757] bg-white text-[#141413]"
: "border-transparent text-[#77736a] hover:border-[#d8d3c7] hover:bg-white hover:text-[#141413]"
)}
>
<Icon className="h-4 w-4" aria-hidden="true" />
{item.label}
</Link>
);
})}
</nav>
</div>
{/* Tab Navigation */}
<nav className="flex gap-1" role="navigation" aria-label="AwoooP 主要導航">
{navItems.map((item) => {
const Icon = item.icon;
const isActive =
pathname === item.href ||
pathname?.startsWith(item.href + "/");
return (
<Link
key={item.href}
href={item.href}
aria-current={isActive ? "page" : undefined}
className={cn(
"flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-150",
isActive
? "bg-brand-accent/15 text-brand-accent border border-brand-accent/30"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
)}
>
<Icon className="w-4 h-4" aria-hidden="true" />
{item.label}
</Link>
);
})}
</nav>
<main className="px-5 py-5">{children}</main>
</div>
{/* Page Content */}
<main className="flex-1 px-6 py-6">
{children}
</main>
</div>
</AppLayout>
);
}

View File

@@ -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`);
}

View File

@@ -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 (
<span
className={cn(
@@ -158,7 +145,7 @@ function RunStateBadge({ state }: { state: RunState }) {
function ShadowBadge({ isShadow }: { isShadow: boolean }) {
if (!isShadow) return <span className="text-muted-foreground text-sm">--</span>;
return (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-800 text-gray-400 border border-gray-600">
<span className="inline-flex items-center border border-[#d8d3c7] bg-white px-2 py-0.5 text-xs font-medium text-[#5f5b52]">
Shadow
</span>
);
@@ -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 (
<tr className="border-b border-border hover:bg-accent/30 transition-colors">
@@ -202,12 +188,12 @@ function RunRow({ run }: { run: Run }) {
</td>
<td className="px-4 py-3">
<span className="flex items-center gap-1 text-sm font-mono text-muted-foreground">
{totalTokens > 0 ? (
{run.step_count > 0 || cost > 0 ? (
<>
<Cpu className="w-3.5 h-3.5" aria-hidden="true" />
{totalTokens.toLocaleString()}
{run.step_count.toLocaleString()} steps
<span className="text-xs text-muted-foreground/60">
({run.token_usage_input ?? 0} {run.token_usage_output ?? 0})
(${cost.toFixed(4)})
</span>
</>
) : (
@@ -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() {
</div>
{/* Filters */}
<div className="flex items-center gap-3 p-4 bg-card border border-border rounded-xl flex-wrap">
<div className="flex flex-wrap items-center gap-3 border border-[#e0ddd4] bg-white p-4">
<Filter className="w-4 h-4 text-muted-foreground flex-shrink-0" aria-hidden="true" />
<span className="text-sm text-muted-foreground"></span>
@@ -335,7 +325,7 @@ export default function RunsPage() {
<option value=""></option>
{tenants.map((t) => (
<option key={t.project_id} value={t.project_id}>
{t.name || t.project_id}
{t.display_name || t.project_id}
</option>
))}
</select>
@@ -373,7 +363,7 @@ export default function RunsPage() {
)}
{/* Table */}
<div className="bg-card border border-border rounded-xl overflow-hidden shadow-sm">
<div className="overflow-hidden border border-[#e0ddd4] bg-white shadow-[0_1px_4px_rgba(0,0,0,0.05)]">
<div className="overflow-x-auto">
<table className="w-full" role="table" aria-label="Run 清單">
<thead>
@@ -394,7 +384,7 @@ export default function RunsPage() {
Shadow
</th>
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Token
/ Steps
</th>
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">

View File

@@ -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 ? (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-md text-xs font-medium bg-red-900/40 text-red-300 border border-red-600/40">
<span className="inline-flex items-center gap-1 border border-[#e2a29b] bg-[#fff0ef] px-2.5 py-0.5 text-xs font-medium text-[#9f2f25]">
<Ban className="w-3 h-3" aria-hidden="true" />
</span>
) : (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-md text-xs font-medium bg-green-900/40 text-green-300 border border-green-600/40">
<span className="inline-flex items-center gap-1 border border-[#9bc7a4] bg-[#f0faf2] px-2.5 py-0.5 text-xs font-medium text-[#17602a]">
<CheckCircle2 className="w-3 h-3" aria-hidden="true" />
</span>
@@ -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 (
<tr className="border-b border-border hover:bg-accent/30 transition-colors">
<td className="px-4 py-3">
@@ -125,7 +136,7 @@ function TenantRow({ tenant }: { tenant: Tenant }) {
</span>
</td>
<td className="px-4 py-3">
<span className="text-sm text-foreground font-medium">{tenant.name || "--"}</span>
<span className="text-sm text-foreground font-medium">{tenant.display_name || "--"}</span>
</td>
<td className="px-4 py-3">
<MigrationModeBadge mode={tenant.migration_mode} />
@@ -135,7 +146,7 @@ function TenantRow({ tenant }: { tenant: Tenant }) {
{tenant.budget_limit_usd != null ? (
<>
<DollarSign className="w-3.5 h-3.5" aria-hidden="true" />
{tenant.budget_limit_usd.toLocaleString("en-US", {
{budget?.toLocaleString("en-US", {
minimumFractionDigits: 2,
})}
</>
@@ -145,7 +156,7 @@ function TenantRow({ tenant }: { tenant: Tenant }) {
</span>
</td>
<td className="px-4 py-3">
<SuspendedBadge suspended={tenant.is_suspended} />
<SuspendedBadge suspended={!tenant.is_active} />
</td>
</tr>
);
@@ -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 */}
<div className="bg-card border border-border rounded-xl overflow-hidden shadow-sm">
<div className="overflow-hidden border border-[#e0ddd4] bg-white shadow-[0_1px_4px_rgba(0,0,0,0.05)]">
<div className="overflow-x-auto">
<table className="w-full" role="table" aria-label="租戶清單">
<thead>

View File

@@ -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<WorkStatus, { label: string; className: string }> = {
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 (
<span
className={cn(
"inline-flex items-center border px-2 py-0.5 text-xs font-semibold",
config.className
)}
>
{config.label}
</span>
);
}
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 (
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<ClipboardList className="h-5 w-5 text-[#d97757]" aria-hidden="true" />
<div>
<h2 className="text-lg font-semibold tracking-normal text-[#141413]">
</h2>
<p className="text-xs text-[#77736a]">
{workItems.length}
</p>
</div>
</div>
</div>
<div className="grid gap-px border border-[#e0ddd4] bg-[#e0ddd4] md:grid-cols-4">
{summary.map((item) => {
const Icon = item.icon;
return (
<div key={item.label} className="bg-white px-4 py-3">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-[#77736a]">{item.label}</span>
<Icon className="h-4 w-4 text-[#87867f]" aria-hidden="true" />
</div>
<div className="mt-2 font-mono text-2xl font-semibold text-[#141413]">
{item.value}
</div>
</div>
);
})}
</div>
<div className="overflow-hidden border border-[#e0ddd4] bg-white">
<div className="overflow-x-auto">
<table className="w-full" role="table" aria-label="AwoooP 工作鏈路">
<thead>
<tr className="border-b border-[#e0ddd4] bg-[#faf9f3]">
<th className="px-4 py-3 text-left text-xs font-semibold text-[#77736a]">
Phase
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-[#77736a]">
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-[#77736a]">
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-[#77736a]">
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-[#77736a]">
Source
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-[#77736a]">
Gate
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-[#77736a]">
Link
</th>
</tr>
</thead>
<tbody>
{workItems.map((item) => (
<tr key={`${item.phase}-${item.title}`} className="border-b border-[#eee9dd] last:border-b-0">
<td className="px-4 py-3 font-mono text-xs font-semibold text-[#141413]">
{item.phase}
</td>
<td className="px-4 py-3 text-sm font-medium text-[#141413]">
{item.title}
</td>
<td className="px-4 py-3">
<StatusBadge status={item.status} />
</td>
<td className="px-4 py-3 text-sm text-[#5f5b52]">
{item.surface}
</td>
<td className="px-4 py-3">
<span className="inline-flex items-center gap-1.5 font-mono text-xs text-[#5f5b52]">
<Database className="h-3.5 w-3.5" aria-hidden="true" />
{item.source}
</span>
</td>
<td className="px-4 py-3">
<span className="inline-flex items-center gap-1.5 text-sm text-[#5f5b52]">
<Network className="h-3.5 w-3.5" aria-hidden="true" />
{item.gate}
</span>
</td>
<td className="px-4 py-3">
<Link
href={item.href}
className="inline-flex items-center gap-1.5 border border-[#d8d3c7] px-2.5 py-1 text-xs font-semibold text-[#141413] hover:border-[#d97757]"
>
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

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

View File

@@ -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 rateRedis 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 hallucinationalertname 被當 deployment/host、namespace 亂填)進入 approval 前的路徑。
- AwoooP Console 下一步應接入真實 run step journal / trace view而不是只列 run state。