89b64960f3
Add hoover/dirty.js: line-matching helpers that flag UI rows/cards edited (saved to config) but not yet applied, consuming the pending state the daemon already streams — status.pending_diff for hash subsystems, firewall pending zone+type for firewalld. Visual language is amber (.config-dirty + PendingDot), distinct from the red .pending-delete; orphanInfo surfaces removed entries (e.g. WireGuard peers) on their container table. Wired into the backends, dhcp, interfaces, nat, proxy, rules, wireguard, and zones pages; Card and Table gain cls/title props. Covered by 27 node tests (tests/test-dirty.js).
261 lines
12 KiB
JavaScript
261 lines
12 KiB
JavaScript
import { html, h, PageHeader, ConfirmDelete, Tabs, Table, ServiceStatus, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, ActionButton, ActionGroup, QuickModal, ApplyConfirm, PendingDot, dirtySet, dirtyInfo } from '/static/hoover/index.js';
|
|
|
|
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',
|
|
},
|
|
});
|
|
}
|
|
|
|
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',
|
|
},
|
|
});
|
|
|
|
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',
|
|
},
|
|
});
|
|
|
|
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 set = dirtySet(state.dnsmasq.data?.status);
|
|
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, i) => {
|
|
const info = dirtyInfo(set, 'dhcp.ranges[' + i + ']');
|
|
return html`<tr key=${(r.interface || '_g') + '-' + r.start + '-' + r.end} class=${info.class || undefined} title=${info.title || undefined}>
|
|
<td>${info.dirty ? PendingDot({}) : ''}${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" />
|
|
</td>
|
|
</tr>`;
|
|
});
|
|
|
|
const leaseRows = staticLeases.map((l, i) => {
|
|
const info = dirtyInfo(set, 'dhcp.static_leases[' + i + ']');
|
|
return html`<tr key=${l.mac} class=${info.class || undefined} title=${info.title || undefined}>
|
|
<td>${info.dirty ? PendingDot({}) : ''}${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" />
|
|
</td>
|
|
</tr>`;
|
|
});
|
|
|
|
const dnsRows = dnsRecords.map((rec, i) => {
|
|
const info = dirtyInfo(set, 'dns.custom_records[' + i + ']');
|
|
return html`<tr key=${rec.name} class=${info.class || undefined} title=${info.title || undefined}>
|
|
<td>${info.dirty ? PendingDot({}) : ''}<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" />
|
|
</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');
|
|
// No modelFetch — WS delta updates the dnsmasq model.
|
|
} else {
|
|
toast(res.error || 'Failed to update', 'error');
|
|
}
|
|
};
|
|
|
|
const currentDomain = dnsCfg.domain || null;
|
|
const domainInfo = dirtyInfo(set, 'dns.domain');
|
|
const domainSection = html`<div class="domain-config ${domainInfo.class}" title=${domainInfo.title || undefined} 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?.active_zones, 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(', ') + ')';
|
|
}
|
|
toast(msg, 'success');
|
|
// No modelFetch — WS delta updates the dnsmasq model.
|
|
} 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,
|
|
];
|
|
},
|
|
});
|