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

+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 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 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 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** — zone/record management across Cloudflare, Loopia, Pi-hole, Azure
DNS, cPanel, and Technitium; providers are configured in-app (not via env DNS, cPanel, and Technitium; providers are configured in-app (not via env
vars) and their credentials are encrypted at rest 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 detail page. The detail page also has an **Admin Links** section
(operator/admin to add/edit/remove) for bookmarking that server's own (operator/admin to add/edit/remove) for bookmarking that server's own
admin UIs — Dockge, Webmin, Cockpit, Portainer, or anything else reachable 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 "Proxmox link" card per server ("Not a VM? Hide this" / "+ Show Proxmox
link options") — it stays visible regardless once a server actually is link options") — it stays visible regardless once a server actually is
linked, so unlinking is always reachable. 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 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 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 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 **Domains** — when each domain registration expires, read from the registry
itself. The domains behind your DNS zones are picked up automatically (a zone 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` | ✅ | ✅ | ✅ | | Maintenance | `/maintenance` | ✅ | ✅ | ✅ |
| Uptime Kuma | `/uptime-kuma` | ✅ | ✅ | ✅ | | Uptime Kuma | `/uptime-kuma` | ✅ | ✅ | ✅ |
| osTicket | `/osticket` | ✅ | ✅ | ✅ | | osTicket | `/osticket` | ✅ | ✅ | ✅ |
| Admin Links | `/admin-links` | ✅ | ✅ | ✅ |
| Generator | `/generator` | ✅ | ✅ | ✅ | | Generator | `/generator` | ✅ | ✅ | ✅ |
| **Administration** | | | | | | **Administration** | | | | |
| Integrations | `/integrations` | ✅ | ✅ | ✅ | | Integrations | `/integrations` | ✅ | ✅ | ✅ |
@@ -74,6 +75,9 @@ even further, to admin only:
admin-only (applying an existing tag to a server needs operator). admin-only (applying an existing tag to a server needs operator).
- **Ports**: everyone can see both the agent-reported and manual tables; - **Ports**: everyone can see both the agent-reported and manual tables;
adding, editing, or deleting a manual port opening needs operator. 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, - Everything under **Settings** (notification channels, badge colors,
display prefs, log retention, backup/restore) is admin-only, matching display prefs, log retention, backup/restore) is admin-only, matching
the page itself being admin-only. 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 { createTailscaleAdapter } from "../integrations/tailscale/adapter.js";
import { createProxmoxAdapter } from "../integrations/proxmox/adapter.js"; import { createProxmoxAdapter } from "../integrations/proxmox/adapter.js";
import { createPhpIpamAdapter } from "../integrations/phpipam/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(); export const ipamRouter = Router();
@@ -36,7 +38,14 @@ async function matchingDnsRecordsByIp(ips: string[]): Promise<Map<string, string
ipamRouter.get("/", asyncHandler(async (_req, res) => { ipamRouter.get("/", asyncHandler(async (_req, res) => {
const rows = await db.select().from(ipamEntries).orderBy(ipamEntries.ipAddress); const rows = await db.select().from(ipamEntries).orderBy(ipamEntries.ipAddress);
const byIp = await matchingDnsRecordsByIp(rows.map((r) => r.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({ 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())); 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) => { serversRouter.get("/:id/detail", asyncHandler(async (req, res) => {
const id = Number(req.params.id); const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_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 UptimeKuma from "./pages/UptimeKuma";
import PbsBackup from "./pages/PbsBackup"; import PbsBackup from "./pages/PbsBackup";
import OsTicket from "./pages/OsTicket"; import OsTicket from "./pages/OsTicket";
import AdminLinks from "./pages/AdminLinks";
import Generator from "./pages/Generator"; import Generator from "./pages/Generator";
import Maintenance from "./pages/Maintenance"; import Maintenance from "./pages/Maintenance";
import Domains from "./pages/Domains"; import Domains from "./pages/Domains";
@@ -111,6 +112,7 @@ export default function App() {
<Route path="/uptime-kuma" element={<UptimeKuma user={user} />} /> <Route path="/uptime-kuma" element={<UptimeKuma user={user} />} />
<Route path="/pbs" element={<PbsBackup user={user} />} /> <Route path="/pbs" element={<PbsBackup user={user} />} />
<Route path="/osticket" element={<OsTicket user={user} />} /> <Route path="/osticket" element={<OsTicket user={user} />} />
<Route path="/admin-links" element={<AdminLinks user={user} />} />
<Route <Route
path="/users" path="/users"
element={ element={
+13 -1
View File
@@ -122,6 +122,8 @@ export interface IpamEntry {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
matchingDnsRecords: 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 { export interface IpamSyncResult {
@@ -395,6 +397,15 @@ export interface ServerDetail {
links: ServerLink[]; links: ServerLink[];
} }
export interface AdminLink {
id: number;
serverId: number;
serverName: string;
serverHostname: string | null;
label: string;
url: string;
}
export interface PortEntry { export interface PortEntry {
/** Null for a port only known from the agent that has no note yet. */ /** Null for a port only known from the agent that has no note yet. */
id: number | null; id: number | null;
@@ -985,7 +996,7 @@ export const api = {
), ),
}, },
ipam: { ipam: {
list: () => request<{ entries: IpamEntry[] }>("/api/ipam"), list: () => request<{ entries: IpamEntry[]; excludedRanges: string[] }>("/api/ipam"),
create: (data: IpamInput) => create: (data: IpamInput) =>
request<{ entry: IpamEntry }>("/api/ipam", { method: "POST", body: JSON.stringify(data) }), request<{ entry: IpamEntry }>("/api/ipam", { method: "POST", body: JSON.stringify(data) }),
update: (id: number, data: Partial<Omit<IpamInput, "ipAddress">>) => 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) }), request<{ link: ServerLink }>(`/api/servers/${id}/links/${linkId}`, { method: "PATCH", body: JSON.stringify(data) }),
removeLink: (id: number, linkId: number) => removeLink: (id: number, linkId: number) =>
request<void>(`/api/servers/${id}/links/${linkId}`, { method: "DELETE" }), request<void>(`/api/servers/${id}/links/${linkId}`, { method: "DELETE" }),
allLinks: () => request<{ links: AdminLink[] }>("/api/servers/links"),
ports: { ports: {
list: (id: number) => request<PortList>(`/api/servers/${id}/ports`), list: (id: number) => request<PortList>(`/api/servers/${id}/ports`),
scan: (id: number, data: { address: string; from: number; to: number }) => scan: (id: number, data: { address: string; from: number; to: number }) =>
+2
View File
@@ -31,6 +31,7 @@ import {
IconShieldCheck, IconShieldCheck,
IconTicket, IconTicket,
IconPlug, IconPlug,
IconExternalLink,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { api, type CurrentUser, type MaintenanceWindow } from "../api/client"; import { api, type CurrentUser, type MaintenanceWindow } from "../api/client";
import { formatRemaining } from "../utils/duration"; import { formatRemaining } from "../utils/duration";
@@ -103,6 +104,7 @@ const NAV: NavEntry[] = [
{ to: "/maintenance", label: "Maintenance", icon: <IconTool size={20} /> }, { to: "/maintenance", label: "Maintenance", icon: <IconTool size={20} /> },
{ to: "/uptime-kuma", label: "Uptime Kuma", icon: <IconActivityHeartbeat size={20} /> }, { to: "/uptime-kuma", label: "Uptime Kuma", icon: <IconActivityHeartbeat size={20} /> },
{ to: "/osticket", label: "osTicket", icon: <IconTicket 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} /> }, { 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 }) { export default function Ipam({ user }: { user: CurrentUser }) {
const canEdit = user.role === "admin" || user.role === "operator"; const canEdit = user.role === "admin" || user.role === "operator";
const [entries, setEntries] = useState<IpamEntry[] | null>(null); 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 [error, setError] = useState<string | null>(null);
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const [search, setSearch] = useState(() => searchParams.get("q") ?? ""); const [search, setSearch] = useState(() => searchParams.get("q") ?? "");
@@ -34,6 +39,7 @@ export default function Ipam({ user }: { user: CurrentUser }) {
.list() .list()
.then((res) => { .then((res) => {
setEntries(res.entries); setEntries(res.entries);
setExcludedRanges(res.excludedRanges);
// Deep-link from global search (?editId=) — jump straight to that entry's edit form. // Deep-link from global search (?editId=) — jump straight to that entry's edit form.
const editId = Number(searchParams.get("editId")); const editId = Number(searchParams.get("editId"));
const target = res.entries.find((e) => e.id === editId); const target = res.entries.find((e) => e.id === editId);
@@ -44,14 +50,49 @@ export default function Ipam({ user }: { user: CurrentUser }) {
useEffect(load, []); 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(() => { const filtered = useMemo(() => {
if (!entries) return []; if (!entries) return [];
const base = hideExcluded ? entries.filter((e) => !e.excluded) : entries;
const q = search.trim().toLowerCase(); const q = search.trim().toLowerCase();
if (!q) return entries; if (!q) return base;
return entries.filter((e) => return base.filter((e) =>
[e.ipAddress, e.label, e.vendor, e.location].some((v) => (v ?? "").toLowerCase().includes(q)), [e.ipAddress, e.label, e.vendor, e.location].some((v) => (v ?? "").toLowerCase().includes(q)),
); );
}, [entries, search]); }, [entries, hideExcluded, search]);
function startAdd() { function startAdd() {
setAdding(true); setAdding(true);
@@ -176,6 +217,67 @@ export default function Ipam({ user }: { user: CurrentUser }) {
{error && <div className="alert alert-danger">{error}</div>} {error && <div className="alert alert-danger">{error}</div>}
{syncResult && <div className="alert alert-success">Synced from {syncResult}</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 && ( {canEdit && showForm && (
<div className="card mb-3"> <div className="card mb-3">
<div className="card-header"> <div className="card-header">
@@ -327,6 +429,7 @@ export default function Ipam({ user }: { user: CurrentUser }) {
{isIpv6(entry.ipAddress) ? "IPv6" : "IPv4"} {isIpv6(entry.ipAddress) ? "IPv6" : "IPv4"}
</span> </span>
{entry.ipAddress} {entry.ipAddress}
{entry.excluded && <span className="badge bg-secondary-lt text-secondary ms-2">excluded</span>}
</td> </td>
<td>{entry.label ?? "—"}</td> <td>{entry.label ?? "—"}</td>
<td> <td>
@@ -343,6 +446,11 @@ export default function Ipam({ user }: { user: CurrentUser }) {
<button className="btn btn-sm" onClick={() => startEdit(entry)}> <button className="btn btn-sm" onClick={() => startEdit(entry)}>
Edit Edit
</button> </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)}> <button className="btn btn-sm btn-outline-danger" onClick={() => remove(entry)}>
Delete Delete
</button> </button>
@@ -354,7 +462,9 @@ export default function Ipam({ user }: { user: CurrentUser }) {
{(sorted ?? []).length === 0 && ( {(sorted ?? []).length === 0 && (
<tr> <tr>
<td colSpan={canEdit ? 7 : 5} className="text-secondary text-center"> <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> </td>
</tr> </tr>
)} )}