1186 lines
46 KiB
TypeScript
1186 lines
46 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useCallback } from "react";
|
|
import { useTranslations, useFormatter } from "next-intl";
|
|
import type { PiecedTenant, TenantRequest } from "@/types";
|
|
import { StatusBadge } from "@/components/ui/status-badge";
|
|
import { Modal } from "@/components/ui/modal";
|
|
import {
|
|
applyTableView,
|
|
nextSort,
|
|
SearchInput,
|
|
SortableTh,
|
|
Pagination,
|
|
type SortState,
|
|
} from "@/components/admin/table-controls";
|
|
import { formatDateTime, formatRelative } from "@/lib/format";
|
|
import Link from "next/link";
|
|
|
|
type Tab = "requests" | "tenants" | "health";
|
|
type RequestFilter = "all" | "pending" | "provisioning" | "approved" | "rejected";
|
|
|
|
interface HealthData {
|
|
tenants: { total: number; phases: Record<string, number> };
|
|
spend: { global: number; perTenant: Record<string, number> };
|
|
services: {
|
|
litellm: { healthy: boolean; details?: any };
|
|
vllm: { healthy: boolean; details?: any };
|
|
};
|
|
}
|
|
|
|
interface AdminPanelProps {
|
|
initialTenants: PiecedTenant[];
|
|
}
|
|
|
|
export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
|
const t = useTranslations("admin");
|
|
const f = useFormatter();
|
|
const [tab, setTab] = useState<Tab>("requests");
|
|
|
|
// Requests state
|
|
const [requests, setRequests] = useState<TenantRequest[]>([]);
|
|
const [filter, setFilter] = useState<RequestFilter>("all");
|
|
const [loadingRequests, setLoadingRequests] = useState(true);
|
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
|
const [rejectModal, setRejectModal] = useState<string | null>(null);
|
|
const [rejectNotes, setRejectNotes] = useState("");
|
|
// Approve is the highest-consequence request action — it provisions
|
|
// real infrastructure and triggers the billable setup fee — so it now
|
|
// goes through a confirmation modal like reject/delete, instead of
|
|
// firing on a single click.
|
|
const [approveModal, setApproveModal] = useState<string | null>(null);
|
|
|
|
// Tenants state
|
|
const [tenants, setTenants] = useState<PiecedTenant[]>(initialTenants);
|
|
const [loadingTenants, setLoadingTenants] = useState(false);
|
|
const [deleteModal, setDeleteModal] = useState<string | null>(null);
|
|
|
|
// Health state
|
|
const [health, setHealth] = useState<HealthData | null>(null);
|
|
const [loadingHealth, setLoadingHealth] = useState(false);
|
|
|
|
// Shared
|
|
const [error, setError] = useState("");
|
|
|
|
// Client-side table view state (search / sort / page) for each tab.
|
|
const [reqSearch, setReqSearch] = useState("");
|
|
const [reqSort, setReqSort] = useState<SortState>({
|
|
key: "created",
|
|
dir: "desc",
|
|
});
|
|
const [reqPage, setReqPage] = useState(1);
|
|
|
|
const [tenSearch, setTenSearch] = useState("");
|
|
const [tenSort, setTenSort] = useState<SortState>({
|
|
key: "created",
|
|
dir: "desc",
|
|
});
|
|
const [tenPage, setTenPage] = useState(1);
|
|
// Action-scoped error — shown inside the active confirmation modal so
|
|
// a failed approve/reject/delete surfaces next to the action that
|
|
// caused it (and keeps the modal open), rather than as a detached
|
|
// panel-level banner that isn't tied to any row.
|
|
const [actionError, setActionError] = useState("");
|
|
|
|
// ─── Requests fetching ───
|
|
const fetchRequests = useCallback(async () => {
|
|
try {
|
|
const url =
|
|
filter === "all"
|
|
? "/api/admin/requests"
|
|
: `/api/admin/requests?status=${filter}`;
|
|
const res = await fetch(url);
|
|
if (!res.ok) throw new Error("Failed to fetch");
|
|
const data = await res.json();
|
|
setRequests(data);
|
|
} catch (e: any) {
|
|
setError(e.message);
|
|
} finally {
|
|
setLoadingRequests(false);
|
|
}
|
|
}, [filter]);
|
|
|
|
useEffect(() => {
|
|
if (tab === "requests") {
|
|
setLoadingRequests(true);
|
|
fetchRequests();
|
|
}
|
|
}, [tab, filter, fetchRequests]);
|
|
|
|
// ─── Tenants fetching ───
|
|
const fetchTenants = useCallback(async () => {
|
|
setLoadingTenants(true);
|
|
try {
|
|
const res = await fetch("/api/tenants");
|
|
if (!res.ok) throw new Error("Failed to fetch tenants");
|
|
const data = await res.json();
|
|
setTenants(data);
|
|
} catch (e: any) {
|
|
setError(e.message);
|
|
} finally {
|
|
setLoadingTenants(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (tab === "tenants") {
|
|
fetchTenants();
|
|
}
|
|
}, [tab, fetchTenants]);
|
|
|
|
// ─── Health fetching ───
|
|
const fetchHealth = useCallback(async () => {
|
|
setLoadingHealth(true);
|
|
try {
|
|
const res = await fetch("/api/admin/health");
|
|
if (!res.ok) throw new Error("Failed to fetch health");
|
|
const data = await res.json();
|
|
setHealth(data);
|
|
} catch (e: any) {
|
|
setError(e.message);
|
|
} finally {
|
|
setLoadingHealth(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (tab === "health") {
|
|
fetchHealth();
|
|
}
|
|
}, [tab, fetchHealth]);
|
|
|
|
// Also fetch health for spend data when on tenants tab
|
|
useEffect(() => {
|
|
if (tab === "tenants" && !health) {
|
|
fetchHealth();
|
|
}
|
|
}, [tab, health, fetchHealth]);
|
|
|
|
// ─── Request actions ───
|
|
const handleApprove = async (id: string) => {
|
|
setActionLoading(id);
|
|
setActionError("");
|
|
try {
|
|
const res = await fetch(`/api/admin/requests/${id}/approve`, {
|
|
method: "POST",
|
|
});
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({}));
|
|
throw new Error(data.error || "Approve failed");
|
|
}
|
|
setApproveModal(null);
|
|
await fetchRequests();
|
|
} catch (e: any) {
|
|
// Keep the modal open so the admin sees why provisioning didn't
|
|
// start; the error renders inside the dialog next to the action.
|
|
setActionError(e.message);
|
|
} finally {
|
|
setActionLoading(null);
|
|
}
|
|
};
|
|
|
|
const handleReject = async (id: string) => {
|
|
setActionLoading(id);
|
|
setActionError("");
|
|
try {
|
|
const res = await fetch(`/api/admin/requests/${id}/reject`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ adminNotes: rejectNotes || undefined }),
|
|
});
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({}));
|
|
throw new Error(data.error || "Reject failed");
|
|
}
|
|
setRejectModal(null);
|
|
setRejectNotes("");
|
|
await fetchRequests();
|
|
} catch (e: any) {
|
|
setActionError(e.message);
|
|
} finally {
|
|
setActionLoading(null);
|
|
}
|
|
};
|
|
|
|
// ─── Tenant actions ───
|
|
const handleSuspend = async (name: string, suspend: boolean) => {
|
|
setActionLoading(name);
|
|
setError("");
|
|
try {
|
|
const res = await fetch(`/api/admin/tenants/${name}/suspend`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ suspend }),
|
|
});
|
|
if (!res.ok) {
|
|
const data = await res.json();
|
|
throw new Error(data.error || "Suspend failed");
|
|
}
|
|
await fetchTenants();
|
|
} catch (e: any) {
|
|
setError(e.message);
|
|
} finally {
|
|
setActionLoading(null);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (name: string) => {
|
|
setActionLoading(name);
|
|
setActionError("");
|
|
try {
|
|
const res = await fetch(`/api/admin/tenants/${name}/delete`, {
|
|
method: "POST",
|
|
});
|
|
if (!res.ok) {
|
|
const data = await res.json();
|
|
throw new Error(data.error || "Delete failed");
|
|
}
|
|
setDeleteModal(null);
|
|
// Bug 32: K8s deletion is asynchronous — the resource enters a
|
|
// Terminating phase with a deletionTimestamp set, finalizers run,
|
|
// then the resource is fully removed. fetchTenants() right
|
|
// after the API call would race the K8s store and often still
|
|
// include the just-deleted row. Two complementary fixes:
|
|
// 1. Optimistically drop the row from local state so the UI
|
|
// reflects the user's intent immediately.
|
|
// 2. Schedule a delayed refetch (1.5s) to pick up any side
|
|
// effects (cascaded request rows, freshly-released names).
|
|
// The immediate fetchTenants() is kept as a "best chance" — if
|
|
// K8s does report the deletion synchronously (rare), we get the
|
|
// freshest data. If it doesn't, the optimistic update has us
|
|
// covered until the delayed refetch lands.
|
|
setTenants((prev) => prev.filter((t) => t.metadata.name !== name));
|
|
fetchTenants();
|
|
setTimeout(() => fetchTenants(), 1500);
|
|
} catch (e: any) {
|
|
setActionError(e.message);
|
|
} finally {
|
|
setActionLoading(null);
|
|
}
|
|
};
|
|
|
|
const FILTERS: RequestFilter[] = [
|
|
"all",
|
|
"pending",
|
|
"provisioning",
|
|
"approved",
|
|
"rejected",
|
|
];
|
|
|
|
const pendingCount = requests.filter((r) => r.status === "pending").length;
|
|
|
|
// Derived table views: search → sort → paginate, applied client-side
|
|
// on top of the already-fetched lists.
|
|
const reqView = applyTableView(requests, {
|
|
search: reqSearch,
|
|
searchOf: (r) => [
|
|
r.companyName,
|
|
r.contactName,
|
|
r.contactEmail,
|
|
r.agentName,
|
|
r.tenantName,
|
|
],
|
|
sort: reqSort,
|
|
sortOf: (r, key) =>
|
|
key === "company"
|
|
? r.companyName || ""
|
|
: key === "status"
|
|
? r.status || ""
|
|
: r.createdAt || "",
|
|
page: reqPage,
|
|
});
|
|
|
|
const tenView = applyTableView(tenants, {
|
|
search: tenSearch,
|
|
searchOf: (tn) => [
|
|
tn.metadata.name,
|
|
tn.spec.displayName,
|
|
tn.spec.agentName,
|
|
],
|
|
sort: tenSort,
|
|
sortOf: (tn, key) =>
|
|
key === "name"
|
|
? tn.spec.displayName || tn.metadata.name
|
|
: key === "phase"
|
|
? tn.status?.phase || "Pending"
|
|
: tn.metadata.creationTimestamp || "",
|
|
page: tenPage,
|
|
});
|
|
|
|
const onReqSort = (key: string) => {
|
|
setReqSort((s) => nextSort(s, key));
|
|
setReqPage(1);
|
|
};
|
|
const onTenSort = (key: string) => {
|
|
setTenSort((s) => nextSort(s, key));
|
|
setTenPage(1);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{/* Tab bar */}
|
|
<div className="flex gap-1 border-b border-border mb-6">
|
|
<button
|
|
onClick={() => setTab("requests")}
|
|
className={`px-4 py-2.5 text-sm font-medium transition-colors relative ${
|
|
tab === "requests"
|
|
? "text-accent"
|
|
: "text-text-muted hover:text-text-secondary"
|
|
}`}
|
|
>
|
|
{t("requests")}
|
|
{pendingCount > 0 && tab !== "requests" && (
|
|
<span className="ml-1.5 inline-flex items-center justify-center h-4 min-w-[16px] px-1 text-[10px] font-bold bg-accent text-surface-0 rounded-full">
|
|
{pendingCount}
|
|
</span>
|
|
)}
|
|
{tab === "requests" && (
|
|
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-accent" />
|
|
)}
|
|
</button>
|
|
<button
|
|
onClick={() => setTab("tenants")}
|
|
className={`px-4 py-2.5 text-sm font-medium transition-colors relative ${
|
|
tab === "tenants"
|
|
? "text-accent"
|
|
: "text-text-muted hover:text-text-secondary"
|
|
}`}
|
|
>
|
|
{t("allTenants")}
|
|
<span className="ml-1.5 text-xs text-text-muted tabular-nums">
|
|
{tenants.length}
|
|
</span>
|
|
{tab === "tenants" && (
|
|
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-accent" />
|
|
)}
|
|
</button>
|
|
<button
|
|
onClick={() => setTab("health")}
|
|
className={`px-4 py-2.5 text-sm font-medium transition-colors relative ${
|
|
tab === "health"
|
|
? "text-accent"
|
|
: "text-text-muted hover:text-text-secondary"
|
|
}`}
|
|
>
|
|
{t("health")}
|
|
{tab === "health" && (
|
|
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-accent" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Error banner */}
|
|
{error && (
|
|
<div className="mb-4 text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2">
|
|
{error}
|
|
<button
|
|
onClick={() => setError("")}
|
|
className="ml-2 text-red-300 hover:text-red-200"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* ───── REQUESTS TAB ───── */}
|
|
{tab === "requests" && (
|
|
<>
|
|
<div className="flex items-center justify-between gap-3 mb-4 flex-wrap">
|
|
<div className="flex gap-1.5 flex-wrap">
|
|
{FILTERS.map((f) => (
|
|
<button
|
|
key={f}
|
|
onClick={() => {
|
|
setFilter(f);
|
|
setReqPage(1);
|
|
}}
|
|
className={`px-3 py-1 text-xs rounded-full transition-colors ${
|
|
filter === f
|
|
? "bg-accent text-surface-0"
|
|
: "bg-surface-2 text-text-muted hover:text-text-secondary border border-border"
|
|
}`}
|
|
>
|
|
{t(`filter_${f}`)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<SearchInput
|
|
value={reqSearch}
|
|
onChange={(v) => {
|
|
setReqSearch(v);
|
|
setReqPage(1);
|
|
}}
|
|
placeholder={t("searchRequestsPlaceholder")}
|
|
/>
|
|
</div>
|
|
|
|
{loadingRequests ? (
|
|
<div className="bg-surface-1 border border-border rounded-xl p-12 text-center">
|
|
<div className="h-5 w-5 border-2 border-accent border-t-transparent rounded-full animate-spin mx-auto mb-2" />
|
|
<p className="text-text-muted text-xs">{t("loadingRequests")}</p>
|
|
</div>
|
|
) : requests.length === 0 ? (
|
|
<div className="bg-surface-1 border border-border rounded-xl p-12 text-center">
|
|
<p className="text-text-secondary text-sm">{t("noRequests")}</p>
|
|
</div>
|
|
) : reqView.total === 0 ? (
|
|
<div className="bg-surface-1 border border-border rounded-xl p-12 text-center">
|
|
<p className="text-text-secondary text-sm">{t("noMatches")}</p>
|
|
</div>
|
|
) : (
|
|
<div className="bg-surface-1 border border-border rounded-xl overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-border text-left">
|
|
<SortableTh
|
|
label={t("company")}
|
|
sortKey="company"
|
|
sort={reqSort}
|
|
onSort={onReqSort}
|
|
/>
|
|
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
|
|
{t("contact")}
|
|
</th>
|
|
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden md:table-cell">
|
|
{t("agentName")}
|
|
</th>
|
|
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden lg:table-cell">
|
|
{t("packages")}
|
|
</th>
|
|
<SortableTh
|
|
label={t("status")}
|
|
sortKey="status"
|
|
sort={reqSort}
|
|
onSort={onReqSort}
|
|
/>
|
|
<SortableTh
|
|
label={t("submitted")}
|
|
sortKey="created"
|
|
sort={reqSort}
|
|
onSort={onReqSort}
|
|
className="hidden md:table-cell"
|
|
/>
|
|
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
|
|
{t("actions")}
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{reqView.paged.map((req) => (
|
|
<tr
|
|
key={req.id}
|
|
className="border-b border-border last:border-0 hover:bg-surface-2/50 transition-colors"
|
|
>
|
|
<td className="px-4 py-3">
|
|
<div className="font-medium text-text-primary text-sm flex items-center gap-2">
|
|
{req.companyName}
|
|
{/* Bug 37a: distinguish resume requests in the
|
|
queue. Provision and resume share status
|
|
semantics but very different action
|
|
consequences — a resume approval just
|
|
un-suspends an existing tenant, no
|
|
provisioning workflow runs. */}
|
|
{req.requestType === "resume" && (
|
|
<span
|
|
className="px-1.5 py-0.5 text-[10px] font-semibold rounded uppercase tracking-wider bg-success/15 text-success"
|
|
title={t("resumeRequestTooltip")}
|
|
>
|
|
{t("resumeRequestBadge")}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{req.requestType === "resume" && req.tenantName && (
|
|
<div className="text-text-muted text-xs font-mono mt-0.5">
|
|
{req.tenantName}
|
|
</div>
|
|
)}
|
|
{/* Feature 6: customer's reactivation rationale,
|
|
shown inline so admin can triage without
|
|
opening a detail view. Truncated for
|
|
queue density; full content on hover. */}
|
|
{req.requestType === "resume" && req.customerNotes && (
|
|
<div
|
|
className="text-text-secondary text-xs mt-1 max-w-[280px] line-clamp-2 whitespace-pre-wrap"
|
|
title={req.customerNotes}
|
|
>
|
|
{req.customerNotes}
|
|
</div>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<div className="text-text-primary text-sm">
|
|
{req.contactName}
|
|
</div>
|
|
<div className="text-text-muted text-xs">
|
|
{req.contactEmail}
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3 font-mono text-xs text-text-secondary hidden md:table-cell">
|
|
{req.agentName}
|
|
</td>
|
|
<td className="px-4 py-3 text-xs text-text-secondary font-mono hidden lg:table-cell">
|
|
{req.packages?.length
|
|
? req.packages.join(", ")
|
|
: "—"}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<RequestStatusBadge status={req.status} />
|
|
</td>
|
|
<td className="px-4 py-3 text-xs text-text-muted tabular-nums hidden md:table-cell">
|
|
<div
|
|
title={`${t("submitted")}: ${formatDateTime(req.createdAt, f)}${
|
|
req.updatedAt && req.updatedAt !== req.createdAt
|
|
? `\n${t("updated")}: ${formatDateTime(req.updatedAt, f)}`
|
|
: ""
|
|
}`}
|
|
className="leading-tight"
|
|
>
|
|
<div>{formatDateTime(req.createdAt, f)}</div>
|
|
<div className="text-[10px] text-text-muted/70">
|
|
{formatRelative(req.createdAt, f)}
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<div className="flex gap-1.5">
|
|
{req.status === "pending" && (
|
|
<>
|
|
<button
|
|
onClick={() => {
|
|
setActionError("");
|
|
setApproveModal(req.id);
|
|
}}
|
|
disabled={actionLoading === req.id}
|
|
className="px-2.5 py-1 text-xs font-medium bg-emerald-500/15 text-emerald-400 rounded-md hover:bg-emerald-500/25 transition-colors disabled:opacity-50"
|
|
>
|
|
{t("approve")}
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
setActionError("");
|
|
setRejectModal(req.id);
|
|
}}
|
|
disabled={actionLoading === req.id}
|
|
className="px-2.5 py-1 text-xs font-medium bg-red-500/15 text-red-400 rounded-md hover:bg-red-500/25 transition-colors disabled:opacity-50"
|
|
>
|
|
{t("reject")}
|
|
</button>
|
|
</>
|
|
)}
|
|
{(req.status === "provisioning" ||
|
|
req.status === "approved" ||
|
|
req.status === "active") &&
|
|
req.tenantName && (
|
|
<Link
|
|
href={`/tenants/${req.tenantName}`}
|
|
className="px-2.5 py-1 text-xs font-medium bg-accent/15 text-accent rounded-md hover:bg-accent/25 transition-colors"
|
|
>
|
|
{t("viewTenant")}
|
|
</Link>
|
|
)}
|
|
{req.status === "rejected" && (
|
|
<button
|
|
onClick={() => {
|
|
setActionError("");
|
|
setApproveModal(req.id);
|
|
}}
|
|
disabled={actionLoading === req.id}
|
|
className="px-2.5 py-1 text-xs font-medium bg-amber-500/15 text-amber-400 rounded-md hover:bg-amber-500/25 transition-colors disabled:opacity-50"
|
|
>
|
|
{t("reApprove")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
{req.adminNotes && (
|
|
<p className="text-[11px] text-text-muted mt-1 max-w-[200px] truncate">
|
|
{req.adminNotes}
|
|
</p>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<Pagination
|
|
page={reqView.page}
|
|
totalPages={reqView.totalPages}
|
|
total={reqView.total}
|
|
onPage={setReqPage}
|
|
/>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* ───── TENANTS TAB ───── */}
|
|
{tab === "tenants" && (
|
|
<>
|
|
{/* Summary cards */}
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
|
|
<SummaryCard
|
|
label={t("totalTenants")}
|
|
value={tenants.length}
|
|
/>
|
|
<SummaryCard
|
|
label={t("running")}
|
|
value={
|
|
tenants.filter(
|
|
(t) => t.status?.phase === "Running" || t.status?.phase === "Ready"
|
|
).length
|
|
}
|
|
color="text-emerald-400"
|
|
/>
|
|
<SummaryCard
|
|
label={t("suspended")}
|
|
value={tenants.filter((t) => t.spec.suspend).length}
|
|
color="text-amber-400"
|
|
/>
|
|
<SummaryCard
|
|
label={t("errors")}
|
|
value={
|
|
tenants.filter((t) => t.status?.phase === "Error").length
|
|
}
|
|
color="text-red-400"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex justify-end mb-4">
|
|
<SearchInput
|
|
value={tenSearch}
|
|
onChange={(v) => {
|
|
setTenSearch(v);
|
|
setTenPage(1);
|
|
}}
|
|
placeholder={t("searchTenantsPlaceholder")}
|
|
/>
|
|
</div>
|
|
|
|
{loadingTenants ? (
|
|
<div className="bg-surface-1 border border-border rounded-xl p-12 text-center">
|
|
<div className="h-5 w-5 border-2 border-accent border-t-transparent rounded-full animate-spin mx-auto mb-2" />
|
|
<p className="text-text-muted text-xs">{t("loadingTenants")}</p>
|
|
</div>
|
|
) : tenants.length === 0 ? (
|
|
<div className="bg-surface-1 border border-border rounded-xl p-12 text-center">
|
|
<p className="text-text-secondary text-sm">{t("noTenants")}</p>
|
|
</div>
|
|
) : tenView.total === 0 ? (
|
|
<div className="bg-surface-1 border border-border rounded-xl p-12 text-center">
|
|
<p className="text-text-secondary text-sm">{t("noMatches")}</p>
|
|
</div>
|
|
) : (
|
|
<div className="bg-surface-1 border border-border rounded-xl overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-border text-left">
|
|
<SortableTh
|
|
label={t("name")}
|
|
sortKey="name"
|
|
sort={tenSort}
|
|
onSort={onTenSort}
|
|
/>
|
|
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
|
|
{t("displayName")}
|
|
</th>
|
|
<SortableTh
|
|
label={t("phase")}
|
|
sortKey="phase"
|
|
sort={tenSort}
|
|
onSort={onTenSort}
|
|
/>
|
|
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden md:table-cell">
|
|
{t("packages")}
|
|
</th>
|
|
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden md:table-cell">
|
|
{t("spendChf")}
|
|
</th>
|
|
<SortableTh
|
|
label={t("created")}
|
|
sortKey="created"
|
|
sort={tenSort}
|
|
onSort={onTenSort}
|
|
className="hidden md:table-cell"
|
|
/>
|
|
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
|
|
{t("actions")}
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{tenView.paged.map((tenant) => {
|
|
const tenantSpend =
|
|
health?.spend?.perTenant?.[tenant.metadata.name];
|
|
return (
|
|
<tr
|
|
key={tenant.metadata.name}
|
|
className={`border-b border-border last:border-0 hover:bg-surface-2/50 transition-colors ${
|
|
tenant.spec.suspend ? "opacity-60" : ""
|
|
}`}
|
|
>
|
|
<td className="px-4 py-3 font-mono text-xs text-accent">
|
|
{tenant.metadata.name}
|
|
</td>
|
|
<td className="px-4 py-3 text-text-primary">
|
|
<span>{tenant.spec.displayName}</span>
|
|
{tenant.spec.suspend && (
|
|
<span className="ml-2 px-1.5 py-0.5 text-[10px] font-medium bg-amber-400/15 text-amber-400 rounded">
|
|
{t("suspendedBadge")}
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<StatusBadge
|
|
phase={tenant.status?.phase ?? "Pending"}
|
|
/>
|
|
</td>
|
|
<td className="px-4 py-3 text-xs text-text-secondary font-mono hidden md:table-cell">
|
|
{tenant.spec.packages?.join(", ") || "—"}
|
|
</td>
|
|
<td className="px-4 py-3 text-xs font-mono tabular-nums text-text-secondary hidden md:table-cell">
|
|
{tenantSpend !== undefined
|
|
? `CHF ${tenantSpend.toFixed(2)}`
|
|
: "—"}
|
|
</td>
|
|
<td className="px-4 py-3 text-xs text-text-muted tabular-nums hidden md:table-cell">
|
|
<div
|
|
title={formatDateTime(
|
|
tenant.metadata.creationTimestamp,
|
|
f
|
|
)}
|
|
className="leading-tight"
|
|
>
|
|
<div>
|
|
{formatDateTime(
|
|
tenant.metadata.creationTimestamp,
|
|
f
|
|
)}
|
|
</div>
|
|
<div className="text-[10px] text-text-muted/70">
|
|
{formatRelative(
|
|
tenant.metadata.creationTimestamp,
|
|
f
|
|
)}
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<div className="flex gap-1.5 flex-wrap">
|
|
<Link
|
|
href={`/tenants/${tenant.metadata.name}`}
|
|
className="px-2.5 py-1 text-xs font-medium bg-accent/15 text-accent rounded-md hover:bg-accent/25 transition-colors"
|
|
>
|
|
{t("manage")}
|
|
</Link>
|
|
<button
|
|
onClick={() =>
|
|
handleSuspend(
|
|
tenant.metadata.name,
|
|
!tenant.spec.suspend
|
|
)
|
|
}
|
|
disabled={actionLoading === tenant.metadata.name}
|
|
className="px-2.5 py-1 text-xs font-medium bg-amber-500/15 text-amber-400 rounded-md hover:bg-amber-500/25 transition-colors disabled:opacity-50"
|
|
>
|
|
{actionLoading === tenant.metadata.name
|
|
? "…"
|
|
: tenant.spec.suspend
|
|
? t("resume")
|
|
: t("suspend")}
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
setActionError("");
|
|
setDeleteModal(tenant.metadata.name);
|
|
}}
|
|
disabled={actionLoading === tenant.metadata.name}
|
|
className="px-2.5 py-1 text-xs font-medium bg-red-500/15 text-red-400 rounded-md hover:bg-red-500/25 transition-colors disabled:opacity-50"
|
|
>
|
|
{t("deleteTenant")}
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<Pagination
|
|
page={tenView.page}
|
|
totalPages={tenView.totalPages}
|
|
total={tenView.total}
|
|
onPage={setTenPage}
|
|
/>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* ───── HEALTH TAB ───── */}
|
|
{tab === "health" && (
|
|
<>
|
|
{loadingHealth ? (
|
|
<div className="bg-surface-1 border border-border rounded-xl p-12 text-center">
|
|
<div className="h-5 w-5 border-2 border-accent border-t-transparent rounded-full animate-spin mx-auto mb-2" />
|
|
<p className="text-text-muted text-xs">{t("loadingHealth")}</p>
|
|
</div>
|
|
) : health ? (
|
|
<div className="space-y-6">
|
|
{/* Service health indicators */}
|
|
<div>
|
|
<h3 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
|
{t("serviceHealth")}
|
|
</h3>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
<ServiceCard
|
|
name="vLLM"
|
|
subtitle={t("vllmDescription")}
|
|
healthy={health.services.vllm.healthy}
|
|
t={t}
|
|
/>
|
|
<ServiceCard
|
|
name="LiteLLM"
|
|
subtitle={t("litellmDescription")}
|
|
healthy={health.services.litellm.healthy}
|
|
t={t}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tenant overview */}
|
|
<div>
|
|
<h3 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
|
{t("tenantOverview")}
|
|
</h3>
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
<SummaryCard
|
|
label={t("totalTenants")}
|
|
value={health.tenants.total}
|
|
/>
|
|
<SummaryCard
|
|
label={t("running")}
|
|
value={
|
|
(health.tenants.phases["Running"] ?? 0) +
|
|
(health.tenants.phases["Ready"] ?? 0)
|
|
}
|
|
color="text-emerald-400"
|
|
/>
|
|
<SummaryCard
|
|
label={t("suspended")}
|
|
value={health.tenants.phases["Suspended"] ?? 0}
|
|
color="text-amber-400"
|
|
/>
|
|
<SummaryCard
|
|
label={t("errors")}
|
|
value={health.tenants.phases["Error"] ?? 0}
|
|
color="text-red-400"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Spend overview */}
|
|
<div>
|
|
<h3 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
|
{t("spendOverview")}
|
|
</h3>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
<div className="bg-surface-1 border border-border rounded-xl p-4">
|
|
<p className="text-xs text-text-muted uppercase tracking-wider mb-1">
|
|
{t("globalSpend")}
|
|
</p>
|
|
<p className="font-mono text-2xl font-semibold tabular-nums text-text-primary">
|
|
CHF {health.spend.global.toFixed(2)}
|
|
</p>
|
|
</div>
|
|
<div className="bg-surface-1 border border-border rounded-xl p-4">
|
|
<p className="text-xs text-text-muted uppercase tracking-wider mb-1">
|
|
{t("activeTenants")}
|
|
</p>
|
|
<p className="font-mono text-2xl font-semibold tabular-nums text-text-primary">
|
|
{Object.keys(health.spend.perTenant).length}
|
|
</p>
|
|
<p className="text-xs text-text-muted mt-1">
|
|
{t("tenantsWithSpend")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Refresh button */}
|
|
<div className="flex justify-end">
|
|
<button
|
|
onClick={fetchHealth}
|
|
disabled={loadingHealth}
|
|
className="px-4 py-2 text-xs font-medium bg-surface-2 border border-border rounded-lg text-text-secondary hover:text-text-primary transition-colors disabled:opacity-50"
|
|
>
|
|
{t("refresh")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="bg-surface-1 border border-border rounded-xl p-12 text-center">
|
|
<p className="text-text-secondary text-sm">{t("healthUnavailable")}</p>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* ───── APPROVE MODAL ───── */}
|
|
<Modal
|
|
open={!!approveModal}
|
|
onClose={() => {
|
|
setApproveModal(null);
|
|
setActionError("");
|
|
}}
|
|
ariaLabel={t("approveTitle")}
|
|
>
|
|
{approveModal &&
|
|
(() => {
|
|
const req = requests.find((r) => r.id === approveModal);
|
|
const isReapprove = req?.status === "rejected";
|
|
return (
|
|
<>
|
|
<h3 className="font-display text-lg font-semibold text-text-primary mb-2">
|
|
{t("approveTitle")}
|
|
</h3>
|
|
<p className="text-sm text-text-secondary mb-2">
|
|
{isReapprove
|
|
? t("approveReapproveWarning")
|
|
: t("approveWarning")}
|
|
</p>
|
|
{req && (
|
|
<p className="text-xs font-mono text-accent bg-surface-2 border border-border rounded-lg px-3 py-2 mb-4">
|
|
{req.companyName}
|
|
{req.agentName ? ` · ${req.agentName}` : ""}
|
|
</p>
|
|
)}
|
|
{actionError && (
|
|
<p className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mb-4">
|
|
{actionError}
|
|
</p>
|
|
)}
|
|
<div className="flex gap-2 justify-end">
|
|
<button
|
|
onClick={() => {
|
|
setApproveModal(null);
|
|
setActionError("");
|
|
}}
|
|
className="px-4 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
|
|
>
|
|
{t("cancelAction")}
|
|
</button>
|
|
<button
|
|
onClick={() => handleApprove(approveModal)}
|
|
disabled={actionLoading === approveModal}
|
|
className="px-4 py-2 text-sm font-medium bg-emerald-500/15 text-emerald-400 rounded-lg hover:bg-emerald-500/25 transition-colors disabled:opacity-50"
|
|
>
|
|
{actionLoading === approveModal ? "…" : t("confirmApprove")}
|
|
</button>
|
|
</div>
|
|
</>
|
|
);
|
|
})()}
|
|
</Modal>
|
|
|
|
{/* ───── REJECT MODAL ───── */}
|
|
<Modal
|
|
open={!!rejectModal}
|
|
onClose={() => {
|
|
setRejectModal(null);
|
|
setRejectNotes("");
|
|
setActionError("");
|
|
}}
|
|
ariaLabel={t("rejectTitle")}
|
|
>
|
|
{rejectModal && (
|
|
<>
|
|
<h3 className="font-display text-lg font-semibold text-text-primary mb-4">
|
|
{t("rejectTitle")}
|
|
</h3>
|
|
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
|
|
{t("adminNotesLabel")}
|
|
</label>
|
|
<textarea
|
|
value={rejectNotes}
|
|
onChange={(e) => setRejectNotes(e.target.value)}
|
|
placeholder={t("adminNotesPlaceholder")}
|
|
rows={3}
|
|
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors resize-none mb-4"
|
|
/>
|
|
{actionError && (
|
|
<p className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mb-4">
|
|
{actionError}
|
|
</p>
|
|
)}
|
|
<div className="flex gap-2 justify-end">
|
|
<button
|
|
onClick={() => {
|
|
setRejectModal(null);
|
|
setRejectNotes("");
|
|
setActionError("");
|
|
}}
|
|
className="px-4 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
|
|
>
|
|
{t("cancelAction")}
|
|
</button>
|
|
<button
|
|
onClick={() => handleReject(rejectModal)}
|
|
disabled={actionLoading === rejectModal}
|
|
className="px-4 py-2 text-sm font-medium bg-red-500/15 text-red-400 rounded-lg hover:bg-red-500/25 transition-colors disabled:opacity-50"
|
|
>
|
|
{actionLoading === rejectModal ? "…" : t("confirmReject")}
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</Modal>
|
|
|
|
{/* ───── DELETE MODAL ───── */}
|
|
<Modal
|
|
open={!!deleteModal}
|
|
onClose={() => {
|
|
setDeleteModal(null);
|
|
setActionError("");
|
|
}}
|
|
ariaLabel={t("deleteTitle")}
|
|
>
|
|
{deleteModal && (
|
|
<>
|
|
<h3 className="font-display text-lg font-semibold text-text-primary mb-2">
|
|
{t("deleteTitle")}
|
|
</h3>
|
|
<p className="text-sm text-text-secondary mb-2">
|
|
{t("deleteWarning")}
|
|
</p>
|
|
<p className="text-xs font-mono text-accent bg-surface-2 border border-border rounded-lg px-3 py-2 mb-4">
|
|
{deleteModal}
|
|
</p>
|
|
{actionError && (
|
|
<p className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mb-4">
|
|
{actionError}
|
|
</p>
|
|
)}
|
|
<div className="flex gap-2 justify-end">
|
|
<button
|
|
onClick={() => {
|
|
setDeleteModal(null);
|
|
setActionError("");
|
|
}}
|
|
className="px-4 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
|
|
>
|
|
{t("cancelAction")}
|
|
</button>
|
|
<button
|
|
onClick={() => handleDelete(deleteModal)}
|
|
disabled={actionLoading === deleteModal}
|
|
className="px-4 py-2 text-sm font-medium bg-red-500/15 text-red-400 rounded-lg hover:bg-red-500/25 transition-colors disabled:opacity-50"
|
|
>
|
|
{actionLoading === deleteModal ? "…" : t("confirmDelete")}
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</Modal>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function ServiceCard({
|
|
name,
|
|
subtitle,
|
|
healthy,
|
|
t,
|
|
}: {
|
|
name: string;
|
|
subtitle: string;
|
|
healthy: boolean;
|
|
t: any;
|
|
}) {
|
|
return (
|
|
<div className="bg-surface-1 border border-border rounded-xl p-4 flex items-center gap-4">
|
|
<div
|
|
className={`shrink-0 h-10 w-10 rounded-lg flex items-center justify-center ${
|
|
healthy ? "bg-emerald-400/15" : "bg-red-400/15"
|
|
}`}
|
|
>
|
|
<div
|
|
className={`h-3 w-3 rounded-full ${
|
|
healthy ? "bg-emerald-400" : "bg-red-400 animate-pulse"
|
|
}`}
|
|
/>
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-medium text-text-primary">{name}</p>
|
|
<p className="text-xs text-text-muted truncate">{subtitle}</p>
|
|
</div>
|
|
<div className="ml-auto shrink-0">
|
|
<span
|
|
className={`text-xs font-medium px-2 py-0.5 rounded-full ${
|
|
healthy
|
|
? "bg-emerald-400/15 text-emerald-400"
|
|
: "bg-red-400/15 text-red-400"
|
|
}`}
|
|
>
|
|
{healthy ? t("statusHealthy") : t("statusDown")}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function RequestStatusBadge({ status }: { status: string }) {
|
|
const colors: Record<string, string> = {
|
|
pending: "bg-blue-400/15 text-blue-400",
|
|
approved: "bg-emerald-400/15 text-emerald-400",
|
|
provisioning: "bg-amber-400/15 text-amber-400",
|
|
active: "bg-emerald-400/15 text-emerald-400",
|
|
rejected: "bg-red-400/15 text-red-400",
|
|
};
|
|
return (
|
|
<span
|
|
className={`inline-flex items-center gap-1.5 px-2 py-0.5 text-xs font-medium rounded-full ${colors[status] || "bg-surface-2 text-text-muted"}`}
|
|
>
|
|
<span
|
|
className={`h-1.5 w-1.5 rounded-full ${
|
|
status === "pending"
|
|
? "bg-blue-400"
|
|
: status === "approved" || status === "active"
|
|
? "bg-emerald-400"
|
|
: status === "provisioning"
|
|
? "bg-amber-400 animate-pulse"
|
|
: status === "rejected"
|
|
? "bg-red-400"
|
|
: "bg-text-muted"
|
|
}`}
|
|
/>
|
|
{status}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function SummaryCard({
|
|
label,
|
|
value,
|
|
color,
|
|
}: {
|
|
label: string;
|
|
value: number;
|
|
color?: string;
|
|
}) {
|
|
return (
|
|
<div className="bg-surface-1 border border-border rounded-xl p-4">
|
|
<p className="text-xs text-text-muted uppercase tracking-wider mb-1">
|
|
{label}
|
|
</p>
|
|
<p
|
|
className={`font-mono text-2xl font-semibold tabular-nums ${color || "text-text-primary"}`}
|
|
>
|
|
{value}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|