Add a phpIPAM import to the IP Addresses page
New "Sync from phpIPAM" action on IP Addresses, alongside the existing
"Sync from Tailscale" / "Sync from Proxmox" ones and built the same way:
a new integration type (config in-app, URL + API app ID + app token,
credentials encrypted at rest) that this page pulls from on demand.
Deliberately import-only, not a full integration -- no dedicated page,
dashboard widget, or nav entry, since that's all this was asked for.
Auth is phpIPAM's static "App token" method: create an API app under
Administration -> API with its security set to "SSL with App token", and
its one-time code goes straight in as the `token` header (also sent as
`phpipam-token`, in case a given version expects that name instead) --
no login call, no token to renew. The user/password "User token" method
isn't implemented.
Addresses are read the standard way: GET /subnets/, then GET
/subnets/{id}/addresses/ for each, rather than assuming a single
"all addresses" endpoint exists on every version. phpIPAM wraps every
response as {code, success, data} -- including an empty result: a subnet
with nothing in it answers success:false, message:"No addresses found"
rather than success:true, data:[]. That's read as "nothing here", not a
failure; anything else with success:false throws with phpIPAM's own
message. One subnet failing outright (e.g. the app lacks permission on
it) is skipped with a note rather than aborting the whole sync. Every
address field is read defensively -- optional, independently
type-checked -- so a field phpIPAM renames or drops in some version
leaves that value blank instead of breaking the import.
Imported entries: label from hostname or description, "phpIPAM" as
vendor, the subnet's own description (or its CIDR, if it has none) as
location, and description/note/MAC folded into notes. Existing sync
plumbing (upsertSyncedEntry) gained a location parameter so this and any
future sync can set it; the two existing syncs pass null, unchanged.
Endpoints, the token header, and the address/subnet field names are
cross-checked against phpIPAM's own published API documentation. Not
verified against a live instance -- there wasn't one available while
building this, so if a real sync comes back empty or with the wrong
fields, that's the next thing to check.
Verified with 23 backend checks against a fake phpIPAM server matching
that documented shape (the empty-subnet quirk, a subnet that fails
outright, malformed/missing fields, a non-JSON response) and the real
route (added/updated/skipped counts, a manually-entered IP never
overwritten, roles, no-enabled-integration, upstream failure surfaced
per-integration rather than as a 500, audit entries) plus a browser check
of the real IP Addresses page against the real routers: the sync button,
its result message, re-syncing (updates rather than duplicates), the
manual entry staying untouched, and the viewer view.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bf7f73f6b6
commit
70ba60c7da
14 files changed
+281
-13
No files matched your search
@@ -79,6 +79,26 @@ the dashboard views themselves load fine.
|
||||
metrics endpoint only exposes current status, not a monitor's scheduled start/end time, so this reflects
|
||||
what's in maintenance right now rather than mirroring Uptime Kuma's own schedule.
|
||||
|
||||
### phpIPAM
|
||||
|
||||
- **Config fields:** phpIPAM URL, API app ID, App token
|
||||
- **Auth:** the `token` header (and `phpipam-token`, in case your version expects that name instead), set to a
|
||||
static per-app code. In phpIPAM, go to **Administration → API**, create (or edit) an API app, and set its
|
||||
**App security** to **"SSL with App token"** (or "App token" if the install isn't served over HTTPS). Saving
|
||||
it shows the app's code once — that's what goes in this integration's "App token" field, and the app's own
|
||||
short id (chosen when you created it) goes in "API app ID". phpIPAM's other auth method — logging in as a
|
||||
real user to get a short-lived token — isn't supported; use an App-token app instead.
|
||||
- **Required access:** the API app just needs read access to whichever sections/subnets you want imported.
|
||||
- **What it does:** this is import-only, not a full integration page — on the **IP Addresses** page, "Sync
|
||||
from phpIPAM" reads every subnet and the addresses in each, and adds or updates an IPAM entry per address
|
||||
(label from its hostname or description, the subnet's own description as its location, description/note/MAC
|
||||
folded into notes), the same way "Sync from Tailscale"/"Sync from Proxmox" already work: an address you
|
||||
entered by hand, or that a different sync source owns, is never overwritten.
|
||||
- **Not verified against a live instance** — built from phpIPAM's own published API documentation
|
||||
(endpoints, the `token` header, and the address/subnet field names all cross-checked there), but nobody has
|
||||
run it against a real phpIPAM yet. If a sync comes back empty or with the wrong fields, tell us what your
|
||||
instance actually returned and we'll adjust.
|
||||
|
||||
## DNS providers
|
||||
|
||||
### Cloudflare
|
||||
|
||||
@@ -51,10 +51,11 @@ All modules from the original plan are built:
|
||||
is picked up automatically and an unreachable host is flagged instead of
|
||||
silently going stale
|
||||
- **IP Addresses (IPAM)** — inventory of IPs across vendors/locations,
|
||||
with "Sync from Tailscale" and "Sync from Proxmox" actions to pull in
|
||||
tailnet device IPs and VM/LXC IPs (never overwrites a
|
||||
manually-entered IP), and each entry now shows its matching DNS
|
||||
record(s) from the DNS module's cache
|
||||
with "Sync from Tailscale", "Sync from Proxmox", and "Sync from phpIPAM"
|
||||
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
|
||||
- **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
|
||||
|
||||
@@ -287,6 +287,7 @@ export const integrationTypes = [
|
||||
"gitea",
|
||||
"dockhand",
|
||||
"uptimekuma",
|
||||
"phpipam",
|
||||
] as const;
|
||||
export type IntegrationType = (typeof integrationTypes)[number];
|
||||
|
||||
|
||||
@@ -50,6 +50,12 @@ export const INTEGRATION_FIELDS: Partial<Record<IntegrationType, IntegrationFiel
|
||||
},
|
||||
{ key: "password", label: "API key (or password, on old installs)", secret: true, type: "password" },
|
||||
],
|
||||
phpipam: [
|
||||
{ key: "url", label: "phpIPAM URL", secret: false, placeholder: "https://ipam.example.lan" },
|
||||
{ key: "appId", label: "API app ID", secret: false, placeholder: "as set under Administration → API" },
|
||||
{ key: "token", label: "App token (API code)", secret: true, type: "password" },
|
||||
{ key: "insecure", label: "Allow self-signed certificate", secret: false, type: "checkbox" },
|
||||
],
|
||||
};
|
||||
|
||||
/** Fixed base URL per integration type, stored on the row for display/reference. */
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* phpIPAM adapter.
|
||||
* Requires config: url, appId, token; optional: insecure
|
||||
*
|
||||
* Auth: phpIPAM's REST API lives at `<url>/api/<appId>/...` and is enabled per "API app" under
|
||||
* Administration -> API. This adapter expects that app's security method set to **"SSL with App
|
||||
* token"** (or, on a LAN-only install, "App token" without SSL) — a static code shown once when
|
||||
* the app is created, sent on every request as the `token` header. That's the simplest of
|
||||
* phpIPAM's auth methods (no separate login call, no token expiry to renew), so it's the only one
|
||||
* this adapter implements; the user/password "User token" method (POST /user/ to obtain a
|
||||
* short-lived token) is not supported.
|
||||
*
|
||||
* Every response is wrapped as {code, success, data} — including, unusually, an *empty* result:
|
||||
* a subnet with no addresses answers `success:false, message:"No addresses found"` rather than
|
||||
* `success:true, data:[]`. That's read as "nothing here", not an error; anything else with
|
||||
* success:false is a real failure and throws with phpIPAM's own message.
|
||||
*
|
||||
* Addresses are read per subnet (GET /subnets/, then GET /subnets/{id}/addresses/ for each) — the
|
||||
* standard, long-documented way to enumerate every address in phpIPAM — rather than assuming a
|
||||
* single "all addresses" endpoint exists across every version. Object fields are read
|
||||
* defensively (each one is optional and independently type-checked): a field phpIPAM renames or
|
||||
* drops in some version leaves that value blank rather than breaking the sync.
|
||||
*
|
||||
* Endpoints, the `token` header, and the address/subnet field names are cross-checked against
|
||||
* phpIPAM's own published API documentation (phpipam.net/api-documentation) — but not verified
|
||||
* against a live instance, and the "no addresses found" empty-result shape specifically is from
|
||||
* long-standing third-party-client convention rather than the docs themselves. If your instance's
|
||||
* response shapes differ, tell us what came back and we'll adjust.
|
||||
*/
|
||||
import * as http from "node:http";
|
||||
import * as https from "node:https";
|
||||
import { withDiagLogging } from "../../services/diagLog.js";
|
||||
|
||||
export interface PhpIpamConfig {
|
||||
url: string;
|
||||
appId: string;
|
||||
token: string;
|
||||
insecure?: boolean;
|
||||
}
|
||||
|
||||
export interface PhpIpamAddress {
|
||||
ip: string;
|
||||
hostname: string | null;
|
||||
description: string | null;
|
||||
note: string | null;
|
||||
mac: string | null;
|
||||
/** The subnet's own description, or its CIDR if it has none — "where" this address lives in phpIPAM. */
|
||||
subnetLabel: string;
|
||||
}
|
||||
|
||||
export interface PhpIpamAdapter {
|
||||
ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>;
|
||||
listAddresses(): Promise<PhpIpamAddress[]>;
|
||||
}
|
||||
|
||||
interface RawResponse {
|
||||
status: number;
|
||||
text: () => string;
|
||||
}
|
||||
|
||||
// phpIPAM is a plain web app (unlike e.g. Synology's fixed 5000/5001) — no default port override, just the URL's own scheme.
|
||||
function request(url: string, insecure: boolean, token: string): Promise<RawResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsed = new URL(url);
|
||||
const isHttps = parsed.protocol === "https:";
|
||||
const lib = isHttps ? https : http;
|
||||
const req = lib.request(
|
||||
{
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port || (isHttps ? 443 : 80),
|
||||
path: parsed.pathname + parsed.search,
|
||||
method: "GET",
|
||||
// phpIPAM's docs name this header "token"; some versions instead look for "phpipam-token" — send both.
|
||||
headers: { token, "phpipam-token": token, Accept: "application/json" },
|
||||
...(isHttps ? { rejectUnauthorized: !insecure } : {}),
|
||||
},
|
||||
(res) => {
|
||||
let body = "";
|
||||
res.setEncoding("utf8");
|
||||
res.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
res.on("end", () => resolve({ status: res.statusCode ?? 0, text: () => body }));
|
||||
},
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
const str = (v: unknown): string | null => (typeof v === "string" && v !== "" ? v : null);
|
||||
|
||||
export function createPhpIpamAdapter(config: PhpIpamConfig): PhpIpamAdapter {
|
||||
const insecure = config.insecure === true;
|
||||
|
||||
function base() {
|
||||
return `${config.url.replace(/\/$/, "")}/api/${config.appId.replace(/^\/|\/$/g, "")}`;
|
||||
}
|
||||
|
||||
/** GETs one endpoint and returns its `data` array — [] for phpIPAM's "no X found" not-really-an-error shape. */
|
||||
async function apiList(path: string): Promise<any[]> {
|
||||
const res = await request(`${base()}${path}`, insecure, config.token);
|
||||
let body: any = null;
|
||||
try {
|
||||
body = res.text() ? JSON.parse(res.text()) : null;
|
||||
} catch {
|
||||
// non-JSON error page
|
||||
}
|
||||
if (!body || typeof body !== "object") {
|
||||
throw new Error(`phpIPAM API error: HTTP ${res.status}`);
|
||||
}
|
||||
if (body.success === false) {
|
||||
if (/no .*found/i.test(String(body.message ?? ""))) return [];
|
||||
throw new Error(body.message || `phpIPAM API error: HTTP ${res.status}`);
|
||||
}
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
throw new Error(body.message || `phpIPAM API error: HTTP ${res.status}`);
|
||||
}
|
||||
return Array.isArray(body.data) ? body.data : [];
|
||||
}
|
||||
|
||||
async function listAddresses(): Promise<PhpIpamAddress[]> {
|
||||
const subnets = await apiList("/subnets/");
|
||||
const out: PhpIpamAddress[] = [];
|
||||
for (const s of subnets) {
|
||||
const subnetId = s?.id;
|
||||
if (subnetId === undefined || subnetId === null) continue;
|
||||
const subnetLabel = str(s.description) ?? (str(s.subnet) && str(s.mask) ? `${s.subnet}/${s.mask}` : `subnet ${subnetId}`);
|
||||
|
||||
let addresses: any[];
|
||||
try {
|
||||
addresses = await apiList(`/subnets/${subnetId}/addresses/`);
|
||||
} catch (err) {
|
||||
// One unreadable subnet (e.g. this app lacks permission on it) shouldn't fail the whole sync.
|
||||
console.error(`[phpipam] couldn't read addresses for subnet ${subnetId}:`, err instanceof Error ? err.message : err);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const a of addresses) {
|
||||
const ip = str(a?.ip);
|
||||
if (!ip) continue;
|
||||
out.push({
|
||||
ip,
|
||||
hostname: str(a?.hostname),
|
||||
description: str(a?.description),
|
||||
note: str(a?.note),
|
||||
mac: str(a?.mac),
|
||||
subnetLabel,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }> {
|
||||
const start = Date.now();
|
||||
try {
|
||||
await apiList("/subnets/");
|
||||
return { ok: true, latencyMs: Date.now() - start };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
return withDiagLogging("phpipam", { ping, listAddresses });
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { createSemaphoreAdapter } from "./semaphore/adapter.js";
|
||||
import { createProxmoxAdapter } from "./proxmox/adapter.js";
|
||||
import { createSynologyAdapter } from "./synology/adapter.js";
|
||||
import { createUptimeKumaAdapter } from "./uptimekuma/adapter.js";
|
||||
import { createPhpIpamAdapter } from "./phpipam/adapter.js";
|
||||
|
||||
export interface PingableAdapter {
|
||||
ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>;
|
||||
@@ -35,6 +36,8 @@ export function createIntegrationAdapter(type: IntegrationType, config: Integrat
|
||||
return createSynologyAdapter(config as any);
|
||||
case "uptimekuma":
|
||||
return createUptimeKumaAdapter(config as any);
|
||||
case "phpipam":
|
||||
return createPhpIpamAdapter(config as any);
|
||||
default:
|
||||
throw new Error(`Integration type "${type}" is not implemented yet`);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { asyncHandler } from "../utils/asyncHandler.js";
|
||||
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";
|
||||
|
||||
export const ipamRouter = Router();
|
||||
|
||||
@@ -141,17 +142,18 @@ async function upsertSyncedEntry(
|
||||
label: string,
|
||||
vendor: string,
|
||||
notes: string | null,
|
||||
location: string | null,
|
||||
source: string,
|
||||
): Promise<"added" | "updated" | "skipped"> {
|
||||
const [existing] = await db.select().from(ipamEntries).where(eq(ipamEntries.ipAddress, ip)).limit(1);
|
||||
if (!existing) {
|
||||
await db.insert(ipamEntries).values({ ipAddress: ip, label, vendor, notes, source });
|
||||
await db.insert(ipamEntries).values({ ipAddress: ip, label, vendor, notes, location, source });
|
||||
return "added";
|
||||
}
|
||||
if (existing.source === source) {
|
||||
await db
|
||||
.update(ipamEntries)
|
||||
.set({ label, notes, updatedAt: new Date().toISOString() })
|
||||
.set({ label, notes, location, updatedAt: new Date().toISOString() })
|
||||
.where(eq(ipamEntries.id, existing.id));
|
||||
return "updated";
|
||||
}
|
||||
@@ -193,7 +195,7 @@ ipamRouter.post("/sync-tailscale", requireRole("operator"), asyncHandler(async (
|
||||
const label = device.label || device.hostname || ip;
|
||||
const notes = device.os ? `OS: ${device.os}` : null;
|
||||
|
||||
const result = await upsertSyncedEntry(ip, label, "Tailscale", notes, "tailscale");
|
||||
const result = await upsertSyncedEntry(ip, label, "Tailscale", notes, null, "tailscale");
|
||||
if (result === "added") added++;
|
||||
else if (result === "updated") updated++;
|
||||
else {
|
||||
@@ -258,7 +260,7 @@ ipamRouter.post("/sync-proxmox", requireRole("operator"), asyncHandler(async (re
|
||||
const notes = `Proxmox ${guest.type === "qemu" ? "VM" : "LXC"} #${guest.vmid} on ${guest.node}`;
|
||||
|
||||
for (const ip of detail.ipAddresses) {
|
||||
const result = await upsertSyncedEntry(ip, label, "Proxmox", notes, "proxmox");
|
||||
const result = await upsertSyncedEntry(ip, label, "Proxmox", notes, null, "proxmox");
|
||||
if (result === "added") added++;
|
||||
else if (result === "updated") updated++;
|
||||
else {
|
||||
@@ -278,3 +280,56 @@ ipamRouter.post("/sync-proxmox", requireRole("operator"), asyncHandler(async (re
|
||||
|
||||
res.json({ added, updated, skipped, skippedIps, errors });
|
||||
}));
|
||||
|
||||
ipamRouter.post("/sync-phpipam", requireRole("operator"), asyncHandler(async (req, res) => {
|
||||
const phpIpamIntegrations = await db
|
||||
.select()
|
||||
.from(integrations)
|
||||
.where(and(eq(integrations.type, "phpipam"), eq(integrations.enabled, true)));
|
||||
|
||||
if (phpIpamIntegrations.length === 0) {
|
||||
return res.status(400).json({ error: "no_phpipam_integration" });
|
||||
}
|
||||
|
||||
let added = 0;
|
||||
let updated = 0;
|
||||
let skipped = 0;
|
||||
const skippedIps: string[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const integration of phpIpamIntegrations) {
|
||||
const loaded = await loadIntegrationConfig(integration.id);
|
||||
if (!loaded || loaded.integration.type !== "phpipam") continue;
|
||||
|
||||
let addresses;
|
||||
try {
|
||||
const adapter = createPhpIpamAdapter(loaded.config as { url: string; appId: string; token: string; insecure?: boolean });
|
||||
addresses = await adapter.listAddresses();
|
||||
} catch (err) {
|
||||
errors.push(`${integration.name}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const addr of addresses) {
|
||||
const label = addr.hostname || addr.description || addr.ip;
|
||||
const notes = [addr.description, addr.note, addr.mac ? `MAC ${addr.mac}` : null].filter((v): v is string => !!v).join(" — ") || null;
|
||||
|
||||
const result = await upsertSyncedEntry(addr.ip, label, "phpIPAM", notes, addr.subnetLabel, "phpipam");
|
||||
if (result === "added") added++;
|
||||
else if (result === "updated") updated++;
|
||||
else {
|
||||
skipped++;
|
||||
skippedIps.push(addr.ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await recordAudit({
|
||||
actor: req.currentUser!,
|
||||
category: "ipam",
|
||||
action: "sync_phpipam",
|
||||
detail: { added, updated, skipped },
|
||||
});
|
||||
|
||||
res.json({ added, updated, skipped, skippedIps, errors });
|
||||
}));
|
||||
@@ -428,7 +428,7 @@ export interface ManualTaskInput {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export type IntegrationType = "proxmox" | "synology" | "semaphore" | "tailscale" | "gitea" | "dockhand" | "uptimekuma";
|
||||
export type IntegrationType = "proxmox" | "synology" | "semaphore" | "tailscale" | "gitea" | "dockhand" | "uptimekuma" | "phpipam";
|
||||
|
||||
export interface IntegrationField {
|
||||
key: string;
|
||||
@@ -888,6 +888,7 @@ export const api = {
|
||||
remove: (id: number) => request<void>(`/api/ipam/${id}`, { method: "DELETE" }),
|
||||
syncTailscale: () => request<IpamSyncResult>("/api/ipam/sync-tailscale", { method: "POST" }),
|
||||
syncProxmox: () => request<IpamSyncResult>("/api/ipam/sync-proxmox", { method: "POST" }),
|
||||
syncPhpIpam: () => request<IpamSyncResult>("/api/ipam/sync-phpipam", { method: "POST" }),
|
||||
},
|
||||
dns: {
|
||||
providerFields: () =>
|
||||
|
||||
@@ -18,6 +18,7 @@ const INTEGRATION_TYPE_LABELS: Record<IntegrationType, string> = {
|
||||
gitea: "Gitea",
|
||||
dockhand: "Dockhand",
|
||||
uptimekuma: "Uptime Kuma",
|
||||
phpipam: "phpIPAM",
|
||||
};
|
||||
|
||||
const DNS_PROVIDER_LABELS: Record<DnsProviderType, string> = {
|
||||
|
||||
@@ -9,6 +9,7 @@ const TYPE_LABELS: Record<IntegrationType, string> = {
|
||||
gitea: "Gitea",
|
||||
dockhand: "Dockhand",
|
||||
uptimekuma: "Uptime Kuma",
|
||||
phpipam: "phpIPAM",
|
||||
};
|
||||
|
||||
export default function IntegrationEditForm({
|
||||
|
||||
@@ -9,6 +9,7 @@ const TYPE_LABELS: Record<IntegrationType, string> = {
|
||||
gitea: "Gitea",
|
||||
dockhand: "Dockhand",
|
||||
uptimekuma: "Uptime Kuma",
|
||||
phpipam: "phpIPAM",
|
||||
};
|
||||
|
||||
export default function IntegrationForm({ onCreated, onCancel }: { onCreated: () => void; onCancel: () => void }) {
|
||||
|
||||
@@ -15,6 +15,7 @@ const TYPE_LABELS: Record<IntegrationType, string> = {
|
||||
gitea: "Gitea",
|
||||
dockhand: "Dockhand",
|
||||
uptimekuma: "Uptime Kuma",
|
||||
phpipam: "phpIPAM",
|
||||
};
|
||||
|
||||
function typeBadgeStyle(colors: Record<string, string>, type: IntegrationType): CSSProperties {
|
||||
|
||||
+14
-4
@@ -24,7 +24,7 @@ export default function Ipam({ user }: { user: CurrentUser }) {
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [form, setForm] = useState<IpamInput>(emptyForm);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [syncing, setSyncing] = useState<"tailscale" | "proxmox" | null>(null);
|
||||
const [syncing, setSyncing] = useState<"tailscale" | "proxmox" | "phpipam" | null>(null);
|
||||
const [syncResult, setSyncResult] = useState<string | null>(null);
|
||||
const selection = useSelection<number>();
|
||||
const [bulkDeleting, setBulkDeleting] = useState(false);
|
||||
@@ -125,15 +125,22 @@ export default function Ipam({ user }: { user: CurrentUser }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function runSync(source: "tailscale" | "proxmox") {
|
||||
const SYNC_LABELS: Record<"tailscale" | "proxmox" | "phpipam", string> = {
|
||||
tailscale: "Tailscale",
|
||||
proxmox: "Proxmox",
|
||||
phpipam: "phpIPAM",
|
||||
};
|
||||
|
||||
async function runSync(source: "tailscale" | "proxmox" | "phpipam") {
|
||||
setError(null);
|
||||
setSyncResult(null);
|
||||
setSyncing(source);
|
||||
try {
|
||||
const res = source === "tailscale" ? await api.ipam.syncTailscale() : await api.ipam.syncProxmox();
|
||||
const res =
|
||||
source === "tailscale" ? await api.ipam.syncTailscale() : source === "proxmox" ? await api.ipam.syncProxmox() : await api.ipam.syncPhpIpam();
|
||||
const parts = [`${res.added} added`, `${res.updated} updated`];
|
||||
if (res.skipped > 0) parts.push(`${res.skipped} skipped (already tracked manually)`);
|
||||
setSyncResult(`${source === "tailscale" ? "Tailscale" : "Proxmox"}: ${parts.join(", ")}`);
|
||||
setSyncResult(`${SYNC_LABELS[source]}: ${parts.join(", ")}`);
|
||||
if (res.errors.length > 0) setError(res.errors.join("; "));
|
||||
load();
|
||||
} catch (err) {
|
||||
@@ -259,6 +266,9 @@ export default function Ipam({ user }: { user: CurrentUser }) {
|
||||
<button className="btn btn-outline-secondary" onClick={() => runSync("proxmox")} disabled={!!syncing}>
|
||||
{syncing === "proxmox" ? "Syncing…" : "Sync from Proxmox"}
|
||||
</button>
|
||||
<button className="btn btn-outline-secondary" onClick={() => runSync("phpipam")} disabled={!!syncing}>
|
||||
{syncing === "phpipam" ? "Syncing…" : "Sync from phpIPAM"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button className="btn btn-outline-secondary ms-auto" onClick={exportCsv}>
|
||||
|
||||
@@ -18,6 +18,7 @@ const INTEGRATION_NAMES: Record<string, string> = {
|
||||
gitea: "Gitea",
|
||||
dockhand: "Dockhand",
|
||||
uptimekuma: "Uptime Kuma",
|
||||
phpipam: "phpIPAM",
|
||||
};
|
||||
|
||||
function ColorList({
|
||||
|
||||
Reference in new issue
Block a user