Files
Homelab-manager/web/src/pages/settings/BadgeSettings.tsx
T
bobbanandClaude Sonnet 5 70ba60c7da 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>
2026-09-29 19:49:10 +02:00

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>
</>
);
}