import { useEffect, useMemo, useState, type FormEvent } from "react"; import { Link } from "react-router-dom"; import { api, type AdminLink, type CurrentUser, type PortEntry, type ServerRecord } from "../api/client"; import { useSortable } from "../hooks/useSortable"; import SortableTh from "../components/SortableTh"; import { downloadCsv } from "../utils/csv"; import { guessAdminUrl, portOptionLabel } from "../utils/adminLinkUrl"; import { confirmDialog } from "../utils/dialogs"; interface LinkForm { serverId: number | ""; label: string; url: string; } const emptyForm: LinkForm = { serverId: "", label: "", url: "" }; export default function AdminLinks({ user }: { user: CurrentUser }) { const canEdit = user.role === "admin" || user.role === "operator"; const [links, setLinks] = useState(null); const [servers, setServers] = useState(null); const [error, setError] = useState(null); const [search, setSearch] = useState(""); const [editing, setEditing] = useState(null); const [adding, setAdding] = useState(false); const [form, setForm] = useState(emptyForm); const [saving, setSaving] = useState(false); const [portOptions, setPortOptions] = useState(null); const [portAddress, setPortAddress] = useState(null); const [loadingPorts, setLoadingPorts] = useState(false); const [portPick, setPortPick] = useState(""); function load() { api.servers.allLinks() .then((res) => setLinks(res.links)) .catch((err) => setError(err instanceof Error ? err.message : String(err))); } useEffect(() => { load(); api.servers .list() .then((res) => setServers(res.servers)) .catch(() => {}); }, []); // Once a server is picked in the add/edit form, offer to fill the URL from one of its own known // ports (agent-reported or already noted on its Ports card) instead of typing it out by hand. useEffect(() => { setPortPick(""); if (form.serverId === "") { setPortOptions(null); setPortAddress(null); return; } const serverId = form.serverId; setLoadingPorts(true); Promise.all([api.servers.detail(serverId), api.servers.ports.list(serverId)]) .then(([detail, portList]) => { setPortAddress(detail.server.hostname || detail.ipAddresses[0] || null); setPortOptions(portList.ports); }) .catch(() => { setPortOptions(null); setPortAddress(null); }) .finally(() => setLoadingPorts(false)); }, [form.serverId]); function pickPort(key: string) { setPortPick(key); const entry = portOptions?.find((p) => `${p.protocol}:${p.port}` === key); if (!entry) return; const url = guessAdminUrl(portAddress, entry); setForm((f) => ({ ...f, url: url ?? f.url, label: f.label || entry.label || entry.agent?.process || "" })); } const filtered = useMemo(() => { if (!links) return []; const q = search.trim().toLowerCase(); if (!q) return links; return links.filter((l) => [l.serverName, l.serverHostname, l.label, l.url].some((v) => (v ?? "").toLowerCase().includes(q))); }, [links, search]); const { sorted, sortKey, sortDir, requestSort } = useSortable(filtered, "serverName"); function exportCsv() { downloadCsv( "admin-links.csv", ["Server", "Label", "URL"], (sorted ?? []).map((l) => [l.serverName, l.label, l.url]), ); } function startAdd() { setAdding(true); setEditing(null); setForm(emptyForm); } function startEdit(link: AdminLink) { setEditing(link); setAdding(false); setForm({ serverId: link.serverId, label: link.label, url: link.url }); } function cancelForm() { setAdding(false); setEditing(null); setForm(emptyForm); } async function submit(e: FormEvent) { e.preventDefault(); if (form.serverId === "") return; setError(null); setSaving(true); try { if (editing) { await api.servers.updateLink(editing.serverId, editing.id, { label: form.label, url: form.url }); } else { await api.servers.addLink(form.serverId, { label: form.label, url: form.url }); } cancelForm(); load(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setSaving(false); } } async function remove(link: AdminLink) { if (!(await confirmDialog({ title: "Remove admin link", message: `Remove the "${link.label}" link from ${link.serverName}?`, confirmLabel: "Remove", danger: true }))) return; setError(null); try { await api.servers.removeLink(link.serverId, link.id); load(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } } const showForm = adding || editing; return ( <>

Admin Links

Every admin bookmark added to a server's own page (Infrastructure → Servers → a server → Admin Links) — Dockge, Webmin, Cockpit, and the like — in one place, instead of visiting each server to find them.

{error &&
{error}
} {canEdit && showForm && (

{editing ? `Edit "${editing.label}"` : "Add an admin link"}

{editing &&
Remove and re-add to move it to a different server.
}
setForm({ ...form, label: e.target.value })} />
setForm({ ...form, url: e.target.value })} />
{form.serverId !== "" && (
{portOptions && portOptions.length > 0 && !portAddress && (
This server has no known hostname or IP yet, so picking a port won't fill in an address — see its Ports card under Infrastructure → Servers.
)}
)}
)}
setSearch(e.target.value)} />
{canEdit && !showForm && ( )}
label="Server" sortKeyName="serverName" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> label="Label" sortKeyName="label" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> {canEdit && } {(sorted ?? []).map((l) => ( {canEdit && ( )} ))} {links !== null && links.length === 0 && ( )} {links !== null && links.length > 0 && (sorted ?? []).length === 0 && ( )}
URLActions
{l.serverName} {l.serverHostname &&
{l.serverHostname}
}
{l.label} {l.url}
No admin links added yet.
Nothing matches "{search}".
); }