Add per-server admin-page links (Dockge, Webmin, Cockpit, etc.)

Each server's detail page gets an "Admin Links" card for bookmarking
that host's own web UIs -- container managers, Webmin, Cockpit, or
anything else reachable by URL -- so there's a quick way to jump
there without hunting down the address each time.

New server_links table (serverId FK, label, url, ON DELETE CASCADE so
removing a server cleans up its links automatically) and three new
routes: POST/PATCH/DELETE /api/servers/:id/links, gated to operator+
like the rest of this page's editing actions; the existing GET
/:id/detail now includes the server's links alongside hardware/DNS
info. URLs are validated to start with http:// or https:// server-side
(rejecting e.g. a javascript: URL that would otherwise render as a
clickable link).

Rendered as a row of pill buttons (label opens the URL in a new tab),
with inline edit/remove controls next to each when the viewer can
edit, and an "Add link" form matching the existing task-form style on
the same page.

Verified against a temp SQLite DB with real migrations: create/list/
update/delete all work, and deleting the parent server cascades to
remove its links rather than leaving them orphaned.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-17 22:51:10 +02:00
1 parent f4274c1ad8
commit 655862c94e
8 files changed
+1407 -4

No files matched your search

+13
View File
@@ -249,11 +249,18 @@ export interface DnsMatch {
providerType: string;
}
export interface ServerLink {
id: number;
label: string;
url: string;
}
export interface ServerDetail {
server: ServerRecord;
ipAddresses: string[];
hardware: ServerHardware;
dnsMatches: DnsMatch[];
links: ServerLink[];
}
export interface ServerUpdateInput {
@@ -631,6 +638,12 @@ export const api = {
request<{ server: ServerRecord }>(`/api/servers/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
detail: (id: number) => request<ServerDetail>(`/api/servers/${id}/detail`),
remove: (id: number) => request<void>(`/api/servers/${id}`, { method: "DELETE" }),
addLink: (id: number, data: { label: string; url: string }) =>
request<{ link: ServerLink }>(`/api/servers/${id}/links`, { method: "POST", body: JSON.stringify(data) }),
updateLink: (id: number, linkId: number, data: { label?: string; url?: string }) =>
request<{ link: ServerLink }>(`/api/servers/${id}/links/${linkId}`, { method: "PATCH", body: JSON.stringify(data) }),
removeLink: (id: number, linkId: number) =>
request<void>(`/api/servers/${id}/links/${linkId}`, { method: "DELETE" }),
},
tasks: {
list: (
+129 -1
View File
@@ -8,6 +8,7 @@ import {
type ProxmoxGuest,
type ScheduleType,
type ServerDetail as ServerDetailResponse,
type ServerLink,
type TaskRecord,
} from "../api/client";
import ServerTaskTable, { SCHEDULE_TYPE_LABELS } from "../components/ServerTaskTable";
@@ -82,6 +83,11 @@ export default function ServerDetail({ user }: { user: CurrentUser }) {
const [actingOnGuest, setActingOnGuest] = useState(false);
const [guestActionError, setGuestActionError] = useState<string | null>(null);
const [adminLinkForm, setAdminLinkForm] = useState<{ label: string; url: string } | null>(null);
const [editingAdminLinkId, setEditingAdminLinkId] = useState<number | null>(null);
const [savingAdminLink, setSavingAdminLink] = useState(false);
const [adminLinkError, setAdminLinkError] = useState<string | null>(null);
const loadDetail = useCallback(() => {
setRefreshing(true);
return api.servers
@@ -224,11 +230,59 @@ export default function ServerDetail({ user }: { user: CurrentUser }) {
}
}
function openAddAdminLink() {
setEditingAdminLinkId(null);
setAdminLinkError(null);
setAdminLinkForm({ label: "", url: "" });
}
function startEditAdminLink(link: ServerLink) {
setEditingAdminLinkId(link.id);
setAdminLinkError(null);
setAdminLinkForm({ label: link.label, url: link.url });
}
function cancelAdminLinkForm() {
setAdminLinkForm(null);
setEditingAdminLinkId(null);
setAdminLinkError(null);
}
async function submitAdminLink(e: React.FormEvent) {
e.preventDefault();
if (!adminLinkForm) return;
setSavingAdminLink(true);
setAdminLinkError(null);
try {
if (editingAdminLinkId) {
await api.servers.updateLink(serverId, editingAdminLinkId, adminLinkForm);
} else {
await api.servers.addLink(serverId, adminLinkForm);
}
cancelAdminLinkForm();
await loadDetail();
} catch (err) {
setAdminLinkError(err instanceof Error ? err.message : String(err));
} finally {
setSavingAdminLink(false);
}
}
async function removeAdminLink(link: ServerLink) {
if (!confirm(`Remove the "${link.label}" link?`)) return;
try {
await api.servers.removeLink(serverId, link.id);
await loadDetail();
} catch (err) {
setAdminLinkError(err instanceof Error ? err.message : String(err));
}
}
if (loading) return <div className="text-secondary">Loading…</div>;
if (error && !detail) return <div className="alert alert-danger">{error}</div>;
if (!detail) return null;
const { server, hardware, ipAddresses, dnsMatches } = detail;
const { server, hardware, ipAddresses, dnsMatches, links } = detail;
async function guestAction(action: "start" | "stop" | "restart" | "shutdown") {
if (!server.proxmoxIntegrationId || !server.proxmoxNode || !server.proxmoxGuestType || server.proxmoxVmid === null) return;
@@ -269,6 +323,80 @@ export default function ServerDetail({ user }: { user: CurrentUser }) {
</div>
{error && <div className="alert alert-danger">{error}</div>}
<div className="card mb-3">
<div className="card-header">
<h3 className="card-title">Admin Links</h3>
{canEditTasks && !adminLinkForm && (
<div className="card-actions">
<button className="btn btn-sm btn-primary" onClick={openAddAdminLink}>
Add link
</button>
</div>
)}
</div>
<div className="card-body">
{adminLinkError && <div className="alert alert-danger">{adminLinkError}</div>}
{adminLinkForm && (
<form onSubmit={submitAdminLink} className="row g-2 align-items-end mb-3">
<div className="col-md-3">
<label className="form-label">Label</label>
<input
className="form-control"
required
placeholder="e.g. Dockge, Webmin, Cockpit"
value={adminLinkForm.label}
onChange={(e) => setAdminLinkForm({ ...adminLinkForm, label: e.target.value })}
/>
</div>
<div className="col-md-6">
<label className="form-label">URL</label>
<input
type="url"
className="form-control"
required
placeholder="http://10.0.0.5:5001"
value={adminLinkForm.url}
onChange={(e) => setAdminLinkForm({ ...adminLinkForm, url: e.target.value })}
/>
</div>
<div className="col-md-3 d-flex gap-2">
<button type="submit" className="btn btn-primary" disabled={savingAdminLink}>
{editingAdminLinkId ? "Save" : "Add"}
</button>
<button type="button" className="btn" onClick={cancelAdminLinkForm}>
Cancel
</button>
</div>
</form>
)}
{links.length === 0 ? (
<div className="text-secondary">No admin links added yet — e.g. Dockge, Webmin, or Cockpit for this server.</div>
) : (
<div className="d-flex flex-wrap gap-2">
{links.map((l) => (
<div className="btn-group" key={l.id}>
<a href={l.url} target="_blank" rel="noopener noreferrer" className="btn btn-outline-secondary btn-sm">
{l.label}
</a>
{canEditTasks && (
<>
<button className="btn btn-outline-secondary btn-sm" onClick={() => startEditAdminLink(l)} title="Edit">
✎
</button>
<button className="btn btn-outline-danger btn-sm" onClick={() => removeAdminLink(l)} title="Remove">
×
</button>
</>
)}
</div>
))}
</div>
)}
</div>
</div>
{isAdmin && (
<div className="card mb-3">
<div className="card-header">