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
@@ -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