diff --git a/README.md b/README.md index 452555d..3470f27 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/ROLES.md b/ROLES.md index 0112e60..5cce5c5 100644 --- a/ROLES.md +++ b/ROLES.md @@ -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. diff --git a/server/src/routes/ipam.ts b/server/src/routes/ipam.ts index bced049..dfc397a 100644 --- a/server/src/routes/ipam.ts +++ b/server/src/routes/ipam.ts @@ -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 { 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({ diff --git a/server/src/routes/servers.ts b/server/src/routes/servers.ts index d88ab4b..d33914c 100644 --- a/server/src/routes/servers.ts +++ b/server/src/routes/servers.ts @@ -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" }); diff --git a/web/src/App.tsx b/web/src/App.tsx index 6bf1617..5152b24 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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() { } /> } /> } /> + } /> 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>) => @@ -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(`/api/servers/${id}/links/${linkId}`, { method: "DELETE" }), + allLinks: () => request<{ links: AdminLink[] }>("/api/servers/links"), ports: { list: (id: number) => request(`/api/servers/${id}/ports`), scan: (id: number, data: { address: string; from: number; to: number }) => diff --git a/web/src/layout/AppShell.tsx b/web/src/layout/AppShell.tsx index 7307e42..b98507e 100644 --- a/web/src/layout/AppShell.tsx +++ b/web/src/layout/AppShell.tsx @@ -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: }, { to: "/uptime-kuma", label: "Uptime Kuma", icon: }, { to: "/osticket", label: "osTicket", icon: }, + { to: "/admin-links", label: "Admin Links", icon: }, { to: "/generator", label: "Generator", icon: }, ], }, diff --git a/web/src/pages/AdminLinks.tsx b/web/src/pages/AdminLinks.tsx new file mode 100644 index 0000000..9172929 --- /dev/null +++ b/web/src/pages/AdminLinks.tsx @@ -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(null); + const [servers, setServers] = useState(null); + const [error, setError] = useState(null); + const [search, setSearch] = useState(""); + + const [editing, setEditing] = useState(null); + const [adding, setAdding] = useState(false); + const [form, setForm] = useState(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 ( + <> +

Admin Links

+

+ 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. +

+ {error &&
{error}
} + + {canEdit && showForm && ( +
+
+

{editing ? `Edit "${editing.label}"` : "Add an admin link"}

+
+
+
+
+ + + {editing &&
Remove and re-add to move it to a different server.
} +
+
+ + setForm({ ...form, label: e.target.value })} + /> +
+
+ + setForm({ ...form, url: e.target.value })} + /> +
+
+
+ + +
+
+
+ )} + +
+
+ setSearch(e.target.value)} + /> +
+ {canEdit && !showForm && ( + + )} + +
+
+
+ + + + label="Server" sortKeyName="serverName" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> + label="Label" sortKeyName="label" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> + + {canEdit && } + + + + {(sorted ?? []).map((l) => ( + + + + + {canEdit && ( + + )} + + ))} + {links !== null && links.length === 0 && ( + + + + )} + {links !== null && links.length > 0 && (sorted ?? []).length === 0 && ( + + + + )} + +
URLActions
+ {l.serverName} + {l.serverHostname &&
{l.serverHostname}
} +
{l.label} + + {l.url} + + +
+ + +
+
+ No admin links added yet. +
+ Nothing matches "{search}". +
+
+
+ + ); +} diff --git a/web/src/pages/Ipam.tsx b/web/src/pages/Ipam.tsx index bb6aef3..3a3c1dd 100644 --- a/web/src/pages/Ipam.tsx +++ b/web/src/pages/Ipam.tsx @@ -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(null); + const [excludedRanges, setExcludedRanges] = useState([]); + const [hideExcluded, setHideExcluded] = useState(true); + const [showRanges, setShowRanges] = useState(false); + const [newRange, setNewRange] = useState(""); + const [savingRanges, setSavingRanges] = useState(false); const [error, setError] = useState(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 { + 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 &&
{error}
} {syncResult &&
Synced from {syncResult}
} +
+
+
+ + +
+ {showRanges && ( +
+
+ 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. +
+
+ {excludedRanges.length === 0 && Nothing excluded.} + {excludedRanges.map((r) => ( + + {r} + {canEdit && ( +
+ {canEdit && ( +
+ setNewRange(e.target.value)} + /> + +
+ )} +
+ )} +
+
+ {canEdit && showForm && (
@@ -327,6 +429,7 @@ export default function Ipam({ user }: { user: CurrentUser }) { {isIpv6(entry.ipAddress) ? "IPv6" : "IPv4"} {entry.ipAddress} + {entry.excluded && excluded} {entry.label ?? "—"} @@ -343,6 +446,11 @@ export default function Ipam({ user }: { user: CurrentUser }) { + {!entry.excluded && ( + + )} @@ -354,7 +462,9 @@ export default function Ipam({ user }: { user: CurrentUser }) { {(sorted ?? []).length === 0 && ( - 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."} )}