added phpipam and tailscale integration
This commit is contained in:
1 parent
812c8e5016
commit
b5a64fdeac
12 files changed
+846
-66
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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() },
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user