"use client"; import { useMemo, useState } from "react"; import useSWR from "swr"; import AppShell from "@/components/AppShell"; import { ConfirmDialog, EmptyState, FormError, LoadError, Modal, PageHeader, SkeletonRows, Spinner, } from "@/components/ui"; import { useToast } from "@/components/toast"; import { api, fetcher } from "@/lib/api"; type CatalogEntry = { key: string; label: string; description: string; device_type: string; mcp_server: string | null; default_port: number; default_username: string | null; has_default_secret: boolean; secret_kind: string; env_defaults_configured: boolean; asterisk_container?: string | null; category: string; }; type Device = { id: number; name: string; catalog_key: string | null; device_type: string; address: string; port: number | null; mcp_server: string | null; username: string | null; has_secret: boolean; vessel_id: number | null; }; type Vessel = { id: number; name: string; site: string | null; public_ip: string | null; notes: string | null; devices?: Device[]; }; type SlotState = { enabled: boolean; port: string; address: string; username: string; secret: string; }; function defaultSlots(catalog: CatalogEntry[]): Record { return Object.fromEntries( catalog.map((c) => [ c.key, { enabled: ["proxmox", "pfsense", "docker_vm", "asterisk_geneseasx"].includes(c.key), port: String(c.default_port), address: "", username: c.default_username || "", secret: "", }, ]) ); } function secretPlaceholder(c: CatalogEntry, editing: boolean) { if (c.has_default_secret) return "configured in .env (leave blank to use)"; if (c.secret_kind === "api_key") return editing ? "leave blank to keep" : "API key"; return editing ? "leave blank to keep" : "password"; } export default function InventoryPage() { const { data: catalog, error: catalogError } = useSWR( "/api/vessels/catalog", fetcher ); const { data: vessels, error: vesselsError, isLoading, mutate } = useSWR( "/api/vessels", fetcher ); const toast = useToast(); const [open, setOpen] = useState(false); const [editId, setEditId] = useState(null); const [expanded, setExpanded] = useState(null); const [loadingDetail, setLoadingDetail] = useState(null); const [error, setError] = useState(null); const [saving, setSaving] = useState(false); const [query, setQuery] = useState(""); const [deleteId, setDeleteId] = useState(null); const [deleting, setDeleting] = useState(false); const [form, setForm] = useState({ name: "", site: "", public_ip: "", notes: "" }); const [slots, setSlots] = useState>({}); const filteredVessels = useMemo(() => { const q = query.trim().toLowerCase(); if (!q) return vessels || []; return (vessels || []).filter( (v) => v.name.toLowerCase().includes(q) || (v.site || "").toLowerCase().includes(q) || (v.public_ip || "").toLowerCase().includes(q) ); }, [vessels, query]); const catalogSorted = useMemo(() => { if (!catalog) return []; const order = { core: 0, network: 1, optional: 2 }; return [...catalog].sort( (a, b) => (order[a.category as keyof typeof order] ?? 9) - (order[b.category as keyof typeof order] ?? 9) ); }, [catalog]); const openCreate = () => { setEditId(null); setForm({ name: "", site: "", public_ip: "", notes: "" }); setSlots(catalog ? defaultSlots(catalog) : {}); setError(null); setOpen(true); }; const openEdit = async (id: number) => { setError(null); const detail = await api(`/api/vessels/${id}`); setEditId(id); setForm({ name: detail.name, site: detail.site || "", public_ip: detail.public_ip || "", notes: detail.notes || "", }); const base = catalog ? defaultSlots(catalog) : {}; for (const d of detail.devices || []) { if (d.catalog_key && base[d.catalog_key]) { base[d.catalog_key] = { enabled: true, port: d.port != null ? String(d.port) : base[d.catalog_key].port, address: d.address !== detail.public_ip ? d.address : "", username: d.username || "", secret: "", }; } } setSlots(base); setOpen(true); }; const toggleSlot = (key: string, enabled: boolean) => { setSlots((s) => ({ ...s, [key]: { ...s[key], enabled } })); }; const updateSlot = (key: string, patch: Partial) => { setSlots((s) => ({ ...s, [key]: { ...s[key], ...patch } })); }; const buildPayload = () => { const devices = Object.entries(slots) .filter(([, v]) => v.enabled) .map(([catalog_key, v]) => ({ catalog_key, enabled: true, port: v.port ? Number(v.port) : undefined, address: v.address || undefined, username: v.username || undefined, secret: v.secret || undefined, })); return { ...form, site: form.site || null, public_ip: form.public_ip || null, notes: form.notes || null, devices }; }; const submit = async (e: React.FormEvent) => { e.preventDefault(); setError(null); setSaving(true); try { const payload = buildPayload(); if (editId) { await api(`/api/vessels/${editId}`, { method: "PATCH", body: JSON.stringify(payload) }); } else { await api("/api/vessels", { method: "POST", body: JSON.stringify(payload) }); } setOpen(false); toast.success(editId ? "Vessel updated" : "Vessel created"); mutate(); } catch (err: any) { setError(err.message); toast.error(err.message || "Save failed"); } finally { setSaving(false); } }; const remove = async () => { if (deleteId == null) return; setDeleting(true); try { await api(`/api/vessels/${deleteId}`, { method: "DELETE" }); toast.success("Vessel deleted"); mutate(); } catch (err: any) { toast.error(err.message || "Delete failed"); } finally { setDeleting(false); setDeleteId(null); } }; const loadDetail = async (id: number) => { if (expanded === id) { setExpanded(null); return; } setLoadingDetail(id); try { const detail = await api(`/api/vessels/${id}`); mutate( (current) => current?.map((v) => (v.id === id ? { ...v, devices: detail.devices } : v)), false ); setExpanded(id); } catch (err: any) { toast.error(err.message || "Could not load vessel devices"); } finally { setLoadingDetail(null); } }; return ( {catalog ? "+ New vessel" : "Loading catalog…"} } /> {vessels && vessels.length > 0 && ( setQuery(e.target.value)} aria-label="Search vessels" /> )} {isLoading && !vessels ? ( ) : vesselsError ? ( mutate()} /> ) : (vessels || []).length === 0 ? ( + New vessel } /> ) : filteredVessels.length === 0 ? ( ) : (
{filteredVessels.map((v) => (
{expanded === v.id && v.devices && (
{v.devices.map((d) => ( ))}
Device Type Endpoint Port MCP
{d.name} {d.catalog_key || d.device_type} {d.address} {d.port ?? "—"} {d.mcp_server || "—"}
)}
))}
)} setOpen(false)} title={editId ? "Edit vessel" : "New vessel"}>
setForm({ ...form, name: e.target.value })} required />
setForm({ ...form, site: e.target.value })} />
setForm({ ...form, public_ip: e.target.value })} placeholder="e.g. 203.0.113.10" />