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
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
|||||||
@@ -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
@@ -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 }) =>
|
||||||
|
|||||||
@@ -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} /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in new issue
Block a user