fix(awooop): align console with flywheel execution metrics
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 類修復。"""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,8 @@
|
||||
"operations": "營運",
|
||||
"securityCompliance": "安全合規",
|
||||
"classicAICenter": "經典 AI 中心",
|
||||
"governance": "AI 治理"
|
||||
"governance": "AI 治理",
|
||||
"awooop": "AwoooP"
|
||||
},
|
||||
"locale": {
|
||||
"switch": "切換語系",
|
||||
@@ -1481,4 +1482,4 @@
|
||||
"retry": "重試"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
建立時間
|
||||
|
||||
@@ -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>
|
||||
|
||||
254
apps/web/src/app/[locale]/awooop/work-items/page.tsx
Normal file
254
apps/web/src/app/[locale]/awooop/work-items/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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。
|
||||
|
||||
Reference in New Issue
Block a user