feat(awooop): Operator Console API + 前端(leWOOOgo 積木化修復)
All checks were successful
Code Review / ai-code-review (push) Successful in 42s

後端:
- 新增 platform_operator_service.py(DB 存取集中 Service 層)
- Router 層移除 Depends(get_db),改呼叫 Service 函數
- tenants/contracts/operator_runs 三個 Router 符合 leWOOOgo 規範
- __init__.py 整合四個 platform router

前端:
- apps/web/src/app/[locale]/awooop/ 完整建立(7 個頁面)
- layout.tsx:四分頁導覽(tenants/contracts/runs/approvals)
- 全部使用 @/i18n/routing(Link/usePathname/useRouter)避免 i18n 路徑問題
- approvals page:10s 自動刷新、timeout 倒數、緊急紅色高亮

ADR-106/107/112/114/115/116

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-05-05 11:00:20 +08:00
parent aa4ccec429
commit e22b8e7ab2
12 changed files with 2467 additions and 2 deletions

View File

@@ -1,4 +1,22 @@
"""AwoooP Platform API — Phase 4 Shadow Mode Shell"""
from src.api.v1.platform.runs import router
"""
AwoooP Platform API — Operator Console Router 彙整
===================================================
Phase 4 Shadow Mode + Phase 8 Operator Console
ADR-106/ADR-107/ADR-114/ADR-115/ADR-116
2026-05-05 ogt + Claude Sonnet 4.6(新增 Operator Console 四 router
"""
from fastapi import APIRouter
from src.api.v1.platform.contracts import router as contracts_router
from src.api.v1.platform.operator_runs import router as operator_runs_router
from src.api.v1.platform.runs import router as runs_router
from src.api.v1.platform.tenants import router as tenants_router
router = APIRouter()
router.include_router(runs_router)
router.include_router(tenants_router)
router.include_router(contracts_router)
router.include_router(operator_runs_router)
__all__ = ["router"]

View File

@@ -0,0 +1,53 @@
"""
AwoooP Operator Console — Contracts List API
=============================================
ADR-106AwoooP Agent PlatformADR-107/ADR-112Contract Revision
2026-05-05 ogt + Claude Sonnet 4.6
"""
from __future__ import annotations
from datetime import datetime
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Query
from pydantic import BaseModel
from src.services.platform_operator_service import list_contracts as list_contracts_svc
router = APIRouter()
class ContractItem(BaseModel):
revision_id: UUID
contract_id: str
contract_family: str
lifecycle_status: str
body_hash: str
version_major: int
version_minor: int
created_at: datetime
project_id: str
class ListContractsResponse(BaseModel):
contracts: list[ContractItem]
total: int
@router.get(
"/contracts",
response_model=ListContractsResponse,
summary="列出合約 Revisions",
description=(
"返回 awooop_contract_revisions支援 project_id / lifecycle_status filter。\n\n"
"- 按 created_at DESC 排序,最多 200 筆\n"
"- ADR-107/ADR-112append-only revision 表,只查不寫"
),
)
async def list_contracts(
project_id: str | None = Query(None, description="租戶 ID可選"),
lifecycle_status: str | None = Query(None, description="lifecycle status filterdraft/published/active/revoked"),
) -> dict[str, Any]:
return await list_contracts_svc(project_id=project_id, lifecycle_status=lifecycle_status)

View File

@@ -0,0 +1,136 @@
"""
AwoooP Operator Console — Runs List & Approval API
====================================================
GET /runs/list — 列出 runs可 filter
GET /approvals — 列出待審核 runsstate=waiting_approval
POST /approvals/{run_id}/decide — 核准或拒絕 run
ADR-106AwoooP Agent PlatformADR-114Run State MachineADR-116Gate 5 Approval
2026-05-05 ogt + Claude Sonnet 4.6
"""
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from typing import Any, Literal
from uuid import UUID
from fastapi import APIRouter, Query
from pydantic import BaseModel, Field
from src.services.platform_operator_service import (
decide_approval as decide_approval_svc,
list_approvals as list_approvals_svc,
list_runs as list_runs_svc,
)
router = APIRouter()
_DEFAULT_PER_PAGE = 50
_MAX_PER_PAGE = 200
class RunItem(BaseModel):
run_id: UUID
project_id: str
agent_id: str
state: str
is_shadow: bool
cost_usd: Decimal
step_count: int
created_at: datetime
timeout_at: datetime | None
class ListRunsResponse(BaseModel):
runs: list[RunItem]
total: int
page: int
per_page: int
class ApprovalItem(BaseModel):
run_id: UUID
project_id: str
agent_id: str
created_at: datetime
timeout_at: datetime | None
class ListApprovalsResponse(BaseModel):
items: list[ApprovalItem]
total: int
class DecideApprovalRequest(BaseModel):
project_id: str = Field(..., description="租戶 ID")
decision: Literal["approve", "reject"] = Field(..., description="核准或拒絕")
approver_id: str = Field(..., description="審核人 IDplatform_subject_id 或 operator email")
reason: str | None = Field(None, description="決策原因(可選)")
class DecideApprovalResponse(BaseModel):
run_id: str
decision: str
new_state: str
approval_token_jti: str | None
@router.get(
"/runs/list",
response_model=ListRunsResponse,
summary="列出 Runs",
description=(
"返回 awooop_run_state 記錄,支援 project_id / state filter 與分頁。\n\n"
"- 按 created_at DESC 排序\n"
"- 注意:此路徑為 /runs/list 以避免與 runs.py 的 /runs/{run_id} 衝突"
),
)
async def list_runs(
project_id: str | None = Query(None, description="租戶 ID可選"),
state: str | None = Query(None, description="Run 狀態 filter可選"),
page: int = Query(1, ge=1, description="頁碼,從 1 開始"),
per_page: int = Query(_DEFAULT_PER_PAGE, ge=1, le=_MAX_PER_PAGE, description="每頁筆數"),
) -> dict[str, Any]:
return await list_runs_svc(
project_id=project_id, state=state, page=page, per_page=per_page
)
@router.get(
"/approvals",
response_model=ListApprovalsResponse,
summary="列出待審核 Runs",
description=(
"返回 state=waiting_approval 的 runs即需要人工審核的任務清單。\n\n"
"ADR-116 Gate 5人工審核關卡"
),
)
async def list_approvals(
project_id: str | None = Query(None, description="租戶 ID可選"),
) -> dict[str, Any]:
return await list_approvals_svc(project_id=project_id)
@router.post(
"/approvals/{run_id}/decide",
response_model=DecideApprovalResponse,
summary="核准或拒絕 Run",
description=(
"對 waiting_approval 狀態的 run 做出審核決定。\n\n"
"- approve發行 approval token → record_approval → run 轉為 running\n"
"- reject直接 transition → cancelled\n\n"
"ADR-116 Gate 5Operator Console 人工審核"
),
)
async def decide_approval(
run_id: str,
body: DecideApprovalRequest,
) -> dict[str, Any]:
return await decide_approval_svc(
run_id=run_id,
project_id=body.project_id,
decision=body.decision,
approver_id=body.approver_id,
reason=body.reason,
)

View File

