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:
@@ -42,7 +42,10 @@ All modules from the original plan are built:
|
||||
its scheduled tasks — live hardware from Proxmox for VM/LXC-backed
|
||||
servers, or from the agent's own hardware report for everything else.
|
||||
Proxmox-linked servers also get start/stop/restart buttons right on the
|
||||
detail page.
|
||||
detail page. The detail page also has an **Admin Links** section
|
||||
(operator/admin to add/edit/remove) for bookmarking that server's own
|
||||
admin UIs — Dockge, Webmin, Cockpit, Portainer, or anything else reachable
|
||||
by URL.
|
||||
- **Tailscale**, **Proxmox**, **Synology**, **Semaphore**, **Gitea**,
|
||||
and **Docker** each get their own top-level page (backed by the
|
||||
matching integration) instead of living inside a shared Integrations
|
||||
|
||||
@@ -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
@@ -29,6 +29,13 @@
|
||||
"when": 1789673523898,
|
||||
"tag": "0003_fantastic_randall",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1789677988329,
|
||||
"tag": "0004_brown_gambit",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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: (
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user