Add a "Sync from Tailscale" action to IP Addresses (IPAM)

IPAM was entirely manual — Tailscale device IPs never showed up there
even though the Tailscale integration already lists them. Add an
explicit sync action (matching this app's existing pattern of
user-triggered syncs rather than silent background polling).

- New source column on ipam_entries (null = manual, "tailscale" =
  auto-synced) so a re-sync only ever touches rows it created itself —
  a manually-entered IP that happens to collide with a tailnet address
  is left untouched and reported back as skipped, never overwritten.
- POST /api/ipam/sync-tailscale pulls every enabled Tailscale
  integration's device list, upserting by primary IP (label, OS in
  notes, vendor "Tailscale"); one unreachable Tailscale integration
  doesn't block others.
- New "Sync from Tailscale" button on the IP Addresses page, with a
  small "synced" badge marking which rows came from it.

Also fixed a longstanding TODO found in the same file: the "DNS
records" column always showed "-" because matchingDnsRecords was
hardcoded to an empty array from before the DNS module existed. It
now does the same content-based reverse lookup against the DNS
module's record cache used elsewhere in the app.

Verified end-to-end by running the real server with Tailscale's fetch
call intercepted at the process level (its adapter hardcodes
api.tailscale.com with no configurable URL, so it can't be pointed at
a mock server the way Proxmox/Synology can): confirmed add, the
manual-entry skip/never-overwrite behavior, idempotent re-sync
(add -> update), and the DNS-matching fix, all against the real
route and adapter code.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-15 23:16:17 +02:00
1 parent bc0e54fea8
commit d714a87754
8 files changed
+1160 -8

No files matched your search

+10
View File
@@ -63,11 +63,20 @@ export interface IpamEntry {
vendor: string | null;
location: string | null;
notes: string | null;
source: string | null;
createdAt: string;
updatedAt: string;
matchingDnsRecords: string[];
}
export interface TailscaleSyncResult {
added: number;
updated: number;
skipped: number;
skippedIps: string[];
errors: string[];
}
export interface IpamInput {
ipAddress: string;
label?: string;
@@ -479,6 +488,7 @@ 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" }),
},
dns: {
providerFields: () =>
+30 -1
View File
@@ -17,6 +17,8 @@ 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 [syncResult, setSyncResult] = useState<string | null>(null);
function load() {
api.ipam
@@ -91,6 +93,24 @@ export default function Ipam({ user }: { user: CurrentUser }) {
}
}
async function syncTailscale() {
setError(null);
setSyncResult(null);
setSyncing(true);
try {
const res = await api.ipam.syncTailscale();
const parts = [`${res.added} added`, `${res.updated} updated`];
if (res.skipped > 0) parts.push(`${res.skipped} skipped (already tracked manually)`);
setSyncResult(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);
}
}
function exportCsv() {
downloadCsv(
"ip-addresses.csv",
@@ -112,6 +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>}
{canEdit && showForm && (
<div className="card mb-3">
@@ -195,6 +216,11 @@ export default function Ipam({ user }: { user: CurrentUser }) {
Add IP address
</button>
)}
{canEdit && (
<button className="btn btn-outline-secondary" onClick={syncTailscale} disabled={syncing}>
{syncing ? "Syncing…" : "Sync from Tailscale"}
</button>
)}
<button className="btn btn-outline-secondary ms-auto" onClick={exportCsv}>
Export CSV
</button>
@@ -222,7 +248,10 @@ export default function Ipam({ user }: { user: CurrentUser }) {
{entry.ipAddress}
</td>
<td>{entry.label ?? "—"}</td>
<td>{entry.vendor ?? "—"}</td>
<td>
{entry.vendor ?? "—"}
{entry.source === "tailscale" && <span className="badge bg-cyan-lt ms-2">synced</span>}
</td>
<td>{entry.location ?? "—"}</td>
<td className="text-secondary">
{entry.matchingDnsRecords.length > 0 ? entry.matchingDnsRecords.join(", ") : "—"}