@@ -0,0 +1,47 @@
"""
AwoooP Operator Console — Tenants List API
==========================================
ADR-106AwoooP Agent PlatformADR-115Tenant Onboarding
2026-05-05 ogt + Claude Sonnet 4.6
"""
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from typing import Any
from uuid import UUID
from fastapi import APIRouter
from pydantic import BaseModel
from src.services.platform_operator_service import list_tenants as list_tenants_svc
router = APIRouter()
class TenantItem(BaseModel):
project_id: str
display_name: str
migration_mode: str
budget_limit_usd: Decimal | None
is_active: bool
created_at: datetime
class ListTenantsResponse(BaseModel):
tenants: list[TenantItem]
total: int
@router.get(
"/tenants",
response_model=ListTenantsResponse,
summary="列出所有租戶",
description=(
"返回所有 awooop_projects 記錄(含已停用)。\n\n"
"ADR-106/ADR-115Operator Console 使用,不依 RLS 過濾。"
),
)
async def list_tenants() -> dict[str, Any]:
return await list_tenants_svc()

View File

@@ -0,0 +1,293 @@
"""
AwoooP Operator Console — Platform Operator Service
====================================================
leWOOOgo 積木化DB 存取集中在 Service 層Router 不直接引用 get_db。
ADR-106AwoooP Agent Platform
2026-05-05 ogt + Claude Sonnet 4.6
"""
from __future__ import annotations
import uuid
from datetime import datetime
from decimal import Decimal
from typing import Any
from uuid import UUID
import structlog
from fastapi import HTTPException, status
from sqlalchemy import func, select
from src.db.awooop_models import (
AwoooPContractRevision,
AwoooPProject,
AwoooPRunState,
)
from src.db.base import get_db_context
from src.services.audit_sink import write_audit
from src.services.awooop_approval_token import issue_approval_token, record_approval
from src.services.run_state_machine import transition
logger = structlog.get_logger(__name__)
_MAX_CONTRACTS = 200
_DEFAULT_PER_PAGE = 50
_MAX_PER_PAGE = 200
# =============================================================================
# Tenants
# =============================================================================
async def list_tenants() -> dict[str, Any]:
"""列出所有 AwoooP 租戶Operator Console不依 RLS 過濾)。"""
async with get_db_context("awoooi") as db:
result = await db.execute(
select(AwoooPProject).order_by(AwoooPProject.created_at.asc())
)
rows = list(result.scalars().all())
tenants = [
{
"project_id": r.project_id,
"display_name": r.display_name,
"migration_mode": r.migration_mode,
"budget_limit_usd": r.budget_limit_usd,
"is_active": r.is_active,
"created_at": r.created_at,
}
for r in rows
]
return {"tenants": tenants, "total": len(tenants)}
# =============================================================================
# Contracts
# =============================================================================
async def list_contracts(
project_id: str | None,
lifecycle_status: str | None,
) -> dict[str, Any]:
"""列出合約 revisions可 filter by project_id / lifecycle_status"""
async with get_db_context("awoooi") as db:
stmt = select(AwoooPContractRevision).order_by(
AwoooPContractRevision.created_at.desc()
)
if project_id is not None:
stmt = stmt.where(AwoooPContractRevision.project_id == project_id)
if lifecycle_status is not None:
stmt = stmt.where(
AwoooPContractRevision.lifecycle_status == lifecycle_status
)
count_stmt = select(func.count()).select_from(stmt.subquery())
total_result = await db.execute(count_stmt)
total = total_result.scalar_one()
stmt = stmt.limit(_MAX_CONTRACTS)
result = await db.execute(stmt)
rows = list(result.scalars().all())
contracts = [
{
"revision_id": r.revision_id,
"contract_id": r.contract_id,
"contract_family": r.contract_family,
"lifecycle_status": r.lifecycle_status,
"body_hash": r.body_hash,
"version_major": r.version_major,
"version_minor": r.version_minor,
"created_at": r.created_at,
"project_id": r.project_id,
}
for r in rows
]
return {"contracts": contracts, "total": total}
# =============================================================================
# Runs
# =============================================================================
async def list_runs(
project_id: str | None,
state: str | None,
page: int,
per_page: int,
) -> dict[str, Any]:
"""列出 runs支援 project_id、state filter 與分頁。"""
async with get_db_context("awoooi") as db:
stmt = select(AwoooPRunState).order_by(AwoooPRunState.created_at.desc())
if project_id is not None:
stmt = stmt.where(AwoooPRunState.project_id == project_id)
if state is not None:
stmt = stmt.where(AwoooPRunState.state == state)
count_stmt = select(func.count()).select_from(stmt.subquery())
total_result = await db.execute(count_stmt)
total = total_result.scalar_one()
offset = (page - 1) * per_page
stmt = stmt.offset(offset).limit(per_page)
result = await db.execute(stmt)
rows = list(result.scalars().all())
runs = [
{
"run_id": r.run_id,
"project_id": r.project_id,
"agent_id": r.agent_id,
"state": r.state,
"is_shadow": r.is_shadow,
"cost_usd": r.cost_usd,
"step_count": r.step_count,
"created_at": r.created_at,
"timeout_at": r.timeout_at,
}
for r in rows
]
return {"runs": runs, "total": total, "page": page, "per_page": per_page}
# =============================================================================
# Approvals
# =============================================================================
async def list_approvals(project_id: str | None) -> dict[str, Any]:
"""列出所有 waiting_approval 狀態的 runs。"""
async with get_db_context("awoooi") as db:
stmt = (
select(AwoooPRunState)
.where(AwoooPRunState.state == "waiting_approval")
.order_by(AwoooPRunState.created_at.asc())
)
if project_id is not None:
stmt = stmt.where(AwoooPRunState.project_id == project_id)
count_stmt = select(func.count()).select_from(stmt.subquery())
total_result = await db.execute(count_stmt)
total = total_result.scalar_one()
result = await db.execute(stmt)
rows = list(result.scalars().all())
items = [
{
"run_id": r.run_id,
"project_id": r.project_id,
"agent_id": r.agent_id,
"created_at": r.created_at,
"timeout_at": r.timeout_at,
}
for r in rows
]
return {"approvals": items, "total": total, "items": items}
async def decide_approval(
run_id: str,
project_id: str,
decision: str,
approver_id: str,
reason: str | None,
) -> dict[str, Any]:
"""核准或拒絕一個待審核的 runADR-116 Gate 5"""
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(project_id) as db:
result = await db.execute(
select(AwoooPRunState).where(
AwoooPRunState.run_id == run_uuid,
AwoooPRunState.project_id == project_id,
)
)
run = result.scalar_one_or_none()
if run is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"run {run_id!r} 不存在或非此 project 所有",
)
if run.state != "waiting_approval":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"run {run_id!r} 目前狀態為 {run.state!r},無法審核(需為 waiting_approval",
)
approval_token_jti: str | None = None
new_state: str
if decision == "approve":
token = issue_approval_token(
project_id=project_id,
run_id=run_id,
tool_name="operator_console_approve",
approver_id=approver_id,
)
try:
await record_approval(
project_id=project_id,
run_id=run_id,
tool_name="operator_console_approve",
approver_id=approver_id,
token=token,
)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"核准記錄失敗: {exc}",
) from exc
await transition(run_uuid, project_id, "running")
new_state = "running"
import base64
import json as _json
try:
p_b64 = token.split(".")[1]
padding = 4 - len(p_b64) % 4
if padding != 4:
p_b64 += "=" * padding
payload = _json.loads(base64.urlsafe_b64decode(p_b64))
approval_token_jti = payload.get("jti")
except Exception:
approval_token_jti = None
else:
await transition(
run_uuid,
project_id,
"cancelled",
error_code="E-APPR-REJECTED",
error_detail=f"operator 拒絕: approver={approver_id!r}, reason={reason!r}",
)
new_state = "cancelled"
try:
await write_audit(
project_id=project_id,
action=f"run.approval.{decision}",
resource_type="run",
resource_id=run_id,
details={
"approver_id": approver_id,
"decision": decision,
"reason": reason,
"new_state": new_state,
},
)
except Exception as exc:
logger.warning("approval_audit_write_failed", run_id=run_id, error=str(exc))
return {
"run_id": run_id,
"decision": decision,
"new_state": new_state,
"approval_token_jti": approval_token_jti,
}

