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

+8
View File
@@ -0,0 +1,8 @@
CREATE TABLE `server_links` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`server_id` integer NOT NULL,
`label` text NOT NULL,
`url` text NOT NULL,
`created_at` text DEFAULT (current_timestamp) NOT NULL,
FOREIGN KEY (`server_id`) REFERENCES `servers`(`id`) ON UPDATE no action ON DELETE cascade
);
File diff suppressed because it is too large. Load diff
+7
View File
@@ -29,6 +29,13 @@
"when": 1789673523898,
"tag": "0003_fantastic_randall",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1789677988329,
"tag": "0004_brown_gambit",
"breakpoints": true
}
]
}
+14
View File
@@ -222,6 +222,20 @@ export const scheduledTasks = sqliteTable("scheduled_tasks", {
.default(sql`(current_timestamp)`),
});
// ─── Server links — admin-page bookmarks per server (Dockge, Webmin, Cockpit, etc.) ─
export const serverLinks = sqliteTable("server_links", {
id: integer("id").primaryKey({ autoIncrement: true }),
serverId: integer("server_id")
.notNull()
.references(() => servers.id, { onDelete: "cascade" }),
label: text("label").notNull(),
url: text("url").notNull(),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
});
// ─── Live integrations (Proxmox, Synology, Semaphore, Tailscale, Gitea, Dockhand) ─
export const integrationTypes = [
+94 -2
View File
@@ -2,7 +2,7 @@ import { Router } from "express";
import { eq, and, inArray } from "drizzle-orm";
import { z } from "zod";
import { db } from "../db/client.js";
import { servers, integrations, dnsRecordsCache, dnsZonesCache, dnsProviders } from "../db/schema.js";
import { servers, serverLinks, integrations, dnsRecordsCache, dnsZonesCache, dnsProviders } from "../db/schema.js";
import { requireAuth, requireRole } from "../auth/middleware.js";
import { generateApiToken } from "../services/tokens.js";
import { recordAudit } from "../services/audit.js";
@@ -221,6 +221,12 @@ serversRouter.get("/:id/detail", asyncHandler(async (req, res) => {
.where(inArray(dnsRecordsCache.content, ipAddresses));
}
const links = await db
.select({ id: serverLinks.id, label: serverLinks.label, url: serverLinks.url })
.from(serverLinks)
.where(eq(serverLinks.serverId, id))
.orderBy(serverLinks.label);
const {
apiTokenHash,
ipAddresses: _rawIpAddresses,
@@ -233,7 +239,93 @@ serversRouter.get("/:id/detail", asyncHandler(async (req, res) => {
...serverOut
} = server;
res.json({ server: serverOut, ipAddresses, hardware, dnsMatches });
res.json({ server: serverOut, ipAddresses, hardware, dnsMatches, links });
}));
const linkSchema = z.object({
label: z.string().min(1).max(60),
url: z
.string()
.url()
.refine((u) => u.startsWith("http://") || u.startsWith("https://"), { message: "URL must start with http:// or https://" }),
});
serversRouter.post("/:id/links", requireRole("operator"), asyncHandler(async (req, res) => {
const serverId = Number(req.params.id);
if (!Number.isInteger(serverId)) return res.status(400).json({ error: "invalid_id" });
const parsed = linkSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
}
const [server] = await db.select({ id: servers.id }).from(servers).where(eq(servers.id, serverId)).limit(1);
if (!server) return res.status(404).json({ error: "not_found" });
const [created] = await db.insert(serverLinks).values({ serverId, ...parsed.data }).returning();
await recordAudit({
actor: req.currentUser!,
category: "server",
action: "add_link",
targetType: "server",
targetId: serverId,
detail: { label: created.label, url: created.url },
});
res.status(201).json({ link: { id: created.id, label: created.label, url: created.url } });
}));
serversRouter.patch("/:id/links/:linkId", requireRole("operator"), asyncHandler(async (req, res) => {
const serverId = Number(req.params.id);
const linkId = Number(req.params.linkId);
if (!Number.isInteger(serverId) || !Number.isInteger(linkId)) return res.status(400).json({ error: "invalid_id" });
const parsed = linkSchema.partial().safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
}
const [updated] = await db
.update(serverLinks)
.set(parsed.data)
.where(and(eq(serverLinks.id, linkId), eq(serverLinks.serverId, serverId)))
.returning();
if (!updated) return res.status(404).json({ error: "not_found" });
await recordAudit({
actor: req.currentUser!,
category: "server",
action: "update_link",
targetType: "server",
targetId: serverId,
detail: { label: updated.label, url: updated.url },
});
res.json({ link: { id: updated.id, label: updated.label, url: updated.url } });
}));
serversRouter.delete("/:id/links/:linkId", requireRole("operator"), asyncHandler(async (req, res) => {
const serverId = Number(req.params.id);
const linkId = Number(req.params.linkId);
if (!Number.isInteger(serverId) || !Number.isInteger(linkId)) return res.status(400).json({ error: "invalid_id" });
const deleted = await db
.delete(serverLinks)
.where(and(eq(serverLinks.id, linkId), eq(serverLinks.serverId, serverId)))
.returning();
if (deleted.length === 0) return res.status(404).json({ error: "not_found" });
await recordAudit({
actor: req.currentUser!,
category: "server",
action: "remove_link",
targetType: "server",
targetId: serverId,
detail: { label: deleted[0].label },
});
res.status(204).end();
}));
serversRouter.delete("/:id", requireRole("admin"), asyncHandler(async (req, res) => {