feat(awooop): Operator Console API + 前端(leWOOOgo 積木化修復)
All checks were successful
Code Review / ai-code-review (push) Successful in 42s
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:
495
apps/web/src/app/[locale]/awooop/approvals/[run_id]/page.tsx
Normal file
495
apps/web/src/app/[locale]/awooop/approvals/[run_id]/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
300
apps/web/src/app/[locale]/awooop/approvals/page.tsx
Normal file
300
apps/web/src/app/[locale]/awooop/approvals/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
281
apps/web/src/app/[locale]/awooop/contracts/page.tsx
Normal file
281
apps/web/src/app/[locale]/awooop/contracts/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
apps/web/src/app/[locale]/awooop/layout.tsx
Normal file
106
apps/web/src/app/[locale]/awooop/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
apps/web/src/app/[locale]/awooop/page.tsx
Normal file
9
apps/web/src/app/[locale]/awooop/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
// =============================================================================
|
||||
// WOOO AIOps - AwoooP Console 入口重導向
|
||||
// =============================================================================
|
||||
|
||||
import { redirect } from "@/i18n/routing";
|
||||
|
||||
export default function AwoooPPage() {
|
||||
redirect("/awooop/tenants");
|
||||
}
|
||||
458
apps/web/src/app/[locale]/awooop/runs/page.tsx
Normal file
458
apps/web/src/app/[locale]/awooop/runs/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
269
apps/web/src/app/[locale]/awooop/tenants/page.tsx
Normal file
269
apps/web/src/app/[locale]/awooop/tenants/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user