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:
1 parent
bc0e54fea8
commit
d714a87754
8 files changed
+1160
-8
No files matched your search
@@ -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
@@ -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(", ") : "—"}
|
||||
|
||||
Reference in new issue
Block a user