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>
145 lines
4.8 KiB
TypeScript
145 lines
4.8 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { api } from "../../api/client";
|
|
|
|
const PROVIDER_NAMES: Record<string, string> = {
|
|
cloudflare: "Cloudflare",
|
|
loopia: "Loopia",
|
|
pihole: "Pi-hole",
|
|
azure: "Azure DNS",
|
|
cpanel: "cPanel",
|
|
technitium: "Technitium",
|
|
};
|
|
|
|
const INTEGRATION_NAMES: Record<string, string> = {
|
|
tailscale: "Tailscale",
|
|
proxmox: "Proxmox",
|
|
synology: "Synology",
|
|
semaphore: "Semaphore",
|
|
gitea: "Gitea",
|
|
dockhand: "Dockhand",
|
|
uptimekuma: "Uptime Kuma",
|
|
phpipam: "phpIPAM",
|
|
};
|
|
|
|
function ColorList({
|
|
names,
|
|
colors,
|
|
onChange,
|
|
}: {
|
|
names: Record<string, string>;
|
|
colors: Record<string, string>;
|
|
onChange: (id: string, color: string) => void;
|
|
}) {
|
|
return (
|
|
<div className="d-flex flex-column gap-2">
|
|
{Object.entries(names).map(([id, name]) => {
|
|
const color = colors[id] ?? "#6366f1";
|
|
return (
|
|
<div key={id} className="d-flex align-items-center gap-2">
|
|
<span className="badge" style={{ backgroundColor: `${color}22`, color, border: `1px solid ${color}55`, minWidth: 110 }}>
|
|
{name}
|
|
</span>
|
|
<input
|
|
type="color"
|
|
value={color}
|
|
className="form-control form-control-color"
|
|
onChange={(e) => onChange(id, e.target.value)}
|
|
/>
|
|
<span className="text-secondary small">{color}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function BadgeSettings() {
|
|
const [loading, setLoading] = useState(true);
|
|
const [loadError, setLoadError] = useState<string | null>(null);
|
|
const [providerColors, setProviderColors] = useState<Record<string, string>>({});
|
|
const [integrationColors, setIntegrationColors] = useState<Record<string, string>>({});
|
|
const [saving, setSaving] = useState(false);
|
|
const [saved, setSaved] = useState(false);
|
|
const [saveError, setSaveError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
Promise.all([api.settings.providerColors(), api.settings.integrationColors()])
|
|
.then(([providerRes, integrationRes]) => {
|
|
setProviderColors(providerRes.providerColors);
|
|
setIntegrationColors(integrationRes.integrationColors);
|
|
})
|
|
.catch((err) => setLoadError(err instanceof Error ? err.message : String(err)))
|
|
.finally(() => setLoading(false));
|
|
}, []);
|
|
|
|
async function handleSave() {
|
|
setSaving(true);
|
|
setSaveError(null);
|
|
setSaved(false);
|
|
try {
|
|
const res = await api.settings.update({ providerColors, integrationColors });
|
|
setProviderColors(res.settings.providerColors);
|
|
setIntegrationColors(res.settings.integrationColors);
|
|
setSaved(true);
|
|
setTimeout(() => setSaved(false), 3000);
|
|
} catch (err) {
|
|
setSaveError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
if (loading) return <div className="text-secondary">Loading…</div>;
|
|
|
|
return (
|
|
<>
|
|
<div className="d-flex align-items-center justify-content-between mb-3">
|
|
<h3 className="mb-0">Badges</h3>
|
|
<div className="d-flex align-items-center gap-2">
|
|
{saveError && <span className="text-danger small">{saveError}</span>}
|
|
{saved && <span className="text-success small">✓ Colors saved</span>}
|
|
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
|
|
{saving ? "Saving…" : "Save Colors"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{loadError && <div className="alert alert-danger">{loadError}</div>}
|
|
|
|
<div className="row row-cards">
|
|
<div className="col-md-6">
|
|
<div className="card">
|
|
<div className="card-header">
|
|
<h3 className="card-title">DNS provider badge colors</h3>
|
|
</div>
|
|
<div className="card-body">
|
|
<p className="text-secondary">Customize the color of each DNS provider's badge throughout the app.</p>
|
|
<ColorList
|
|
names={PROVIDER_NAMES}
|
|
colors={providerColors}
|
|
onChange={(id, color) => setProviderColors((c) => ({ ...c, [id]: color }))}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="col-md-6">
|
|
<div className="card">
|
|
<div className="card-header">
|
|
<h3 className="card-title">Integration badge colors</h3>
|
|
</div>
|
|
<div className="card-body">
|
|
<p className="text-secondary">Customize the color of each integration type's badge throughout the app.</p>
|
|
<ColorList
|
|
names={INTEGRATION_NAMES}
|
|
colors={integrationColors}
|
|
onChange={(id, color) => setIntegrationColors((c) => ({ ...c, [id]: color }))}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|