Add a "Sync from Proxmox" action to IP Addresses (IPAM)
Extends the Tailscale IPAM sync to Proxmox: pulls every VM/LXC's IP address(es) across all enabled Proxmox integrations into the inventory, reusing the same never-overwrite-a-manual-entry semantics. Proxmox guests can have multiple NICs (each IP synced as its own entry) and, for QEMU VMs, IP discovery depends on a responsive guest agent — a VM with none simply contributes zero entries rather than erroring, matching getGuestDetail()'s existing best-effort behavior. listGuests() doesn't include IPs, so this fetches getGuestDetail() per guest; acceptable for an explicit, user-triggered sync rather than a background poll. Extracted the shared "insert, update only if I own this row, else skip and report" upsert logic (previously inline in the Tailscale sync handler) into upsertSyncedEntry(), now used by both sync routes so the core safety rule can't drift between them. Verified end-to-end against a mock Proxmox server covering: an LXC with two NICs (both synced), a QEMU VM with a responsive guest agent, a QEMU VM with no agent (zero entries, no error), a manual-entry collision (skipped and reported, never overwritten), and idempotent re-sync (add -> update on the second run). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d714a87754
commit
e7ac6fea3b
4 files changed
+122
-27
No files matched your search
@@ -69,7 +69,7 @@ export interface IpamEntry {
|
||||
matchingDnsRecords: string[];
|
||||
}
|
||||
|
||||
export interface TailscaleSyncResult {
|
||||
export interface IpamSyncResult {
|
||||
added: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
@@ -488,7 +488,8 @@ export const api = {
|
||||
update: (id: number, data: Partial<Omit<IpamInput, "ipAddress">>) =>
|
||||
request<{ entry: IpamEntry }>(`/api/ipam/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
|
||||
remove: (id: number) => request<void>(`/api/ipam/${id}`, { method: "DELETE" }),
|
||||
syncTailscale: () => request<TailscaleSyncResult>("/api/ipam/sync-tailscale", { method: "POST" }),
|
||||
syncTailscale: () => request<IpamSyncResult>("/api/ipam/sync-tailscale", { method: "POST" }),
|
||||
syncProxmox: () => request<IpamSyncResult>("/api/ipam/sync-proxmox", { method: "POST" }),
|
||||
},
|
||||
dns: {
|
||||
providerFields: () =>
|
||||
|
||||
+16
-11
@@ -17,7 +17,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(false);
|
||||
const [syncing, setSyncing] = useState<"tailscale" | "proxmox" | null>(null);
|
||||
const [syncResult, setSyncResult] = useState<string | null>(null);
|
||||
|
||||
function load() {
|
||||
@@ -93,21 +93,21 @@ export default function Ipam({ user }: { user: CurrentUser }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function syncTailscale() {
|
||||
async function runSync(source: "tailscale" | "proxmox") {
|
||||
setError(null);
|
||||
setSyncResult(null);
|
||||
setSyncing(true);
|
||||
setSyncing(source);
|
||||
try {
|
||||
const res = await api.ipam.syncTailscale();
|
||||
const res = source === "tailscale" ? await api.ipam.syncTailscale() : await api.ipam.syncProxmox();
|
||||
const parts = [`${res.added} added`, `${res.updated} updated`];
|
||||
if (res.skipped > 0) parts.push(`${res.skipped} skipped (already tracked manually)`);
|
||||
setSyncResult(parts.join(", "));
|
||||
setSyncResult(`${source === "tailscale" ? "Tailscale" : "Proxmox"}: ${parts.join(", ")}`);
|
||||
if (res.errors.length > 0) setError(res.errors.join("; "));
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
setSyncing(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ export default function Ipam({ user }: { user: CurrentUser }) {
|
||||
<>
|
||||
<h2 className="page-title mb-3">IP Addresses</h2>
|
||||
{error && <div className="alert alert-danger">{error}</div>}
|
||||
{syncResult && <div className="alert alert-success">Synced from Tailscale: {syncResult}</div>}
|
||||
{syncResult && <div className="alert alert-success">Synced from {syncResult}</div>}
|
||||
|
||||
{canEdit && showForm && (
|
||||
<div className="card mb-3">
|
||||
@@ -217,9 +217,14 @@ export default function Ipam({ user }: { user: CurrentUser }) {
|
||||
</button>
|
||||
)}
|
||||
{canEdit && (
|
||||
<button className="btn btn-outline-secondary" onClick={syncTailscale} disabled={syncing}>
|
||||
{syncing ? "Syncing…" : "Sync from Tailscale"}
|
||||
</button>
|
||||
<>
|
||||
<button className="btn btn-outline-secondary" onClick={() => runSync("tailscale")} disabled={!!syncing}>
|
||||
{syncing === "tailscale" ? "Syncing…" : "Sync from Tailscale"}
|
||||
</button>
|
||||
<button className="btn btn-outline-secondary" onClick={() => runSync("proxmox")} disabled={!!syncing}>
|
||||
{syncing === "proxmox" ? "Syncing…" : "Sync from Proxmox"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button className="btn btn-outline-secondary ms-auto" onClick={exportCsv}>
|
||||
Export CSV
|
||||
@@ -250,7 +255,7 @@ export default function Ipam({ user }: { user: CurrentUser }) {
|
||||
<td>{entry.label ?? "—"}</td>
|
||||
<td>
|
||||
{entry.vendor ?? "—"}
|
||||
{entry.source === "tailscale" && <span className="badge bg-cyan-lt ms-2">synced</span>}
|
||||
{entry.source && <span className="badge bg-cyan-lt ms-2">synced from {entry.source}</span>}
|
||||
</td>
|
||||
<td>{entry.location ?? "—"}</td>
|
||||
<td className="text-secondary">
|
||||
|
||||
Reference in new issue
Block a user