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

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