"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 }; spend: { global: number; perTenant: Record }; 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("requests"); // Requests state const [requests, setRequests] = useState([]); const [filter, setFilter] = useState("all"); const [loadingRequests, setLoadingRequests] = useState(true); const [actionLoading, setActionLoading] = useState(null); const [rejectModal, setRejectModal] = useState(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(null); // Tenants state const [tenants, setTenants] = useState(initialTenants); const [loadingTenants, setLoadingTenants] = useState(false); const [deleteModal, setDeleteModal] = useState(null); // Health state const [health, setHealth] = useState(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({ key: "created", dir: "desc", }); const [reqPage, setReqPage] = useState(1); const [tenSearch, setTenSearch] = useState(""); const [tenSort, setTenSort] = useState({ 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 */}
{/* Error banner */} {error && (
{error}
)} {/* ───── REQUESTS TAB ───── */} {tab === "requests" && ( <>
{FILTERS.map((f) => ( ))}
{ setReqSearch(v); setReqPage(1); }} placeholder={t("searchRequestsPlaceholder")} />
{loadingRequests ? (

{t("loadingRequests")}

) : requests.length === 0 ? (

{t("noRequests")}

) : reqView.total === 0 ? (

{t("noMatches")}

) : (
{reqView.paged.map((req) => ( ))}
{t("contact")} {t("agentName")} {t("packages")} {t("actions")}
{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" && ( {t("resumeRequestBadge")} )}
{req.requestType === "resume" && req.tenantName && (
{req.tenantName}
)} {/* 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 && (
{req.customerNotes}
)}
{req.contactName}
{req.contactEmail}
{req.agentName} {req.packages?.length ? req.packages.join(", ") : "—"}
{formatDateTime(req.createdAt, f)}
{formatRelative(req.createdAt, f)}
{req.status === "pending" && ( <> )} {(req.status === "provisioning" || req.status === "approved" || req.status === "active") && req.tenantName && ( {t("viewTenant")} )} {req.status === "rejected" && ( )}
{req.adminNotes && (

{req.adminNotes}

)}
)} )} {/* ───── TENANTS TAB ───── */} {tab === "tenants" && ( <> {/* Summary cards */}
t.status?.phase === "Running" || t.status?.phase === "Ready" ).length } color="text-emerald-400" /> t.spec.suspend).length} color="text-amber-400" /> t.status?.phase === "Error").length } color="text-red-400" />
{ setTenSearch(v); setTenPage(1); }} placeholder={t("searchTenantsPlaceholder")} />
{loadingTenants ? (

{t("loadingTenants")}

) : tenants.length === 0 ? (

{t("noTenants")}

) : tenView.total === 0 ? (

{t("noMatches")}

) : (
{tenView.paged.map((tenant) => { const tenantSpend = health?.spend?.perTenant?.[tenant.metadata.name]; return ( ); })}
{t("displayName")} {t("packages")} {t("spendChf")} {t("actions")}
{tenant.metadata.name} {tenant.spec.displayName} {tenant.spec.suspend && ( {t("suspendedBadge")} )} {tenant.spec.packages?.join(", ") || "—"} {tenantSpend !== undefined ? `CHF ${tenantSpend.toFixed(2)}` : "—"}
{formatDateTime( tenant.metadata.creationTimestamp, f )}
{formatRelative( tenant.metadata.creationTimestamp, f )}
{t("manage")}
)} )} {/* ───── HEALTH TAB ───── */} {tab === "health" && ( <> {loadingHealth ? (

{t("loadingHealth")}

) : health ? (
{/* Service health indicators */}

{t("serviceHealth")}

{/* Tenant overview */}

{t("tenantOverview")}

{/* Spend overview */}

{t("spendOverview")}

{t("globalSpend")}

CHF {health.spend.global.toFixed(2)}

{t("activeTenants")}

{Object.keys(health.spend.perTenant).length}

{t("tenantsWithSpend")}

{/* Refresh button */}
) : (

{t("healthUnavailable")}

)} )} {/* ───── APPROVE MODAL ───── */} { setApproveModal(null); setActionError(""); }} ariaLabel={t("approveTitle")} > {approveModal && (() => { const req = requests.find((r) => r.id === approveModal); const isReapprove = req?.status === "rejected"; return ( <>

{t("approveTitle")}

{isReapprove ? t("approveReapproveWarning") : t("approveWarning")}

{req && (

{req.companyName} {req.agentName ? ` · ${req.agentName}` : ""}

)} {actionError && (

{actionError}

)}
); })()}
{/* ───── REJECT MODAL ───── */} { setRejectModal(null); setRejectNotes(""); setActionError(""); }} ariaLabel={t("rejectTitle")} > {rejectModal && ( <>

{t("rejectTitle")}