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:
2026-09-30 00:07:27 +02:00
co-authored by Claude Sonnet 5
parent 236b1da0dc
commit 1ff1c6550c
9 changed files with 435 additions and 9 deletions
+13 -3
View File
@@ -60,7 +60,11 @@ All modules from the original plan are built:
actions to pull in tailnet device IPs, VM/LXC IPs, and phpIPAM's own
addresses (never overwrites a manually-entered IP, or one a different sync
owns), and each entry now shows its matching DNS record(s) from the DNS
module's cache
module's cache. Shares the Consistency page's excluded-ranges setting (see
below) so addresses that aren't interesting to track — a Docker bridge
network repeating on every host, say — can be hidden here too, with a
"Hide excluded addresses" toggle and a per-entry "Exclude…" shortcut to add
a range on the spot; managing ranges from either page updates the other
- **DNS** — zone/record management across Cloudflare, Loopia, Pi-hole, Azure
DNS, cPanel, and Technitium; providers are configured in-app (not via env
vars) and their credentials are encrypted at rest
@@ -80,7 +84,11 @@ All modules from the original plan are built:
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. Since not every server is a Proxmox VM, an admin can hide the
by URL. **Operations → Admin Links** summarizes every server's admin links
in one sortable, searchable table (server, label, URL, an "Open" link, and
the same add/edit/delete as the per-server section — adding one here just
asks which server it belongs to), so finding or managing one doesn't mean
visiting each server's own page. Since not every server is a Proxmox VM, an admin can hide the
"Proxmox link" card per server ("Not a VM? Hide this" / "+ Show Proxmox
link options") — it stays visible regardless once a server actually is
linked, so unlinking is always reachable.
@@ -216,7 +224,9 @@ many servers reported addresses and how fresh the synced DNS zones are. Only
private addresses are compared; ranges you exclude (Docker's, which repeat the same
subnet on many hosts — 172.16.0.0/12 is excluded by default, remove it if that's a
real LAN for you) are left out of every source; anything that's fine on purpose
can be ignored with a reason, and stays ignored.
can be ignored with a reason, and stays ignored. The excluded-ranges list is the
same one the IP Addresses page manages — edit it from either page and both
reflect the change.
**Domains** — when each domain registration expires, read from the registry
itself. The domains behind your DNS zones are picked up automatically (a zone
+4
View File
@@ -40,6 +40,7 @@ page's own top-level link.
| Maintenance | `/maintenance` | ✅ | ✅ | ✅ |
| Uptime Kuma | `/uptime-kuma` | ✅ | ✅ | ✅ |
| osTicket | `/osticket` | ✅ | ✅ | ✅ |
| Admin Links | `/admin-links` | ✅ | ✅ | ✅ |
| Generator | `/generator` | ✅ | ✅ | ✅ |
| **Administration** | | | | |
| Integrations | `/integrations` | ✅ | ✅ | ✅ |
@@ -74,6 +75,9 @@ even further, to admin only:
admin-only (applying an existing tag to a server needs operator).
- **Ports**: everyone can see both the agent-reported and manual tables;
adding, editing, or deleting a manual port opening needs operator.
- **Admin Links**: everyone can see and open every server's admin
bookmarks, from that server's own page or the summary page; adding,
editing, or removing one needs operator, from either place.
- Everything under **Settings** (notification channels, badge colors,
display prefs, log retention, backup/restore) is admin-only, matching
the page itself being admin-only.
+10 -1
View File
@@ -11,6 +11,8 @@ import { loadIntegrationConfig } from "../integrations/loadIntegration.js";
import { createTailscaleAdapter } from "../integrations/tailscale/adapter.js";
import { createProxmoxAdapter } from "../integrations/proxmox/adapter.js";
import { createPhpIpamAdapter } from "../integrations/phpipam/adapter.js";
import { makeExclusion } from "../services/consistency.js";
import { getSettings } from "../services/settingsStore.js";
export const ipamRouter = Router();
@@ -36,7 +38,14 @@ async function matchingDnsRecordsByIp(ips: string[]): Promise<Map<string, string
ipamRouter.get("/", asyncHandler(async (_req, res) => {
const rows = await db.select().from(ipamEntries).orderBy(ipamEntries.ipAddress);
const byIp = await matchingDnsRecordsByIp(rows.map((r) => r.ipAddress));
res.json({ entries: rows.map((r) => ({ ...r, matchingDnsRecords: byIp.get(r.ipAddress) ?? [] })) });
// Same excluded-ranges setting the Consistency page manages — "not interesting" addresses (Docker's bridge
// network repeating on every host, say) can be hidden here too, without duplicating that configuration.
const { consistency } = await getSettings();
const excluded = makeExclusion(consistency.excludedRanges);
res.json({
entries: rows.map((r) => ({ ...r, matchingDnsRecords: byIp.get(r.ipAddress) ?? [], excluded: excluded(r.ipAddress) })),
excludedRanges: consistency.excludedRanges,
});
}));
const createInput = z.object({
+18
View File
@@ -170,6 +170,24 @@ serversRouter.get("/summary", asyncHandler(async (_req, res) => {
res.json(buildServerSummary(rows, healthChecks, Date.now(), await activeSubjects()));
}));
// For the Operations > Admin Links page — every server's admin bookmarks in one place, instead of visiting
// each server's own detail page to find them.
serversRouter.get("/links", asyncHandler(async (_req, res) => {
const rows = await db
.select({
id: serverLinks.id,
serverId: serverLinks.serverId,
serverName: servers.name,
serverHostname: servers.hostname,
label: serverLinks.label,
url: serverLinks.url,
})
.from(serverLinks)
.innerJoin(servers, eq(serverLinks.serverId, servers.id))
.orderBy(servers.name, serverLinks.label);
res.json({ links: rows });
}));
serversRouter.get("/:id/detail", asyncHandler(async (req, res) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" });
+2
View File
@@ -23,6 +23,7 @@ import Synology from "./pages/Synology";
import UptimeKuma from "./pages/UptimeKuma";
import PbsBackup from "./pages/PbsBackup";
import OsTicket from "./pages/OsTicket";
import AdminLinks from "./pages/AdminLinks";
import Generator from "./pages/Generator";
import Maintenance from "./pages/Maintenance";
import Domains from "./pages/Domains";
@@ -111,6 +112,7 @@ export default function App() {
<Route path="/uptime-kuma" element={<UptimeKuma user={user} />} />
<Route path="/pbs" element={<PbsBackup user={user} />} />
<Route path="/osticket" element={<OsTicket user={user} />} />
<Route path="/admin-links" element={<AdminLinks user={user} />} />
<Route
path="/users"
element={
+13 -1
View File
@@ -122,6 +122,8 @@ export interface IpamEntry {
createdAt: string;
updatedAt: string;
matchingDnsRecords: string[];
/** Falls within one of the Consistency page's excluded ranges — e.g. Docker's bridge network, repeated on every host. */
excluded: boolean;
}
export interface IpamSyncResult {
@@ -395,6 +397,15 @@ export interface ServerDetail {
links: ServerLink[];
}
export interface AdminLink {
id: number;
serverId: number;
serverName: string;
serverHostname: string | null;
label: string;
url: string;
}
export interface PortEntry {
/** Null for a port only known from the agent that has no note yet. */
id: number | null;
@@ -985,7 +996,7 @@ export const api = {
),
},
ipam: {
list: () => request<{ entries: IpamEntry[] }>("/api/ipam"),
list: () => request<{ entries: IpamEntry[]; excludedRanges: string[] }>("/api/ipam"),
create: (data: IpamInput) =>
request<{ entry: IpamEntry }>("/api/ipam", { method: "POST", body: JSON.stringify(data) }),
update: (id: number, data: Partial<Omit<IpamInput, "ipAddress">>) =>
@@ -1092,6 +1103,7 @@ export const api = {
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" }),
allLinks: () => request<{ links: AdminLink[] }>("/api/servers/links"),
ports: {
list: (id: number) => request<PortList>(`/api/servers/${id}/ports`),
scan: (id: number, data: { address: string; from: number; to: number }) =>
+2
View File
@@ -31,6 +31,7 @@ import {
IconShieldCheck,
IconTicket,
IconPlug,
IconExternalLink,
} from "@tabler/icons-react";
import { api, type CurrentUser, type MaintenanceWindow } from "../api/client";
import { formatRemaining } from "../utils/duration";
@@ -103,6 +104,7 @@ const NAV: NavEntry[] = [
{ to: "/maintenance", label: "Maintenance", icon: <IconTool size={20} /> },
{ to: "/uptime-kuma", label: "Uptime Kuma", icon: <IconActivityHeartbeat size={20} /> },
{ to: "/osticket", label: "osTicket", icon: <IconTicket size={20} /> },
{ to: "/admin-links", label: "Admin Links", icon: <IconExternalLink size={20} /> },
{ to: "/generator", label: "Generator", icon: <IconWand size={20} /> },
],
},
+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>
</>
);
}
+114 -4
View File
@@ -17,6 +17,11 @@ function isIpv6(ip: string) {
export default function Ipam({ user }: { user: CurrentUser }) {
const canEdit = user.role === "admin" || user.role === "operator";
const [entries, setEntries] = useState<IpamEntry[] | null>(null);
const [excludedRanges, setExcludedRanges] = useState<string[]>([]);
const [hideExcluded, setHideExcluded] = useState(true);
const [showRanges, setShowRanges] = useState(false);
const [newRange, setNewRange] = useState("");
const [savingRanges, setSavingRanges] = useState(false);
const [error, setError] = useState<string | null>(null);
const [searchParams] = useSearchParams();
const [search, setSearch] = useState(() => searchParams.get("q") ?? "");
@@ -34,6 +39,7 @@ export default function Ipam({ user }: { user: CurrentUser }) {
.list()
.then((res) => {
setEntries(res.entries);
setExcludedRanges(res.excludedRanges);
// Deep-link from global search (?editId=) — jump straight to that entry's edit form.
const editId = Number(searchParams.get("editId"));
const target = res.entries.find((e) => e.id === editId);
@@ -44,14 +50,49 @@ export default function Ipam({ user }: { user: CurrentUser }) {
useEffect(load, []);
/** Saves the whole list (the server validates and normalises it) and reloads — shared with the Consistency page. */
async function saveRanges(ranges: string[]): Promise<boolean> {
setSavingRanges(true);
setError(null);
try {
await api.consistency.setExcludedRanges(ranges);
load();
return true;
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
return false;
} finally {
setSavingRanges(false);
}
}
async function addRange(e: React.FormEvent) {
e.preventDefault();
if (!newRange.trim()) return;
if (await saveRanges([...excludedRanges, newRange.trim()])) setNewRange("");
}
async function excludeAround(entry: IpamEntry) {
const suggestion = isIpv6(entry.ipAddress) ? `${entry.ipAddress}/64` : `${entry.ipAddress.split(".").slice(0, 3).join(".")}.0/24`;
const range = window.prompt(
`Hide this range from IP Addresses and the Consistency report — every address in it, not just this one:`,
suggestion,
);
if (range === null || !range.trim()) return;
await saveRanges([...excludedRanges, range.trim()]);
}
const excludedCount = useMemo(() => entries?.filter((e) => e.excluded).length ?? 0, [entries]);
const filtered = useMemo(() => {
if (!entries) return [];
const base = hideExcluded ? entries.filter((e) => !e.excluded) : entries;
const q = search.trim().toLowerCase();
if (!q) return entries;
return entries.filter((e) =>
if (!q) return base;
return base.filter((e) =>
[e.ipAddress, e.label, e.vendor, e.location].some((v) => (v ?? "").toLowerCase().includes(q)),
);
}, [entries, search]);
}, [entries, hideExcluded, search]);
function startAdd() {
setAdding(true);
@@ -176,6 +217,67 @@ export default function Ipam({ user }: { user: CurrentUser }) {
{error && <div className="alert alert-danger">{error}</div>}
{syncResult && <div className="alert alert-success">Synced from {syncResult}</div>}
<div className="card mb-3">
<div className="card-body">
<div className="d-flex flex-wrap align-items-center gap-2">
<label className="form-check mb-0">
<input
type="checkbox"
className="form-check-input"
checked={hideExcluded}
onChange={(e) => setHideExcluded(e.target.checked)}
/>
<span className="form-check-label">
Hide excluded addresses{excludedCount > 0 && ` (${excludedCount})`}
</span>
</label>
<button type="button" className="btn btn-link btn-sm p-0 ms-2" onClick={() => setShowRanges((s) => !s)}>
{showRanges ? "Hide" : "Manage"} excluded ranges
</button>
</div>
{showRanges && (
<div className="mt-2">
<div className="text-secondary small mb-2">
Addresses in these ranges are left out here and on the Consistency report — the same setting either
page manages. Meant for networks that aren't part of your LAN, like Docker's, which repeat the same
subnet on many hosts.
</div>
<div className="d-flex flex-wrap gap-1 mb-2">
{excludedRanges.length === 0 && <span className="text-secondary small">Nothing excluded.</span>}
{excludedRanges.map((r) => (
<span key={r} className="badge bg-secondary-lt text-secondary d-inline-flex align-items-center">
<code className="bg-transparent p-0">{r}</code>
{canEdit && (
<button
type="button"
className="btn-close ms-1"
style={{ fontSize: "0.5rem" }}
aria-label={`Stop excluding ${r}`}
disabled={savingRanges}
onClick={() => void saveRanges(excludedRanges.filter((x) => x !== r))}
/>
)}
</span>
))}
</div>
{canEdit && (
<form onSubmit={addRange} className="d-flex gap-2" style={{ maxWidth: 460 }}>
<input
className="form-control form-control-sm"
placeholder="e.g. 172.17.0.0/16 or 10.1.2.3"
value={newRange}
onChange={(e) => setNewRange(e.target.value)}
/>
<button type="submit" className="btn btn-sm btn-primary" disabled={savingRanges || !newRange.trim()}>
Exclude
</button>
</form>
)}
</div>
)}
</div>
</div>
{canEdit && showForm && (
<div className="card mb-3">
<div className="card-header">
@@ -327,6 +429,7 @@ export default function Ipam({ user }: { user: CurrentUser }) {
{isIpv6(entry.ipAddress) ? "IPv6" : "IPv4"}
</span>
{entry.ipAddress}
{entry.excluded && <span className="badge bg-secondary-lt text-secondary ms-2">excluded</span>}
</td>
<td>{entry.label ?? "—"}</td>
<td>
@@ -343,6 +446,11 @@ export default function Ipam({ user }: { user: CurrentUser }) {
<button className="btn btn-sm" onClick={() => startEdit(entry)}>
Edit
</button>
{!entry.excluded && (
<button className="btn btn-sm" onClick={() => excludeAround(entry)} title="Hide this and every address in the same range">
Exclude…
</button>
)}
<button className="btn btn-sm btn-outline-danger" onClick={() => remove(entry)}>
Delete
</button>
@@ -354,7 +462,9 @@ export default function Ipam({ user }: { user: CurrentUser }) {
{(sorted ?? []).length === 0 && (
<tr>
<td colSpan={canEdit ? 7 : 5} className="text-secondary text-center">
No IP addresses tracked yet.
{entries && entries.length > 0
? "Every tracked address is currently excluded or filtered out — turn off “Hide excluded addresses”, or adjust your search, to see them."
: "No IP addresses tracked yet."}
</td>
</tr>
)}