added phpipam and tailscale integration

This commit is contained in:
bobban committed 2026-09-14 21:05:04 +02:00
1 parent 812c8e5016
commit b5a64fdeac
12 files changed
+846 -66

No files matched your search

+10
View File
@@ -367,6 +367,16 @@ body {
.secret-notes { color: var(--text-muted); cursor: help; margin-left: 4px; }
/* ===== IPAM ===== */
.source-badge {
display: inline-block;
font-size: 10px;
font-weight: 600;
padding: 2px 6px;
border-radius: 10px;
letter-spacing: 0.03em;
white-space: nowrap;
}
.ipam-address {
font-family: monospace;
font-size: 13px;
+37
View File
@@ -104,6 +104,43 @@ export async function getIpam() {
return handleResponse(await fetch(`${BASE}/ipam`, { headers: authHeaders() }));
}
export async function getIpamSources() {
return handleResponse(await fetch(`${BASE}/ipam/sources`, { headers: authHeaders() }));
}
export async function syncIpam(sources) {
return handleResponse(await fetch(`${BASE}/ipam/sync`, {
method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(sources ? { sources } : {}),
}));
}
export async function updatePhpipamEntry(externalId, data) {
return handleResponse(await fetch(`${BASE}/ipam/phpipam/${externalId}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(data),
}));
}
export async function deletePhpipamEntry(externalId) {
return handleResponse(await fetch(`${BASE}/ipam/phpipam/${externalId}`, {
method: 'DELETE', headers: authHeaders(),
}));
}
export async function deleteTailscaleDevice(nodeId) {
return handleResponse(await fetch(`${BASE}/ipam/tailscale/${nodeId}`, {
method: 'DELETE', headers: authHeaders(),
}));
}
export async function setTailscaleAuthorized(nodeId, authorized) {
return handleResponse(await fetch(`${BASE}/ipam/tailscale/${nodeId}/authorized`, {
method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ authorized }),
}));
}
export async function createIpEntry(data) {
return handleResponse(await fetch(`${BASE}/ipam`, {
method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() },
+275 -58
View File
@@ -1,20 +1,49 @@
import { useState, useEffect } from 'react';
import { getIpam, createIpEntry, updateIpEntry, deleteIpEntry } from '../api/dns';
import {
getIpam, getIpamSources, syncIpam,
createIpEntry, updateIpEntry, deleteIpEntry,
updatePhpipamEntry, deletePhpipamEntry,
deleteTailscaleDevice, setTailscaleAuthorized,
} from '../api/dns';
import ConfirmDialog from './ConfirmDialog';
import { exportCsv } from '../utils/exportCsv';
import { useProviderColors, providerBadgeStyle } from '../context/ProviderColors';
function isIPv6(address) {
return address.includes(':');
return address && address.includes(':');
}
function IpForm({ initial, onSave, onCancel }) {
// ─── Source badge ─────────────────────────────────────────────────────────────
const SOURCE_COLORS = {
local: { bg: 'var(--accent)', text: '#fff' },
phpipam: { bg: '#e67e22', text: '#fff' },
tailscale: { bg: '#4a90e2', text: '#fff' },
};
function SourceBadge({ source, kind }) {
const c = SOURCE_COLORS[source] ?? SOURCE_COLORS.local;
const label = source === 'local' ? 'Local'
: source === 'phpipam' ? (kind === 'device' ? 'phpIPAM device' : 'phpIPAM')
: 'Tailscale';
return (
<span className="source-badge" style={{ background: c.bg, color: c.text }}>
{label}
</span>
);
}
// ─── Edit / Add form ──────────────────────────────────────────────────────────
function IpForm({ initial, onSave, onCancel, subnets }) {
const isExternal = initial && initial.source !== 'local';
const [form, setForm] = useState({
address: initial?.address ?? '',
label: initial?.label ?? '',
vendor: initial?.vendor ?? '',
location: initial?.location ?? '',
notes: initial?.notes ?? '',
address: initial?.address ?? '',
label: initial?.label ?? '',
vendor: initial?.vendor ?? '',
location: initial?.location ?? '',
notes: initial?.notes ?? '',
subnet_id: initial?.subnet_id ?? '',
});
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
@@ -34,27 +63,56 @@ function IpForm({ initial, onSave, onCancel }) {
return (
<div className="modal-overlay" onClick={onCancel}>
<div className="modal" onClick={e => e.stopPropagation()}>
<h3>{initial ? 'Edit IP Address' : 'Add IP Address'}</h3>
<h3>
{initial ? 'Edit' : 'Add'} IP Address
{initial && <SourceBadge source={initial.source} kind={initial.kind} />}
</h3>
<form onSubmit={handleSubmit} className="record-form">
<div className="form-row">
<label>IP Address
<input name="address" value={form.address} onChange={handleChange} placeholder="1.2.3.4 or 2001:db8::1" required autoFocus disabled={!!initial} />
<input name="address" value={form.address} onChange={handleChange}
placeholder="1.2.3.4 or 2001:db8::1" required autoFocus
disabled={!!initial} />
</label>
<label>Label
<input name="label" value={form.label} onChange={handleChange} placeholder="e.g. Web Server 1" />
<label>Label / Hostname
<input name="label" value={form.label} onChange={handleChange}
placeholder="e.g. Web Server 1" />
</label>
</div>
<div className="form-row">
<label>Vendor / Provider
<input name="vendor" value={form.vendor} onChange={handleChange} placeholder="e.g. Hetzner, DigitalOcean" />
{/* Local + phpIPAM device only */}
{(!isExternal || (initial?.source === 'phpipam' && initial?.kind === 'device')) && (
<div className="form-row">
<label>Vendor
<input name="vendor" value={form.vendor} onChange={handleChange}
placeholder="e.g. Cisco, Dell" />
</label>
<label>Location
<input name="location" value={form.location} onChange={handleChange}
placeholder="e.g. Rack A, Floor 2" />
</label>
</div>
)}
{/* phpIPAM address — subnet selector */}
{!initial && subnets.length > 0 && (
<label>phpIPAM Subnet (optional)
<select name="subnet_id" value={form.subnet_id} onChange={handleChange}>
<option value="">— Local only —</option>
{subnets.map(s => (
<option key={s.id} value={s.id}>
{s.subnet}/{s.mask}{s.description ? ` — ${s.description}` : ''}
</option>
))}
</select>
</label>
<label>Location / Region
<input name="location" value={form.location} onChange={handleChange} placeholder="e.g. Frankfurt, US-East" />
</label>
</div>
)}
<label>Notes
<input name="notes" value={form.notes} onChange={handleChange} placeholder="Optional notes" />
<input name="notes" value={form.notes} onChange={handleChange}
placeholder="Optional notes" />
</label>
{error && <p className="error">{error}</p>}
<div className="form-actions">
<button type="button" className="btn-secondary" onClick={onCancel} disabled={saving}>Cancel</button>
@@ -68,13 +126,18 @@ function IpForm({ initial, onSave, onCancel }) {
);
}
// ─── DNS match badges ─────────────────────────────────────────────────────────
function DnsMatchBadges({ matches, colors }) {
if (!matches || matches.length === 0) return <span style={{ color: 'var(--text-muted)', fontSize: 12 }}>—</span>;
if (!matches || matches.length === 0)
return <span style={{ color: 'var(--text-muted)', fontSize: 12 }}>—</span>;
return (
<div className="ipam-dns-matches">
{matches.map((m, i) => (
<span key={i} className="ipam-dns-match" title={`${m.type} ${m.name}`}>
<span className="badge" style={{ ...providerBadgeStyle(colors, m.provider), fontSize: 10, padding: '1px 5px' }}>{m.provider}</span>
<span className="badge" style={{ ...providerBadgeStyle(colors, m.provider), fontSize: 10, padding: '1px 5px' }}>
{m.provider}
</span>
<span className="ipam-dns-name">{m.name}</span>
</span>
))}
@@ -82,99 +145,226 @@ function DnsMatchBadges({ matches, colors }) {
);
}
// ─── Main page ────────────────────────────────────────────────────────────────
export default function IpamPage() {
const { colors } = useProviderColors();
const [entries, setEntries] = useState([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editing, setEditing] = useState(null);
const [confirmDel, setConfirmDel] = useState(null);
const [filter, setFilter] = useState('');
const [error, setError] = useState('');
const [entries, setEntries] = useState([]);
const [syncedAt, setSyncedAt] = useState(null);
const [sources, setSources] = useState({ phpipam: false, tailscale: false });
const [subnets, setSubnets] = useState([]);
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const [showForm, setShowForm] = useState(false);
const [editing, setEditing] = useState(null);
const [confirmDel, setConfirmDel] = useState(null);
const [filter, setFilter] = useState('');
const [sourceFilter, setSourceFilter] = useState('all');
const [error, setError] = useState('');
function load() {
setLoading(true);
getIpam()
.then(setEntries)
.catch(e => setError(e.message))
.finally(() => setLoading(false));
function mergeEntries(data) {
setEntries(data.entries ?? []);
if (data.synced_at) setSyncedAt(data.synced_at);
}
useEffect(() => { load(); }, []);
async function load() {
setLoading(true);
try {
const [data, src] = await Promise.all([getIpam(), getIpamSources()]);
mergeEntries(data);
setSources(src);
} catch (e) {
setError(e.message);
} finally {
setLoading(false);
}
}
useEffect(() => { load(); }, []); // eslint-disable-line
async function handleSync() {
setSyncing(true); setError('');
try {
const data = await syncIpam();
mergeEntries(data);
if (Object.keys(data.results?.errors ?? {}).length) {
const errs = Object.entries(data.results.errors).map(([k, v]) => `${k}: ${v}`).join('; ');
setError(`Sync completed with errors — ${errs}`);
}
} catch (e) {
setError(e.message);
} finally {
setSyncing(false);
}
}
// ── Save (local or phpIPAM write-back) ──────────────────────────────────────
async function handleSave(data) {
if (editing) {
const updated = await updateIpEntry(editing.id, data);
setEntries(e => e.map(x => x.id === editing.id ? updated : x));
if (editing.source === 'phpipam') {
await updatePhpipamEntry(editing.external_id, data);
// Refresh entries from server
const fresh = await getIpam();
mergeEntries(fresh);
} else {
const updated = await updateIpEntry(editing.id, data);
setEntries(e => e.map(x => x.id === editing.id ? { ...updated, source: 'local' } : x));
}
} else {
const created = await createIpEntry(data);
setEntries(e => [...e, created]);
// If a subnet_id was chosen, also create in phpIPAM
if (data.subnet_id && sources.phpipam) {
// fire both in parallel — create locally and re-sync phpIPAM cache
const [created] = await Promise.all([
createIpEntry({ ...data, source: 'local' }),
import('../api/dns').then(m => m.syncIpam(['phpipam'])).catch(() => null),
]);
setEntries(e => [...e, { ...created, source: 'local' }]);
} else {
const created = await createIpEntry({ ...data, source: 'local' });
setEntries(e => [...e, { ...created, source: 'local' }]);
}
}
setShowForm(false);
setEditing(null);
}
// ── Delete ───────────────────────────────────────────────────────────────────
async function handleDelete() {
await deleteIpEntry(confirmDel.id);
setEntries(e => e.filter(x => x.id !== confirmDel.id));
const e = confirmDel;
setConfirmDel(null);
try {
if (e.source === 'phpipam') {
await deletePhpipamEntry(e.external_id);
setEntries(prev => prev.filter(x => !(x.source === 'phpipam' && x.external_id === e.external_id)));
} else if (e.source === 'tailscale') {
await deleteTailscaleDevice(e.external_id);
setEntries(prev => prev.filter(x => !(x.source === 'tailscale' && x.external_id === e.external_id)));
} else {
await deleteIpEntry(e.id);
setEntries(prev => prev.filter(x => x.id !== e.id));
}
} catch (err) {
setError(err.message);
}
}
// ── Tailscale authorize toggle ────────────────────────────────────────────────
async function handleAuthorize(entry, authorized) {
try {
await setTailscaleAuthorized(entry.external_id, authorized);
setEntries(prev => prev.map(e =>
e.source === 'tailscale' && e.external_id === entry.external_id
? { ...e, authorized }
: e
));
} catch (err) {
setError(err.message);
}
}
// ── Filtering ────────────────────────────────────────────────────────────────
const filtered = entries.filter(e => {
if (sourceFilter !== 'all' && e.source !== sourceFilter) return false;
if (!filter) return true;
const q = filter.toLowerCase();
return (
e.address.includes(q) ||
(e.address || '').includes(q) ||
(e.label || '').toLowerCase().includes(q) ||
(e.vendor || '').toLowerCase().includes(q) ||
(e.location || '').toLowerCase().includes(q)
);
});
const hasExternal = sources.phpipam || sources.tailscale;
const v4count = entries.filter(e => !isIPv6(e.address)).length;
const v6count = entries.filter(e => isIPv6(e.address)).length;
function formatSynced(iso) {
if (!iso) return null;
const d = new Date(iso);
return `${d.toLocaleDateString('sv-SE')} ${d.toLocaleTimeString('sv-SE', { hour: '2-digit', minute: '2-digit' })}`;
}
return (
<div className="dashboard">
{/* Header */}
<div className="dashboard-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<h2>IP Addresses</h2>
<p className="dashboard-hint">
{entries.length} address{entries.length !== 1 ? 'es' : ''} stored
{entries.length} address{entries.length !== 1 ? 'es' : ''}
{entries.length > 0 && ` · ${v4count} IPv4 · ${v6count} IPv6`}
{syncedAt && <span style={{ marginLeft: 8, color: 'var(--text-muted)', fontSize: 12 }}>
Last synced: {formatSynced(syncedAt)}
</span>}
</p>
</div>
<button className="btn-primary" onClick={() => { setEditing(null); setShowForm(true); }}>+ Add IP</button>
<div style={{ display: 'flex', gap: 8 }}>
{hasExternal && (
<button className="btn-sync" onClick={handleSync} disabled={syncing}>
{syncing ? 'Syncing…' : '⟳ Sync External'}
</button>
)}
<button className="btn-primary" onClick={() => { setEditing(null); setShowForm(true); }}>
+ Add IP
</button>
</div>
</div>
{error && <div className="error-banner" style={{ marginBottom: 16 }}><strong>Error:</strong> {error}</div>}
{error && <div className="error-banner" style={{ marginBottom: 16 }}><strong>Error:</strong> {error}<button className="dismiss" onClick={() => setError('')}>✕</button></div>}
{/* Toolbar */}
<div className="records-actions" style={{ marginBottom: 16 }}>
<input className="filter-input" placeholder="Filter by IP, label, vendor…" value={filter} onChange={e => setFilter(e.target.value)} />
<input className="filter-input" placeholder="Filter by IP, label, vendor…"
value={filter} onChange={e => setFilter(e.target.value)} />
{hasExternal && (
<select
className="filter-input"
style={{ width: 'auto' }}
value={sourceFilter}
onChange={e => setSourceFilter(e.target.value)}
>
<option value="all">All sources</option>
<option value="local">Local</option>
{sources.phpipam && <option value="phpipam">phpIPAM</option>}
{sources.tailscale && <option value="tailscale">Tailscale</option>}
</select>
)}
<button className="btn-export" onClick={() =>
exportCsv(
filtered.map(e => ({
address: e.address, label: e.label, vendor: e.vendor,
location: e.location, notes: e.notes,
source: e.source, address: e.address, label: e.label,
vendor: e.vendor, location: e.location, notes: e.notes,
dns_matches: (e.dnsMatches || []).map(m => `${m.name} (${m.provider})`).join('; '),
})),
['address','label','vendor','location','notes','dns_matches'],
{ address:'IP Address', label:'Label', vendor:'Vendor', location:'Location', notes:'Notes', dns_matches:'DNS Records' },
['source','address','label','vendor','location','notes','dns_matches'],
{ source:'Source', address:'IP Address', label:'Label', vendor:'Vendor', location:'Location', notes:'Notes', dns_matches:'DNS Records' },
'ip-addresses.csv'
)
}>⬇ Export CSV</button>
<span className="record-count">{filtered.length} entries</span>
</div>
{/* Table */}
{loading ? <p className="hint">Loading…</p> : filtered.length === 0 ? (
<div className="empty-state">
<p>{entries.length === 0 ? 'No IP addresses stored yet — press + Add IP to get started.' : 'No entries match your filter.'}</p>
<p>{entries.length === 0
? 'No IP addresses yet — press + Add IP or ⟳ Sync External to get started.'
: 'No entries match your filter.'
}</p>
</div>
) : (
<div className="table-wrapper">
<table className="records-table">
<thead>
<tr>
{hasExternal && <th>Source</th>}
<th>IP Address</th>
<th>Label</th>
<th>Vendor</th>
@@ -184,8 +374,17 @@ export default function IpamPage() {
</tr>
</thead>
<tbody>
{filtered.map(e => (
<tr key={e.id}>
{filtered.map((e, i) => (
<tr key={e.id ?? `${e.source}-${e.external_id}-${i}`}>
{hasExternal && (
<td>
<SourceBadge source={e.source} kind={e.kind} />
{e.is_exit_node && <span className="source-badge" style={{ background: '#27ae60', color: '#fff', marginLeft: 4 }}>Exit</span>}
{e.source === 'tailscale' && e.authorized === false && (
<span className="source-badge" style={{ background: '#e74c3c', color: '#fff', marginLeft: 4 }}>Pending</span>
)}
</td>
)}
<td>
<span className={`ipam-address ${isIPv6(e.address) ? 'ipam-v6' : 'ipam-v4'}`}>{e.address}</span>
</td>
@@ -195,8 +394,21 @@ export default function IpamPage() {
<td><DnsMatchBadges matches={e.dnsMatches} colors={colors} /></td>
<td className="actions-cell">
{e.notes && <span className="secret-notes" title={e.notes}>ℹ</span>}
<button className="btn-edit" onClick={() => { setEditing(e); setShowForm(true); }} title="Edit">✎</button>
<button className="btn-delete" onClick={() => setConfirmDel(e)} title="Delete">✕</button>
{/* Tailscale: authorize toggle */}
{e.source === 'tailscale' && e.authorized === false && (
<button className="btn-edit" title="Authorize device"
onClick={() => handleAuthorize(e, true)}>✓</button>
)}
{/* Edit — not for Tailscale (IPs are read-only) */}
{e.source !== 'tailscale' && (
<button className="btn-edit" title="Edit"
onClick={() => { setEditing(e); setShowForm(true); }}>✎</button>
)}
<button className="btn-delete" title="Delete"
onClick={() => setConfirmDel(e)}>✕</button>
</td>
</tr>
))}
@@ -208,6 +420,7 @@ export default function IpamPage() {
{showForm && (
<IpForm
initial={editing}
subnets={subnets}
onSave={handleSave}
onCancel={() => { setShowForm(false); setEditing(null); }}
/>
@@ -215,8 +428,12 @@ export default function IpamPage() {
{confirmDel && (
<ConfirmDialog
title="Delete IP Address"
message={`Remove ${confirmDel.address}${confirmDel.label ? ` (${confirmDel.label})` : ''}?`}
title={`Delete ${confirmDel.source === 'tailscale' ? 'Tailscale device' : 'IP Address'}`}
message={
confirmDel.source === 'tailscale'
? `Remove device "${confirmDel.label || confirmDel.address}" from the tailnet? This cannot be undone.`
: `Remove ${confirmDel.address}${confirmDel.label ? ` (${confirmDel.label})` : ''}?${confirmDel.source === 'phpipam' ? ' This will also delete it in phpIPAM.' : ''}`
}
confirmLabel="Delete"
danger
onConfirm={handleDelete}