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'; 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` ${r.interface || '(global)'} ${esc(r.start)} ${esc(r.end)} ${esc(r.lease_time || '12h')} <${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" /> `); const leaseRows = staticLeases.map((l) => html` ${esc(l.mac)} ${esc(l.ip)} ${l.hostname || '-'} <${ConfirmDelete} url=${'/api/dhcp/static-lease/' + enc(l.mac)} deleteKey=${l.mac} message=${'Remove lease ' + l.mac + '?'} success="Lease removed" refresh="dnsmasq" /> `); const dnsRows = dnsRecords.map((rec) => html` ${esc(rec.name || 'unnamed')} ${esc(rec.address || '-')} <${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" /> `); 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`

${currentDomain ? esc(currentDomain) : '(not set)'}

`; const tabNames = ['ranges', 'leases', 'dns', 'active']; const actions = ActionGroup( html``, html``, html``, (() => { 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` ${esc(l.mac || '-')} ${esc(l.ip || '-')} ${esc(l.hostname || '-')} ${esc(l.expires || '-')} `), 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, ]; }, });