ca27ea5522
component.js now creates an AbortController for each page mount, passing it to load(). On unmount, the controller is aborted to cancel in-flight requests that would otherwise mutate unmounted state. Page load functions consistently pass the signal to apiFetch and guard state mutations with abort checks. This eliminates the need for per-page abortController boilerplate and prevents stale errors from appearing on rapid navigation. Users page now guards catch block and loading state cleanup against aborted requests, matching passkeys.js pattern.
257 lines
11 KiB
JavaScript
257 lines
11 KiB
JavaScript
import { html, h, PageHeader, ConfirmDelete, Tabs, Table, ServiceStatus, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionGroup, QuickModal } from '/static/hoover/index.js?v=10';
|
|
|
|
function makeAddRange(activeZones, interfaces) {
|
|
const opts = [
|
|
['', '(global)'],
|
|
...Object.entries(activeZones || {})
|
|
.filter(([, ifaces]) => Array.isArray(ifaces) && ifaces.length > 0)
|
|
.flatMap(([zone, ifaces]) => {
|
|
const label = zone + ' \u2192 ';
|
|
if (ifaces.length === 1)
|
|
return [[ifaces[0], label + ifaces[0]]];
|
|
return [{ group: zone, options: ifaces.map(i => [i, label + i]) }];
|
|
}),
|
|
];
|
|
|
|
const ranges = {};
|
|
(interfaces || []).forEach(iface => {
|
|
for (const cidr of (iface.ips || [])) {
|
|
if (cidr.indexOf('/') === -1) continue;
|
|
const parts = cidr.split('/');
|
|
const addr = parts[0].split('.').map(Number);
|
|
const prefix = parseInt(parts[1], 10);
|
|
if (prefix > 30 || prefix < 8) continue;
|
|
const mask = (0xFFFFFFFF << (32 - prefix)) >>> 0;
|
|
const netA = ((addr[0] << 24) + (addr[1] << 16) + (addr[2] << 8) + addr[3]) & mask;
|
|
const broadcast = (netA | (~mask >>> 0)) >>> 0;
|
|
const s = (netA + 100) >>> 0;
|
|
const e = Math.min((netA + 200) >>> 0, broadcast - 1);
|
|
const toIp = (n) => [n >> 24 & 0xFF, n >> 16 & 0xFF, n >> 8 & 0xFF, n & 0xFF].join('.');
|
|
if (s <= e) {
|
|
ranges[iface.name] = { start: toIp(s), end: toIp(e) };
|
|
}
|
|
break;
|
|
}
|
|
});
|
|
|
|
return QuickModal({
|
|
title: 'Add DHCP Range',
|
|
fields: [
|
|
{ label: 'Interface (optional)', id: 'r-iface', tag: 'select', options: opts },
|
|
{ label: 'Start IP', id: 'r-start', placeholder: '192.168.1.100' },
|
|
{ label: 'End IP', id: 'r-end', placeholder: '192.168.1.200' },
|
|
{ label: 'Lease Time', id: 'r-lease', placeholder: '12h' },
|
|
],
|
|
postRender: (inner) => {
|
|
const select = inner.querySelector('#r-iface');
|
|
const startInput = inner.querySelector('#r-start');
|
|
const endInput = inner.querySelector('#r-end');
|
|
if (select && startInput && endInput) {
|
|
select.addEventListener('change', () => {
|
|
const r = ranges[select.value];
|
|
if (r) {
|
|
startInput.value = r.start;
|
|
endInput.value = r.end;
|
|
} else {
|
|
startInput.value = '';
|
|
endInput.value = '';
|
|
}
|
|
});
|
|
}
|
|
},
|
|
submit: {
|
|
url: '/api/dhcp/ranges',
|
|
body: () => ({
|
|
interface: ($val('r-iface') || '').trim() || undefined,
|
|
start: ($val('r-start') || '').trim(),
|
|
end: ($val('r-end') || '').trim(),
|
|
lease_time: ($val('r-lease') || '').trim() || '12h',
|
|
}),
|
|
validate: (b) => !b.start || !b.end ? 'Start and end are required' : null,
|
|
successMsg: 'Range added',
|
|
},
|
|
refresh: 'dnsmasq',
|
|
});
|
|
}
|
|
|
|
const addLease = QuickModal({
|
|
title: 'Add Static Lease',
|
|
fields: [
|
|
{ label: 'MAC', id: 'l-mac', placeholder: 'aa:bb:cc:dd:ee:ff' },
|
|
{ label: 'IP', id: 'l-ip', placeholder: '192.168.1.50' },
|
|
{ label: 'Hostname (optional)', id: 'l-host', placeholder: 'device-name' },
|
|
],
|
|
submit: {
|
|
url: '/api/dhcp/static-lease',
|
|
body: () => ({
|
|
mac: ($val('l-mac') || '').trim(),
|
|
ip: ($val('l-ip') || '').trim(),
|
|
hostname: ($val('l-host') || '').trim() || undefined,
|
|
}),
|
|
validate: (b) => !b.mac || !b.ip ? 'MAC and IP are required' : null,
|
|
successMsg: 'Lease added',
|
|
},
|
|
refresh: 'dnsmasq',
|
|
});
|
|
|
|
const addDns = QuickModal({
|
|
title: 'Add DNS Record',
|
|
fields: [
|
|
{ label: 'Name', id: 'd-name', placeholder: 'host.local' },
|
|
{ label: 'Address', id: 'd-addr', placeholder: '192.168.1.10' },
|
|
],
|
|
submit: {
|
|
url: '/api/dhcp/dns-record',
|
|
body: () => ({ name: ($val('d-name') || '').trim(), address: ($val('d-addr') || '').trim() }),
|
|
validate: (b) => !b.name || !b.address ? 'Name and address are required' : null,
|
|
successMsg: 'DNS record added',
|
|
},
|
|
refresh: 'dnsmasq',
|
|
});
|
|
|
|
export default definePage({
|
|
init() {
|
|
return {
|
|
dnsmasq: getModel('dnsmasq'),
|
|
firewall: getModel('firewall'),
|
|
activeTab: 'ranges',
|
|
};
|
|
},
|
|
render(state) {
|
|
const guard = renderGuardMulti('DHCP & DNS', 'Dnsmasq management', state.dnsmasq, state.firewall);
|
|
if (guard) return guard;
|
|
|
|
const cfg = state.dnsmasq.data?.config || {};
|
|
const dhcpCfg = cfg.dhcp || {};
|
|
const dnsCfg = cfg.dns || {};
|
|
const ranges = dhcpCfg.ranges || [];
|
|
const staticLeases = dhcpCfg.static_leases || [];
|
|
const dnsRecords = dnsCfg.custom_records || [];
|
|
const status = state.dnsmasq.data?.status || {};
|
|
|
|
const rangesRows = ranges.map((r) => html`<tr key=${(r.interface || '_g') + '-' + r.start + '-' + r.end}>
|
|
<td>${r.interface || '(global)'}</td>
|
|
<td>${esc(r.start)}</td>
|
|
<td>${esc(r.end)}</td>
|
|
<td>${esc(r.lease_time || '12h')}</td>
|
|
<td>
|
|
<${ConfirmDelete}
|
|
url="/api/dhcp/ranges"
|
|
deleteKey=${(r.interface || '_g') + '-' + r.start + '-' + r.end}
|
|
message=${'Remove range ' + r.start + ' - ' + r.end + '?'}
|
|
body=${{ interface: r.interface || '', start: r.start, end: r.end }}
|
|
success="Range removed"
|
|
refresh="dnsmasq" />
|
|
</td>
|
|
</tr>`);
|
|
|
|
const leaseRows = staticLeases.map((l) => html`<tr key=${l.mac}>
|
|
<td>${esc(l.mac)}</td>
|
|
<td>${esc(l.ip)}</td>
|
|
<td>${l.hostname || '-'}</td>
|
|
<td>
|
|
<${ConfirmDelete}
|
|
url=${'/api/dhcp/static-lease/' + enc(l.mac)}
|
|
deleteKey=${l.mac}
|
|
message=${'Remove lease ' + l.mac + '?'}
|
|
success="Lease removed"
|
|
refresh="dnsmasq" />
|
|
</td>
|
|
</tr>`);
|
|
|
|
const dnsRows = dnsRecords.map((rec) => html`<tr key=${rec.name}>
|
|
<td><strong>${esc(rec.name || 'unnamed')}</strong></td>
|
|
<td class="text-sm">${esc(rec.address || '-')}</td>
|
|
<td>
|
|
<${ConfirmDelete}
|
|
url=${'/api/dhcp/dns-record/' + enc(rec.name || '')}
|
|
deleteKey=${rec.name || 'unnamed'}
|
|
message=${'Remove DNS record ' + (rec.name || 'unnamed') + '?'}
|
|
success="Record removed"
|
|
refresh="dnsmasq" />
|
|
</td>
|
|
</tr>`);
|
|
|
|
const _setDomain = async (domain) => {
|
|
const res = await apiFetch('/api/dhcp/domain', {
|
|
method: 'POST',
|
|
body: { domain },
|
|
});
|
|
if (res.ok) {
|
|
toast('DNS domain updated', 'success');
|
|
modelFetch('dnsmasq');
|
|
} else {
|
|
toast(res.error || 'Failed to update', 'error');
|
|
}
|
|
};
|
|
|
|
const currentDomain = dnsCfg.domain || null;
|
|
const domainSection = html`<div class="domain-config" style="margin-bottom: 1rem;">
|
|
<label style="font-weight: 600;">Search Domain</label>
|
|
<p class="text-sm" style="margin: 0.25rem 0 0.5rem;">${currentDomain ? esc(currentDomain) : '<span class="text-muted">(not set)</span>'}</p>
|
|
<div class="form-inline" style="display: flex; gap: 0.5rem; align-items: center;">
|
|
<input id="domain-input" class="input" placeholder="example.local" />
|
|
<button class="btn btn-outline" onClick=${() => _setDomain(($val('domain-input') || '').trim())}>Set</button>
|
|
<button class="btn btn-outline" onClick=${() => _setDomain(null)}>Clear</button>
|
|
</div>
|
|
</div>`;
|
|
|
|
const tabNames = ['ranges', 'leases', 'dns', 'active'];
|
|
const actions = ActionGroup(
|
|
html`<button class="btn btn-primary" onClick=${() => makeAddRange(state.firewall.data?.zones?.active, state.firewall.data?.interfaces)(state)}>Add Range</button>`,
|
|
html`<button class="btn btn-outline" onClick=${() => addLease(state)}>Static Lease</button>`,
|
|
html`<button class="btn btn-outline" onClick=${() => addDns(state)}>DNS Record</button>`,
|
|
(() => {
|
|
const pending = status.pending_changes === true;
|
|
return h('button', {
|
|
class: pending ? 'btn btn-primary btn-apply-pending' : 'btn btn-outline',
|
|
disabled: !pending,
|
|
'on:click': async () => {
|
|
const res = await apiFetch('/api/dhcp/apply', { method: 'POST' });
|
|
if (res.ok) {
|
|
const synced = res.data?.synced;
|
|
let msg = 'dnsmasq applied';
|
|
if (synced && synced.length) {
|
|
msg += ' (auto-synced: ' + synced.join(', ') + ')';
|
|
synced.forEach(s => modelFetch(s));
|
|
}
|
|
toast(msg, 'success');
|
|
modelFetch('dnsmasq');
|
|
} else {
|
|
toast(res.error || 'Apply failed', 'error');
|
|
}
|
|
},
|
|
}, pending ? 'Apply' : 'Synced');
|
|
})(),
|
|
);
|
|
|
|
const leaseTable = state.activeTab === 'active'
|
|
? Table({
|
|
columns: ['MAC', 'IP', 'Hostname', 'Expires'],
|
|
rows: (state.dnsmasq.data?.leases || []).map((l) => html`<tr key=${l.mac || l.ip}>
|
|
<td>${esc(l.mac || '-')}</td>
|
|
<td>${esc(l.ip || '-')}</td>
|
|
<td>${esc(l.hostname || '-')}</td>
|
|
<td>${esc(l.expires || '-')}</td>
|
|
</tr>`),
|
|
emptyText: 'No active leases',
|
|
}) : null;
|
|
|
|
return [
|
|
PageHeader({ title: 'DHCP & DNS', subtitle: 'Dnsmasq management', actions }),
|
|
ServiceStatus({ state: status.service_active ? 'up' : 'down', label: 'Dnsmasq' }),
|
|
Tabs({ state, tabs: tabNames }),
|
|
state.activeTab === 'ranges'
|
|
? Table({ columns: ['Interface', 'Start', 'End', 'Lease', 'Action'], rows: rangesRows, emptyText: 'No DHCP ranges' }) : null,
|
|
state.activeTab === 'leases'
|
|
? Table({ columns: ['MAC', 'IP', 'Hostname', 'Action'], rows: leaseRows, emptyText: 'No static leases' }) : null,
|
|
state.activeTab === 'dns'
|
|
? [
|
|
domainSection,
|
|
Table({ columns: ['Name', 'Address', 'Action'], rows: dnsRows, emptyText: 'No custom DNS records' })
|
|
] : null,
|
|
leaseTable,
|
|
];
|
|
},
|
|
});
|