Share the excluded-ranges filter with IP Addresses; add Admin Links page

IP Addresses (IPAM) now reads the same excluded-ranges setting the
Consistency page manages, so "not interesting" addresses - a Docker
bridge network repeating on every host, say - can be hidden there too.
Adds a "Hide excluded addresses" toggle (on by default, with a live
count), an inline ranges editor matching Consistency's, an "excluded"
badge on rows shown anyway, and a per-row "Exclude..." shortcut that
suggests a /24 (or /64 for IPv6) around that address. Editing ranges
from either page updates both, since it's one shared setting.

Also adds Operations > Admin Links: a single page summarizing every
admin bookmark added across all servers (Dockge, Webmin, Cockpit, etc,
previously only visible per-server on each server's own detail page),
sortable and searchable, with the same add/edit/delete capability -
adding one here just asks which server it belongs to.

Verified both with real HTTP-level tests: a genuine Express app, a
scratch SQLite DB, and forged sessions, covering the exclusion
matching, the shared-setting round trip, the links aggregation and
join, and role enforcement - the real dev DB was confirmed untouched
throughout. Both packages build clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-30 00:07:27 +02:00
1 parent 236b1da0dc
commit 1ff1c6550c
9 files changed
+435 -9

No files matched your search

+259
View File
@@ -0,0 +1,259 @@
import { useEffect, useMemo, useState, type FormEvent } from "react";
import { Link } from "react-router-dom";
import { api, type AdminLink, type CurrentUser, type ServerRecord } from "../api/client";
import { useSortable } from "../hooks/useSortable";
import SortableTh from "../components/SortableTh";
import { downloadCsv } from "../utils/csv";
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<AdminLink[] | null>(null);
const [servers, setServers] = useState<ServerRecord[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [editing, setEditing] = useState<AdminLink | null>(null);
const [adding, setAdding] = useState(false);
const [form, setForm] = useState<LinkForm>(emptyForm);
const [saving, setSaving] = useState(false);
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(() => {});
}, []);
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 (!confirm(`Remove the "${link.label}" link from ${link.serverName}?`)) 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 (
<>
<h2 className="page-title mb-3">Admin Links</h2>
<p className="text-secondary">
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.
</p>
{error && <div className="alert alert-danger">{error}</div>}
{canEdit && showForm && (
<div className="card mb-3">
<div className="card-header">
<h3 className="card-title">{editing ? `Edit "${editing.label}"` : "Add an admin link"}</h3>
</div>
<form onSubmit={submit}>
<div className="card-body row g-3">
<div className="col-md-3">
<label className="form-label">Server</label>
<select
className="form-select"
required
disabled={!!editing}
value={form.serverId}
onChange={(e) => setForm({ ...form, serverId: e.target.value ? Number(e.target.value) : "" })}
>
<option value="" disabled>
— choose a server —
</option>
{(servers ?? []).map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
{editing && <div className="form-hint">Remove and re-add to move it to a different server.</div>}
</div>
<div className="col-md-3">
<label className="form-label">Label</label>
<input
className="form-control"
required
maxLength={60}
placeholder="e.g. Dockge, Webmin, Cockpit"
value={form.label}
onChange={(e) => setForm({ ...form, 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={form.url}
onChange={(e) => setForm({ ...form, url: e.target.value })}
/>
</div>
</div>
<div className="card-footer d-flex gap-2">
<button type="submit" className="btn btn-primary" disabled={saving}>
{editing ? "Save changes" : "Add link"}
</button>
<button type="button" className="btn" onClick={cancelForm}>
Cancel
</button>
</div>
</form>
</div>
)}
<div className="card">
<div className="card-header">
<input
className="form-control"
style={{ maxWidth: 280 }}
placeholder="Search server, label, URL…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="card-actions">
{canEdit && !showForm && (
<button className="btn btn-primary btn-sm" onClick={startAdd}>
Add link
</button>
)}
<button className="btn btn-sm btn-outline-secondary" onClick={exportCsv} disabled={!sorted?.length}>
Export CSV
</button>
</div>
</div>
<div className="table-responsive">
<table className="table table-vcenter card-table">
<thead>
<tr>
<SortableTh<AdminLink> label="Server" sortKeyName="serverName" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<SortableTh<AdminLink> label="Label" sortKeyName="label" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<th>URL</th>
{canEdit && <th className="w-1">Actions</th>}
</tr>
</thead>
<tbody>
{(sorted ?? []).map((l) => (
<tr key={l.id}>
<td>
<Link to={`/servers/${l.serverId}`}>{l.serverName}</Link>
{l.serverHostname && <div className="text-secondary small">{l.serverHostname}</div>}
</td>
<td>{l.label}</td>
<td>
<a href={l.url} target="_blank" rel="noopener noreferrer">
{l.url}
</a>
</td>
{canEdit && (
<td>
<div className="btn-list flex-nowrap">
<button className="btn btn-sm" onClick={() => startEdit(l)}>
Edit
</button>
<button className="btn btn-sm btn-outline-danger" onClick={() => remove(l)}>
Delete
</button>
</div>
</td>
)}
</tr>
))}
{links !== null && links.length === 0 && (
<tr>
<td colSpan={canEdit ? 4 : 3} className="text-secondary text-center">
No admin links added yet.
</td>
</tr>
)}
{links !== null && links.length > 0 && (sorted ?? []).length === 0 && (
<tr>
<td colSpan={canEdit ? 4 : 3} className="text-secondary text-center">
Nothing matches "{search}".
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</>
);
}