View File

@@ -0,0 +1,495 @@
// =============================================================================
// WOOO AIOps - AwoooP M8 審批決策頁面
// =============================================================================
// 顯示 Run 詳情Approve / Reject 各需 Dialog 確認
// Reject 需填寫原因
"use client";
import { useState, useEffect, useCallback } from "react";
import { useRouter, Link } from "@/i18n/routing";
import {
ShieldCheck,
AlertCircle,
CheckCircle,
XCircle,
ArrowLeft,
RefreshCw,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
// =============================================================================
// Types
// =============================================================================
interface RunDetail {
run_id: string;
project_id: string;
agent_id: string;
state: string;
created_at: string;
timeout_at?: string | null;
}
// =============================================================================
// 常數
// =============================================================================
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "";
// =============================================================================
// Sub Components
// =============================================================================
function DetailRow({
label,
value,
}: {
label: string;
value: string | React.ReactNode;
}) {
return (
<div className="flex items-start py-3 border-b border-border last:border-0">
<span className="w-32 flex-shrink-0 text-xs font-medium text-muted-foreground uppercase tracking-wider pt-0.5">
{label}
</span>
<span className="text-sm text-foreground font-mono flex-1">{value}</span>
</div>
);
}
// Approve 確認 Dialog
function ApproveDialog({
runId,
onConfirm,
onCancel,
loading,
}: {
runId: string;
onConfirm: () => void;
onCancel: () => void;
loading: boolean;
}) {
return (
<div
className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="approve-dialog-title"
>
<div className="bg-card border border-border rounded-xl shadow-2xl w-full max-w-md">
{/* Header */}
<div className="flex items-center justify-between p-5 border-b border-border">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-900/40 rounded-lg">
<CheckCircle className="w-5 h-5 text-green-400" aria-hidden="true" />
</div>
<h3 id="approve-dialog-title" className="text-base font-semibold text-foreground">
</h3>
</div>
<button
onClick={onCancel}
className="p-1.5 hover:bg-accent rounded-lg transition"
aria-label="關閉"
>
<X className="w-4 h-4 text-muted-foreground" aria-hidden="true" />
</button>
</div>
{/* Body */}
<div className="p-5">
<p className="text-sm text-foreground mb-2"> Run</p>
<div className="bg-muted rounded-lg px-3 py-2">
<span className="text-xs text-muted-foreground font-mono">
Run ID:{" "}
<span className="text-brand-accent">{runId.slice(0, 8)}</span>
</span>
</div>
<p className="text-xs text-muted-foreground mt-3">
Run
</p>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 p-5 border-t border-border bg-muted/30 rounded-b-xl">
<button
onClick={onCancel}
disabled={loading}
className="px-4 py-2 text-sm text-muted-foreground hover:bg-accent rounded-lg transition disabled:opacity-50"
>
</button>
<button
onClick={onConfirm}
disabled={loading}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium bg-green-600 text-white rounded-lg hover:bg-green-700 transition disabled:opacity-50"
>
{loading && <RefreshCw className="w-3.5 h-3.5 animate-spin" aria-hidden="true" />}
<CheckCircle className="w-3.5 h-3.5" aria-hidden="true" />
</button>
</div>
</div>
</div>
);
}
// Reject 確認 Dialog需填原因
function RejectDialog({
runId,
onConfirm,
onCancel,
loading,
}: {
runId: string;
onConfirm: (reason: string) => void;
onCancel: () => void;
loading: boolean;
}) {
const [reason, setReason] = useState("");
return (
<div
className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="reject-dialog-title"
>
<div className="bg-card border border-border rounded-xl shadow-2xl w-full max-w-md">
{/* Header */}
<div className="flex items-center justify-between p-5 border-b border-border">
<div className="flex items-center gap-3">
<div className="p-2 bg-red-900/40 rounded-lg">
<XCircle className="w-5 h-5 text-red-400" aria-hidden="true" />
</div>
<h3 id="reject-dialog-title" className="text-base font-semibold text-foreground">
</h3>
</div>
<button
onClick={onCancel}
className="p-1.5 hover:bg-accent rounded-lg transition"
aria-label="關閉"
>
<X className="w-4 h-4 text-muted-foreground" aria-hidden="true" />
</button>
</div>
{/* Body */}
<div className="p-5 space-y-4">
<div className="bg-muted rounded-lg px-3 py-2">
<span className="text-xs text-muted-foreground font-mono">
Run ID:{" "}
<span className="text-red-400">{runId.slice(0, 8)}</span>
</span>
</div>
<div>
<label
htmlFor="reject-reason"
className="block text-sm font-medium text-foreground mb-2"
>
<span className="text-red-400 ml-1">*</span>
</label>
<textarea
id="reject-reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={3}
placeholder="請輸入拒絕原因..."
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-red-500/50 resize-none"
/>
</div>
<p className="text-xs text-muted-foreground">
Run
</p>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 p-5 border-t border-border bg-muted/30 rounded-b-xl">
<button
onClick={onCancel}
disabled={loading}
className="px-4 py-2 text-sm text-muted-foreground hover:bg-accent rounded-lg transition disabled:opacity-50"
>
</button>
<button
onClick={() => onConfirm(reason.trim())}
disabled={loading || reason.trim().length === 0}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium bg-red-600 text-white rounded-lg hover:bg-red-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading && <RefreshCw className="w-3.5 h-3.5 animate-spin" aria-hidden="true" />}
<XCircle className="w-3.5 h-3.5" aria-hidden="true" />
</button>
</div>
</div>
</div>
);
}
// =============================================================================
// Main Component
// =============================================================================
export default function ApprovalDecisionPage({
params,
}: {
params: { run_id: string };
}) {
const { run_id } = params;
const router = useRouter();
const [run, setRun] = useState<RunDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [actionLoading, setActionLoading] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const [actionSuccess, setActionSuccess] = useState<string | null>(null);
const [showApproveDialog, setShowApproveDialog] = useState(false);
const [showRejectDialog, setShowRejectDialog] = useState(false);
const fetchRun = useCallback(async () => {
try {
setError(null);
// 使用 approvals API 取得單筆 run 資訊
const res = await fetch(`${API_BASE}/api/v1/platform/approvals?run_id=${run_id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const item = Array.isArray(data.items)
? data.items.find((r: RunDetail) => r.run_id === run_id)
: null;
if (item) {
setRun(item);
} else {
setRun({ run_id, project_id: "--", agent_id: "--", state: "--", created_at: "" });
}
} catch (err) {
setError(err instanceof Error ? err.message : "載入失敗");
} finally {
setLoading(false);
}
}, [run_id]);
useEffect(() => {
fetchRun();
}, [fetchRun]);
const handleApprove = async () => {
setActionLoading(true);
setActionError(null);
try {
const res = await fetch(
`${API_BASE}/api/v1/platform/approvals/${run_id}/decide`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
decision: "approve",
approver_id: "operator",
reason: null,
}),
}
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
setActionSuccess("Run 已成功核准");
setShowApproveDialog(false);
setTimeout(() => router.push("/awooop/approvals"), 1500);
} catch (err) {
setActionError(err instanceof Error ? err.message : "操作失敗");
setShowApproveDialog(false);
} finally {
setActionLoading(false);
}
};
const handleReject = async (reason: string) => {
setActionLoading(true);
setActionError(null);
try {
const res = await fetch(
`${API_BASE}/api/v1/platform/approvals/${run_id}/decide`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
decision: "reject",
approver_id: "operator",
reason,
}),
}
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
setActionSuccess("Run 已拒絕");
setShowRejectDialog(false);
setTimeout(() => router.push("/awooop/approvals"), 1500);
} catch (err) {
setActionError(err instanceof Error ? err.message : "操作失敗");
setShowRejectDialog(false);
} finally {
setActionLoading(false);
}
};
return (
<>
{/* Dialogs */}
{showApproveDialog && (
<ApproveDialog
runId={run_id}
onConfirm={handleApprove}
onCancel={() => setShowApproveDialog(false)}
loading={actionLoading}
/>
)}
{showRejectDialog && (
<RejectDialog
runId={run_id}
onConfirm={handleReject}
onCancel={() => setShowRejectDialog(false)}
loading={actionLoading}
/>
)}
<div className="space-y-6 max-w-2xl">
{/* Back Link */}
<Link
href="/awooop/approvals"
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="w-4 h-4" aria-hidden="true" />
</Link>
{/* Page Header */}
<div className="flex items-center gap-3">
<ShieldCheck className="w-5 h-5 text-brand-accent" aria-hidden="true" />
<div>
<h2 className="text-lg font-semibold text-foreground"></h2>
<p className="text-xs text-muted-foreground font-mono">
{run_id.slice(0, 8)}...
</p>
</div>
</div>
{/* Error / Success States */}
{error && (
<div className="flex items-start gap-3 p-4 bg-red-900/20 border border-red-800/40 rounded-lg">
<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"> Run </p>
<p className="text-xs text-red-400 mt-1">{error}</p>
</div>
</div>
)}
{actionError && (
<div className="flex items-start gap-3 p-4 bg-red-900/20 border border-red-800/40 rounded-lg">
<AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" aria-hidden="true" />
<p className="text-sm text-red-300">{actionError}</p>
</div>
)}
{actionSuccess && (
<div className="flex items-center gap-3 p-4 bg-green-900/20 border border-green-800/40 rounded-lg">
<CheckCircle className="w-5 h-5 text-green-400 flex-shrink-0" aria-hidden="true" />
<p className="text-sm font-medium text-green-300">{actionSuccess}</p>
</div>
)}
{/* Run Detail Card */}
<div className="bg-card border border-border rounded-xl overflow-hidden shadow-sm">
<div className="flex items-center justify-between px-5 py-4 border-b border-border bg-muted/30">
<span className="text-sm font-medium text-foreground">Run </span>
{loading && (
<RefreshCw className="w-4 h-4 animate-spin text-muted-foreground" aria-hidden="true" />
)}
</div>
<div className="px-5">
{loading ? (
Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className="flex items-center py-3 border-b border-border last:border-0"
>
<div className="w-32 h-4 bg-muted animate-pulse rounded mr-4" />
<div className="h-4 bg-muted animate-pulse rounded w-48" />
</div>
))
) : run ? (
<>
<DetailRow label="Run ID" value={run.run_id} />
<DetailRow label="Project ID" value={run.project_id || "--"} />
<DetailRow label="Agent" value={run.agent_id || "--"} />
<DetailRow
label="狀態"
value={
<span className="inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium bg-yellow-900/40 text-yellow-300 border border-yellow-600/40">
{run.state || "--"}
</span>
}
/>
<DetailRow
label="建立時間"
value={
run.created_at
? new Date(run.created_at).toLocaleString("zh-TW")
: "--"
}
/>
{run.timeout_at && (
<DetailRow
label="超時時間"
value={new Date(run.timeout_at).toLocaleString("zh-TW")}
/>
)}
</>
) : null}
</div>
</div>
{/* Action Buttons */}
{!loading && run && !actionSuccess && (
<div className="flex items-center gap-3">
<button
onClick={() => setShowApproveDialog(true)}
disabled={actionLoading}
className={cn(
"flex-1 flex items-center justify-center gap-2 py-3 px-6",
"text-sm font-semibold rounded-xl border transition-all duration-150",
"bg-green-900/30 text-green-300 border-green-600/40",
"hover:bg-green-900/60 hover:border-green-500/60",
"disabled:opacity-50 disabled:cursor-not-allowed",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-green-500/50"
)}
>
<CheckCircle className="w-4 h-4" aria-hidden="true" />
</button>
<button
onClick={() => setShowRejectDialog(true)}
disabled={actionLoading}
className={cn(
"flex-1 flex items-center justify-center gap-2 py-3 px-6",
"text-sm font-semibold rounded-xl border transition-all duration-150",
"bg-red-900/30 text-red-300 border-red-600/40",
"hover:bg-red-900/60 hover:border-red-500/60",
"disabled:opacity-50 disabled:cursor-not-allowed",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/50"
)}
>
<XCircle className="w-4 h-4" aria-hidden="true" />
</button>
</div>
)}
</div>
</>
);
}

View File

@@ -0,0 +1,300 @@
// =============================================================================
// WOOO AIOps - AwoooP M7 審批佇列頁面
// =============================================================================
// 每 10 秒自動刷新timeout < 5 分鐘時紅色高亮警示
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import {
ShieldCheck,
RefreshCw,
AlertCircle,
Clock,
ArrowRight,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Link } from "@/i18n/routing";
// =============================================================================
// Types
// =============================================================================
interface Approval {
run_id: string;
project_id: string;
agent_id: string;
created_at: string;
timeout_at: string | null;
}
// =============================================================================
// 常數
// =============================================================================
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "";
const AUTO_REFRESH_INTERVAL = 10_000; // 10 秒
// =============================================================================
// Helpers
// =============================================================================
function getRemainingMs(timeoutAt: string | null): number | null {
if (!timeoutAt) return null;
return new Date(timeoutAt).getTime() - Date.now();
}
function formatRemaining(ms: number): string {
if (ms <= 0) return "已超時";
const totalSec = Math.floor(ms / 1000);
const minutes = Math.floor(totalSec / 60);
const seconds = totalSec % 60;
if (minutes > 60) {
const hours = Math.floor(minutes / 60);
return `${hours}h ${minutes % 60}m`;
}
if (minutes > 0) return `${minutes}m ${seconds}s`;
return `${seconds}s`;
}
// =============================================================================
// Sub Components
// =============================================================================
function TimeoutCell({ timeoutAt }: { timeoutAt: string | null }) {
const [remaining, setRemaining] = useState<number | null>(
getRemainingMs(timeoutAt)
);
useEffect(() => {
if (!timeoutAt) return;
const timer = setInterval(() => {
setRemaining(getRemainingMs(timeoutAt));
}, 1000);
return () => clearInterval(timer);
}, [timeoutAt]);
if (remaining === null) return <span className="text-muted-foreground text-sm">--</span>;
const isCritical = remaining <= 5 * 60 * 1000; // 5 分鐘內
const isExpired = remaining <= 0;
return (
<span
className={cn(
"inline-flex items-center gap-1.5 font-mono text-sm",
isExpired
? "text-red-400"
: isCritical
? "text-red-300 font-semibold animate-pulse"
: "text-muted-foreground"
)}
aria-label={`剩餘時間: ${formatRemaining(remaining)}`}
>
<Clock className="w-3.5 h-3.5" aria-hidden="true" />
{formatRemaining(remaining)}
</span>
);
}
function ApprovalRow({ approval }: { approval: Approval }) {
const formattedDate = approval.created_at
? new Date(approval.created_at).toLocaleDateString("zh-TW", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})
: "--";
const remainingMs = getRemainingMs(approval.timeout_at);
const isCritical = remainingMs !== null && remainingMs <= 5 * 60 * 1000;
return (
<tr
className={cn(
"border-b border-border hover:bg-accent/30 transition-colors",
isCritical && "bg-red-900/10 hover:bg-red-900/20"
)}
>
<td className="px-4 py-3">
<Link
href={`/awooop/approvals/${approval.run_id}`}
className="inline-flex items-center gap-1.5 font-mono text-xs text-brand-accent bg-brand-accent/10 px-2 py-1 rounded border border-brand-accent/20 hover:bg-brand-accent/20 transition-colors group"
aria-label={`開啟審批 ${approval.run_id}`}
>
{approval.run_id.slice(0, 8)}
<ArrowRight className="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity" aria-hidden="true" />
</Link>
</td>
<td className="px-4 py-3">
<span className="font-mono text-sm text-muted-foreground">
{approval.project_id || "--"}
</span>
</td>
<td className="px-4 py-3">
<span className="font-mono text-sm text-muted-foreground">
{approval.agent_id || "--"}
</span>
</td>
<td className="px-4 py-3">
<span className="text-sm text-muted-foreground font-mono">
{formattedDate}
</span>
</td>
<td className="px-4 py-3">
<TimeoutCell timeoutAt={approval.timeout_at} />
</td>
</tr>
);
}
// =============================================================================
// Main Component
// =============================================================================
export default function ApprovalsPage() {
const [approvals, setApprovals] = useState<Approval[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [lastRefresh, setLastRefresh] = useState<Date>(new Date());
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const fetchApprovals = useCallback(async () => {
try {
setError(null);
const res = await fetch(`${API_BASE}/api/v1/platform/approvals`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
setApprovals(Array.isArray(data.items) ? data.items : []);
setLastRefresh(new Date());
} catch (err) {
setError(err instanceof Error ? err.message : "載入失敗");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchApprovals();
}, [fetchApprovals]);
// 10 秒自動刷新
useEffect(() => {
intervalRef.current = setInterval(() => {
fetchApprovals();
}, AUTO_REFRESH_INTERVAL);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [fetchApprovals]);
const criticalCount = approvals.filter((a) => {
const ms = getRemainingMs(a.timeout_at);
return ms !== null && ms <= 5 * 60 * 1000;
}).length;
return (
<div className="space-y-6">
{/* Page Header */}
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-3">
<ShieldCheck className="w-5 h-5 text-brand-accent" aria-hidden="true" />
<div>
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2">
{criticalCount > 0 && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-bold bg-red-600 text-white animate-pulse">
{criticalCount}
</span>
)}
</h2>
<p className="text-xs text-muted-foreground">
{loading
? "載入中..."
: `${approvals.length} 筆待審 · 上次更新 ${lastRefresh.toLocaleTimeString("zh-TW")}`}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground"> 10 </span>
<button
onClick={() => { setLoading(true); fetchApprovals(); }}
disabled={loading}
className="flex items-center gap-2 px-3 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg transition disabled:opacity-50"
aria-label="立即重新整理"
>
<RefreshCw className={cn("w-4 h-4", loading && "animate-spin")} aria-hidden="true" />
</button>
</div>
</div>
{/* Error State */}
{error && (
<div className="flex items-start gap-3 p-4 bg-red-900/20 border border-red-800/40 rounded-lg">
<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>
<p className="text-xs text-red-400 mt-1">{error}</p>
</div>
</div>
)}
{/* 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">
<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>
</div>
)}
{/* Table */}
{(loading || approvals.length > 0) && (
<div className="bg-card border border-border rounded-xl overflow-hidden shadow-sm">
<div className="overflow-x-auto">
<table className="w-full" role="table" aria-label="審批佇列">
<thead>
<tr className="border-b border-border bg-muted/50">
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Run ID
</th>
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Project ID
</th>
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Agent
</th>
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
</th>
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
</th>
</tr>
</thead>
<tbody>
{loading ? (
Array.from({ length: 5 }).map((_, i) => (
<tr key={i} className="border-b border-border">
{Array.from({ length: 5 }).map((_, j) => (
<td key={j} className="px-4 py-3">
<div className="h-5 bg-muted animate-pulse rounded w-20" />
</td>
))}
</tr>
))
) : (
approvals.map((approval) => (
<ApprovalRow key={approval.run_id} approval={approval} />
))
)}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,281 @@
// =============================================================================
// WOOO AIOps - AwoooP M3 合約儀表板
// =============================================================================
// 顯示所有 contract revisions支援 project_id 篩選
"use client";
import { useState, useEffect, useCallback } from "react";
import {
FileText,
RefreshCw,
AlertCircle,
Filter,
ChevronDown,
} from "lucide-react";
import { cn } from "@/lib/utils";
// =============================================================================
// Types
// =============================================================================
type ContractStatus = "draft" | "published" | "active";
interface Contract {
id: string;
contract_family: string;
project_id: string;
status: ContractStatus;
body_hash: string;
created_at: string;
}
interface Tenant {
project_id: string;
name: string;
}
// =============================================================================
// 常數
// =============================================================================
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "";
const STATUS_CONFIG: Record<
ContractStatus,
{ label: string; bg: string; text: string; border: string }
> = {
draft: {
label: "草稿",
bg: "bg-gray-800",
text: "text-gray-300",
border: "border-gray-600",
},
published: {
label: "已發佈",
bg: "bg-blue-900/40",
text: "text-blue-300",
border: "border-blue-600/40",
},
active: {
label: "生效中",
bg: "bg-green-900/40",
text: "text-green-300",
border: "border-green-600/40",
},
};
// =============================================================================
// Sub Components
// =============================================================================
function StatusBadge({ status }: { status: ContractStatus }) {
const config = STATUS_CONFIG[status] ?? STATUS_CONFIG.draft;
return (
<span
className={cn(
"inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium border",
config.bg,
config.text,
config.border
)}
>
{config.label}
</span>
);
}
function ContractRow({ contract }: { contract: Contract }) {
const formattedDate = contract.created_at
? new Date(contract.created_at).toLocaleDateString("zh-TW", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})
: "--";
return (
<tr className="border-b border-border hover:bg-accent/30 transition-colors">
<td className="px-4 py-3">
<span className="text-sm text-foreground font-medium">
{contract.contract_family || "--"}
</span>
</td>
<td className="px-4 py-3">
<span className="font-mono text-sm text-muted-foreground">
{contract.project_id || "--"}
</span>
</td>
<td className="px-4 py-3">
<StatusBadge status={contract.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">
{contract.body_hash ? contract.body_hash.slice(0, 8) : "--"}
</span>
</td>
<td className="px-4 py-3">
<span className="text-sm text-muted-foreground font-mono">
{formattedDate}
</span>
</td>
</tr>
);
}
// =============================================================================
// Main Component
// =============================================================================
export default function ContractsPage() {
const [contracts, setContracts] = useState<Contract[]>([]);
const [tenants, setTenants] = useState<Tenant[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [projectFilter, setProjectFilter] = useState<string>("");
// 取得租戶清單供篩選
useEffect(() => {
fetch(`${API_BASE}/api/v1/platform/tenants`)
.then((r) => r.json())
.then((data) => setTenants(Array.isArray(data.items) ? data.items : []))
.catch(() => {});
}, []);
const fetchContracts = useCallback(async () => {
try {
setError(null);
setLoading(true);
const params = new URLSearchParams();
if (projectFilter) params.set("project_id", projectFilter);
const res = await fetch(
`${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 : []);
} catch (err) {
setError(err instanceof Error ? err.message : "載入失敗");
} finally {
setLoading(false);
}
}, [projectFilter]);
useEffect(() => {
fetchContracts();
}, [fetchContracts]);
return (
<div className="space-y-6">
{/* Page Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<FileText className="w-5 h-5 text-brand-accent" aria-hidden="true" />
<div>
<h2 className="text-lg font-semibold text-foreground"></h2>
<p className="text-xs text-muted-foreground">
{loading ? "載入中..." : `${contracts.length} 筆合約版本`}
</p>
</div>
</div>
<button
onClick={fetchContracts}
disabled={loading}
className="flex items-center gap-2 px-3 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg transition disabled:opacity-50"
aria-label="重新整理合約清單"
>
<RefreshCw className={cn("w-4 h-4", loading && "animate-spin")} aria-hidden="true" />
</button>
</div>
{/* Filters */}
<div className="flex items-center gap-3 p-4 bg-card border border-border rounded-xl">
<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">
<select
value={projectFilter}
onChange={(e) => setProjectFilter(e.target.value)}
className="appearance-none pl-3 pr-8 py-1.5 text-sm bg-background border border-border rounded-lg text-foreground focus:outline-none focus:ring-2 focus:ring-brand-accent/50 cursor-pointer"
aria-label="選擇租戶"
>
<option value=""></option>
{tenants.map((t) => (
<option key={t.project_id} value={t.project_id}>
{t.name || t.project_id}
</option>
))}
</select>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground pointer-events-none" aria-hidden="true" />
</div>
</div>
{/* Error State */}
{error && (
<div className="flex items-start gap-3 p-4 bg-red-900/20 border border-red-800/40 rounded-lg">
<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>
<p className="text-xs text-red-400 mt-1">{error}</p>
</div>
</div>
)}
{/* Table */}
<div className="bg-card border border-border rounded-xl overflow-hidden shadow-sm">
<div className="overflow-x-auto">
<table className="w-full" role="table" aria-label="合約清單">
<thead>
<tr className="border-b border-border bg-muted/50">
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
</th>
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Project ID
</th>
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
</th>
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Hash
</th>
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
</th>
</tr>
</thead>
<tbody>
{loading ? (
Array.from({ length: 5 }).map((_, i) => (
<tr key={i} className="border-b border-border">
{Array.from({ length: 5 }).map((_, j) => (
<td key={j} className="px-4 py-3">
<div className="h-5 bg-muted animate-pulse rounded w-24" />
</td>
))}
</tr>
))
) : contracts.length === 0 && !error ? (
<tr>
<td colSpan={5} className="px-4 py-16 text-center">
<FileText className="w-10 h-10 text-muted-foreground/30 mx-auto mb-3" aria-hidden="true" />
<p className="text-sm text-muted-foreground">
{projectFilter ? "此租戶尚無合約資料" : "尚無合約資料"}
</p>
</td>
</tr>
) : (
contracts.map((contract) => (
<ContractRow key={contract.id} contract={contract} />
))
)}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,106 @@
// =============================================================================
// WOOO AIOps - AwoooP Operator Console 佈局
// =============================================================================
// AwoooP 專屬次級導航列(水平 tab bar在 DashboardLayout main 區域內運作
// 設計方向:工業監控儀表板風格,橘色 accent 與品牌系統一致
"use client";
import { Link, usePathname } from "@/i18n/routing";
import { Building2, FileText, Activity, ShieldCheck } from "lucide-react";
import { cn } from "@/lib/utils";
// =============================================================================
// 導航設定
// =============================================================================
const navItems = [
{
label: "租戶管理",
href: "/awooop/tenants" as const,
icon: Building2,
},
{
label: "合約儀表板",
href: "/awooop/contracts" as const,
icon: FileText,
},
{
label: "Run 監控",
href: "/awooop/runs" as const,
icon: Activity,
},
{
label: "審批佇列",
href: "/awooop/approvals" as const,
icon: ShieldCheck,
},
];
// =============================================================================
// AwoooP Console 佈局
// =============================================================================
export default function AwoooPLayout({
children,
}: {
children: React.ReactNode;
}) {
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" />
OPERATOR
</span>
</div>
</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>
</div>
{/* Page Content */}
<main className="flex-1 px-6 py-6">
{children}
</main>
</div>
);
}

View File

@@ -0,0 +1,9 @@
// =============================================================================
// WOOO AIOps - AwoooP Console 入口重導向
// =============================================================================
import { redirect } from "@/i18n/routing";
export default function AwoooPPage() {
redirect("/awooop/tenants");
}

View File

@@ -0,0 +1,458 @@
// =============================================================================
// WOOO AIOps - AwoooP M5 Run 監控頁面
// =============================================================================
// 每 30 秒自動刷新RUNNING 狀態顯示 pulse 動畫
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import {
Activity,
RefreshCw,
AlertCircle,
Filter,
ChevronDown,
ChevronLeft,
ChevronRight,
Cpu,
} from "lucide-react";
import { cn } from "@/lib/utils";
// =============================================================================
// Types
// =============================================================================
type RunState =
| "CREATED"
| "QUEUED"
| "POLICY_RESOLVED"
| "RUNNING"
| "WAITING_TOOL"
| "WAITING_APPROVAL"
| "RESUMED"
| "COMPLETED"
| "FAILED"
| "CANCELLED";
interface Run {
run_id: string;
project_id: string;
agent_id: string;
state: RunState;
is_shadow: boolean;
token_usage_input: number | null;
token_usage_output: number | null;
created_at: string;
}
interface Tenant {
project_id: string;
name: string;
}
interface RunsResponse {
items: Run[];
total: number;
page: number;
per_page: number;
}
// =============================================================================
// 常數
// =============================================================================
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "";
const PER_PAGE = 50;
const AUTO_REFRESH_INTERVAL = 30_000; // 30 秒
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",
},
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: {
label: "執行中",
bg: "bg-green-900/40",
text: "text-green-300",
border: "border-green-600/40",
pulse: true,
},
WAITING_TOOL: {
label: "等待工具",
bg: "bg-yellow-900/40",
text: "text-yellow-300",
border: "border-yellow-600/40",
},
WAITING_APPROVAL: {
label: "等待審批",
bg: "bg-yellow-900/40",
text: "text-yellow-300",
border: "border-yellow-600/40",
},
RESUMED: {
label: "已恢復",
bg: "bg-purple-900/40",
text: "text-purple-300",
border: "border-purple-600/40",
},
COMPLETED: {
label: "已完成",
bg: "bg-green-900/40",
text: "text-green-400",
border: "border-green-600/40",
},
FAILED: {
label: "失敗",
bg: "bg-red-900/40",
text: "text-red-300",
border: "border-red-600/40",
},
CANCELLED: {
label: "已取消",
bg: "bg-red-900/30",
text: "text-red-400",
border: "border-red-700/40",
},
};
// =============================================================================
// Sub Components
// =============================================================================
function RunStateBadge({ state }: { state: RunState }) {
const config = STATE_CONFIG[state] ?? STATE_CONFIG.CREATED;
return (
<span
className={cn(
"inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-md text-xs font-mono font-medium border",
config.bg,
config.text,
config.border
)}
>
{config.pulse && (
<span className="w-1.5 h-1.5 rounded-full bg-green-400 animate-pulse" aria-hidden="true" />
)}
{config.label}
</span>
);
}
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">
Shadow
</span>
);
}
function RunRow({ run }: { run: Run }) {
const formattedDate = run.created_at
? new Date(run.created_at).toLocaleDateString("zh-TW", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})
: "--";
const totalTokens =
(run.token_usage_input ?? 0) + (run.token_usage_output ?? 0);
return (
<tr className="border-b border-border hover:bg-accent/30 transition-colors">
<td className="px-4 py-3">
<span className="font-mono text-xs text-brand-accent bg-brand-accent/10 px-2 py-1 rounded border border-brand-accent/20">
{run.run_id.slice(0, 8)}
</span>
</td>
<td className="px-4 py-3">
<span className="font-mono text-sm text-muted-foreground">
{run.project_id || "--"}
</span>
</td>
<td className="px-4 py-3">
<span className="font-mono text-sm text-muted-foreground">
{run.agent_id || "--"}
</span>
</td>
<td className="px-4 py-3">
<RunStateBadge state={run.state} />
</td>
<td className="px-4 py-3">
<ShadowBadge isShadow={run.is_shadow} />
</td>
<td className="px-4 py-3">
<span className="flex items-center gap-1 text-sm font-mono text-muted-foreground">
{totalTokens > 0 ? (
<>
<Cpu className="w-3.5 h-3.5" aria-hidden="true" />
{totalTokens.toLocaleString()}
<span className="text-xs text-muted-foreground/60">
({run.token_usage_input ?? 0} {run.token_usage_output ?? 0})
</span>
</>
) : (
"--"
)}
</span>
</td>
<td className="px-4 py-3">
<span className="text-sm text-muted-foreground font-mono">
{formattedDate}
</span>
</td>
</tr>
);
}
// =============================================================================
// Main Component
// =============================================================================
export default function RunsPage() {
const [runs, setRuns] = useState<Run[]>([]);
const [tenants, setTenants] = useState<Tenant[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [projectFilter, setProjectFilter] = useState<string>("");
const [statusFilter, setStatusFilter] = useState<string>("");
const [page, setPage] = useState(1);
const [lastRefresh, setLastRefresh] = useState<Date>(new Date());
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// 取得租戶清單
useEffect(() => {
fetch(`${API_BASE}/api/v1/platform/tenants`)
.then((r) => r.json())
.then((data) => setTenants(Array.isArray(data.items) ? data.items : []))
.catch(() => {});
}, []);
const fetchRuns = useCallback(async () => {
try {
setError(null);
const params = new URLSearchParams();
if (projectFilter) params.set("project_id", projectFilter);
if (statusFilter) params.set("status", statusFilter);
params.set("page", String(page));
params.set("per_page", String(PER_PAGE));
const res = await fetch(
`${API_BASE}/api/v1/platform/runs/list?${params.toString()}`
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data: RunsResponse = await res.json();
setRuns(Array.isArray(data.items) ? data.items : []);
setTotal(data.total ?? 0);
setLastRefresh(new Date());
} catch (err) {
setError(err instanceof Error ? err.message : "載入失敗");
} finally {
setLoading(false);
}
}, [projectFilter, statusFilter, page]);
// 初次載入
useEffect(() => {
setLoading(true);
fetchRuns();
}, [fetchRuns]);
// 30 秒自動刷新
useEffect(() => {
intervalRef.current = setInterval(() => {
fetchRuns();
}, AUTO_REFRESH_INTERVAL);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [fetchRuns]);
const totalPages = Math.ceil(total / PER_PAGE);
return (
<div className="space-y-6">
{/* Page Header */}
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-3">
<Activity className="w-5 h-5 text-brand-accent" aria-hidden="true" />
<div>
<h2 className="text-lg font-semibold text-foreground">Run </h2>
<p className="text-xs text-muted-foreground">
{loading
? "載入中..."
: `${total} 筆 · 上次更新 ${lastRefresh.toLocaleTimeString("zh-TW")}`}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground"> 30 </span>
<button
onClick={() => { setLoading(true); fetchRuns(); }}
disabled={loading}
className="flex items-center gap-2 px-3 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg transition disabled:opacity-50"
aria-label="立即重新整理"
>
<RefreshCw className={cn("w-4 h-4", loading && "animate-spin")} aria-hidden="true" />
</button>
</div>
</div>
{/* Filters */}
<div className="flex items-center gap-3 p-4 bg-card border border-border rounded-xl flex-wrap">
<Filter className="w-4 h-4 text-muted-foreground flex-shrink-0" aria-hidden="true" />
<span className="text-sm text-muted-foreground"></span>
{/* Project Filter */}
<div className="relative">
<select
value={projectFilter}
onChange={(e) => { setProjectFilter(e.target.value); setPage(1); }}
className="appearance-none pl-3 pr-8 py-1.5 text-sm bg-background border border-border rounded-lg text-foreground focus:outline-none focus:ring-2 focus:ring-brand-accent/50 cursor-pointer"
aria-label="選擇租戶"
>
<option value=""></option>
{tenants.map((t) => (
<option key={t.project_id} value={t.project_id}>
{t.name || t.project_id}
</option>
))}
</select>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground pointer-events-none" aria-hidden="true" />
</div>
{/* Status Filter */}
<div className="relative">
<select
value={statusFilter}
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
className="appearance-none pl-3 pr-8 py-1.5 text-sm bg-background border border-border rounded-lg text-foreground focus:outline-none focus:ring-2 focus:ring-brand-accent/50 cursor-pointer"
aria-label="選擇狀態"
>
<option value=""></option>
{(Object.keys(STATE_CONFIG) as RunState[]).map((s) => (
<option key={s} value={s}>
{STATE_CONFIG[s].label}
</option>
))}
</select>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground pointer-events-none" aria-hidden="true" />
</div>
</div>
{/* Error State */}
{error && (
<div className="flex items-start gap-3 p-4 bg-red-900/20 border border-red-800/40 rounded-lg">
<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"> Run </p>
<p className="text-xs text-red-400 mt-1">{error}</p>
</div>
</div>
)}
{/* Table */}
<div className="bg-card border border-border rounded-xl overflow-hidden shadow-sm">
<div className="overflow-x-auto">
<table className="w-full" role="table" aria-label="Run 清單">
<thead>
<tr className="border-b border-border bg-muted/50">
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Run ID
</th>
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Project ID
</th>
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Agent
</th>
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
</th>
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Shadow
</th>
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Token
</th>
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
</th>
</tr>
</thead>
<tbody>
{loading ? (
Array.from({ length: 8 }).map((_, i) => (
<tr key={i} className="border-b border-border">
{Array.from({ length: 7 }).map((_, j) => (
<td key={j} className="px-4 py-3">
<div className="h-5 bg-muted animate-pulse rounded w-20" />
</td>
))}
</tr>
))
) : runs.length === 0 && !error ? (
<tr>
<td colSpan={7} className="px-4 py-16 text-center">
<Activity className="w-10 h-10 text-muted-foreground/30 mx-auto mb-3" aria-hidden="true" />
<p className="text-sm text-muted-foreground"> Run </p>
</td>
</tr>
) : (
runs.map((run) => <RunRow key={run.run_id} run={run} />)
)}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t border-border bg-muted/30">
<span className="text-xs text-muted-foreground">
{page} / {totalPages} {total}
</span>
<div className="flex items-center gap-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1 || loading}
className="p-1.5 hover:bg-accent rounded transition disabled:opacity-50"
aria-label="上一頁"
>
<ChevronLeft className="w-4 h-4" aria-hidden="true" />
</button>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages || loading}
className="p-1.5 hover:bg-accent rounded transition disabled:opacity-50"
aria-label="下一頁"
>
<ChevronRight className="w-4 h-4" aria-hidden="true" />
</button>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,269 @@
// =============================================================================
// WOOO AIOps - AwoooP M1 租戶管理頁面
// =============================================================================
// 顯示所有 tenant 清單,含 migration_mode badge 與 suspended 狀態
"use client";
import { useState, useEffect, useCallback } from "react";
import {
Building2,
RefreshCw,
AlertCircle,
DollarSign,
Ban,
CheckCircle2,
} from "lucide-react";
import { cn } from "@/lib/utils";
// =============================================================================
// Types
// =============================================================================
type MigrationMode =
| "shadow"
| "canary"
| "read_only"
| "suggest"
| "auto_remediate";
interface Tenant {
project_id: string;
name: string;
migration_mode: MigrationMode;
budget_limit_usd: number | null;
is_suspended: boolean;
}
interface ApiResponse {
items: Tenant[];
total: number;
}
// =============================================================================
// 常數
// =============================================================================
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "";
const MIGRATION_MODE_CONFIG: Record<
MigrationMode,
{ label: string; bg: string; text: string; border: string }
> = {
shadow: {
label: "Shadow",
bg: "bg-gray-800",
text: "text-gray-300",
border: "border-gray-600",
},
canary: {
label: "Canary",
bg: "bg-yellow-900/40",
text: "text-yellow-300",
border: "border-yellow-600/40",
},
read_only: {
label: "Read Only",
bg: "bg-blue-900/40",
text: "text-blue-300",
border: "border-blue-600/40",
},
suggest: {
label: "Suggest",
bg: "bg-purple-900/40",
text: "text-purple-300",
border: "border-purple-600/40",
},
auto_remediate: {
label: "Auto Remediate",
bg: "bg-green-900/40",
text: "text-green-300",
border: "border-green-600/40",
},
};
// =============================================================================
// Sub Components
// =============================================================================
function MigrationModeBadge({ mode }: { mode: MigrationMode }) {
const config = MIGRATION_MODE_CONFIG[mode] ?? MIGRATION_MODE_CONFIG.shadow;
return (
<span
className={cn(
"inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-mono font-medium border",
config.bg,
config.text,
config.border
)}
>
{config.label}
</span>
);
}
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">
<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">
<CheckCircle2 className="w-3 h-3" aria-hidden="true" />
</span>
);
}
function TenantRow({ tenant }: { tenant: Tenant }) {
return (
<tr className="border-b border-border hover:bg-accent/30 transition-colors">
<td className="px-4 py-3">
<span className="font-mono text-sm text-foreground">
{tenant.project_id}
</span>
</td>
<td className="px-4 py-3">
<span className="text-sm text-foreground font-medium">{tenant.name || "--"}</span>
</td>
<td className="px-4 py-3">
<MigrationModeBadge mode={tenant.migration_mode} />
</td>
<td className="px-4 py-3">
<span className="flex items-center gap-1 text-sm text-muted-foreground font-mono">
{tenant.budget_limit_usd != null ? (
<>
<DollarSign className="w-3.5 h-3.5" aria-hidden="true" />
{tenant.budget_limit_usd.toLocaleString("en-US", {
minimumFractionDigits: 2,
})}
</>
) : (
"--"
)}
</span>
</td>
<td className="px-4 py-3">
<SuspendedBadge suspended={tenant.is_suspended} />
</td>
</tr>
);
}
// =============================================================================
// Main Component
// =============================================================================
export default function TenantsPage() {
const [tenants, setTenants] = useState<Tenant[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchTenants = useCallback(async () => {
try {
setError(null);
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 : []);
} catch (err) {
setError(err instanceof Error ? err.message : "載入失敗");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchTenants();
}, [fetchTenants]);
return (
<div className="space-y-6">
{/* Page Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Building2 className="w-5 h-5 text-brand-accent" aria-hidden="true" />
<div>
<h2 className="text-lg font-semibold text-foreground"></h2>
<p className="text-xs text-muted-foreground">
{loading ? "載入中..." : `${tenants.length} 個租戶`}
</p>
</div>
</div>
<button
onClick={fetchTenants}
disabled={loading}
className="flex items-center gap-2 px-3 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg transition disabled:opacity-50"
aria-label="重新整理租戶清單"
>
<RefreshCw className={cn("w-4 h-4", loading && "animate-spin")} aria-hidden="true" />
</button>
</div>
{/* Error State */}
{error && (
<div className="flex items-start gap-3 p-4 bg-red-900/20 border border-red-800/40 rounded-lg">
<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>
<p className="text-xs text-red-400 mt-1">{error}</p>
</div>
</div>
)}
{/* Table */}
<div className="bg-card border border-border rounded-xl overflow-hidden shadow-sm">
<div className="overflow-x-auto">
<table className="w-full" role="table" aria-label="租戶清單">
<thead>
<tr className="border-b border-border bg-muted/50">
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Project ID
</th>
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
</th>
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
</th>
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
(USD)
</th>
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
</th>
</tr>
</thead>
<tbody>
{loading ? (
// Loading skeleton
Array.from({ length: 5 }).map((_, i) => (
<tr key={i} className="border-b border-border">
{Array.from({ length: 5 }).map((_, j) => (
<td key={j} className="px-4 py-3">
<div className="h-5 bg-muted animate-pulse rounded w-24" />
</td>
))}
</tr>
))
) : tenants.length === 0 && !error ? (
<tr>
<td colSpan={5} className="px-4 py-16 text-center">
<Building2 className="w-10 h-10 text-muted-foreground/30 mx-auto mb-3" aria-hidden="true" />
<p className="text-sm text-muted-foreground"></p>
</td>
</tr>
) : (
tenants.map((tenant) => (
<TenantRow key={tenant.project_id} tenant={tenant} />
))
)}
</tbody>
</table>
</div>
</div>
</div>
);
}