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:
1 parent
236b1da0dc
commit
1ff1c6550c
9 files changed
+435
-9
No files matched your search
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user