import { h, PageHeader, Badge, StatusDot, Empty, Card, esc, enc, att_esc, $val, apiFetch, toast, dismissToast, openModal, closeModal, formModal, definePage, hComp, connect, reactive, requestUpdate, Link, createRouter, ToastContainer, render, parseZones } from '/static/hoover/index.js'; function addRangeModal(state) { openModal((inner, idx) => { formModal(inner, 'Add DHCP Range', [ { label: 'Interface (optional)', id: 'r-iface', placeholder: 'Leave empty for global' }, { 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' }, ], [ { label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) }, { label: 'Add', cls: 'btn-primary', action: 's', handler: async () => { const body = { interface: ($val('r-iface') || '').trim() || undefined, start: ($val('r-start') || '').trim(), end: ($val('r-end') || '').trim(), lease_time: ($val('r-lease') || '').trim() || '12h', }; if (!body.start || !body.end) { toast('Start and end are required', 'error'); return; } const resp = await apiFetch('/api/dhcp/ranges', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (resp.ok) { toast('Range added', 'success'); closeModal(idx); await load(state); } else { toast(resp.error || 'Failed', 'error'); } }, }, ], ); }); } function addLeaseModal(state) { openModal((inner, idx) => { formModal(inner, 'Add Static Lease', [ { 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' }, ], [ { label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) }, { label: 'Add', cls: 'btn-primary', action: 's', handler: async () => { const body = { mac: ($val('l-mac') || '').trim(), ip: ($val('l-ip') || '').trim(), hostname: ($val('l-host') || '').trim() || undefined, }; if (!body.mac || !body.ip) { toast('MAC and IP are required', 'error'); return; } const resp = await apiFetch('/api/dhcp/static-lease', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (resp.ok) { toast('Lease added', 'success'); closeModal(idx); await load(state); } else { toast(resp.error || 'Failed', 'error'); } }, }, ], ); }); } function addDnsModal(state) { openModal((inner, idx) => { formModal(inner, 'Add DNS Record', [ { label: 'Name', id: 'd-name', placeholder: 'host.local' }, { label: 'Address', id: 'd-addr', placeholder: '192.168.1.10' }, ], [ { label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) }, { label: 'Add', cls: 'btn-primary', action: 's', handler: async () => { const body = { name: ($val('d-name') || '').trim(), address: ($val('d-addr') || '').trim(), }; if (!body.name || !body.address) { toast('Name and address are required', 'error'); return; } const resp = await apiFetch('/api/dhcp/dns-record', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (resp.ok) { toast('DNS record added', 'success'); closeModal(idx); await load(state); } else { toast(resp.error || 'Failed', 'error'); } }, }, ], ); }); } async function load(state) { try { const cfgR = await apiFetch('/api/dhcp/config'); if (cfgR.ok) state.config = cfgR.data || {}; const stR = await apiFetch('/api/dhcp/status'); if (stR.ok) state.status = stR.data || {}; const lsR = await apiFetch('/api/dhcp/leases'); if (lsR.ok) state.leases = lsR.data || []; } catch (e) { state.error = String(e); } state.loading = false; } export default definePage({ init() { return { config: {}, status: {}, leases: [], loading: true, error: null, activeTab: 'ranges' }; }, subscribe: ['dnsmasq'], load, render(state) { if (state.loading) { return [ PageHeader({ title: 'DHCP & DNS' }), h('div', { class: 'card', key: 'loading' }, h('div', { class: 'card-body loading' }, 'Loading...'), ), ]; } if (state.error) { return [ PageHeader({ title: 'DHCP & DNS' }), h('div', { class: 'card', key: 'error' }, h('div', { class: 'card-body error-msg' }, state.error), ), ]; } const cfg = state.config || {}; const ranges = cfg.ranges || []; const staticLeases = cfg.static_leases || []; const dnsRecords = cfg.dns_records || []; const statusUp = state.status || {}; const isUp = statusUp.state === 'up'; const rangesRows = ranges.map((r, i) => h('tr', { key: i }, h('td', null, r.interface || '(global)'), h('td', null, esc(r.start)), h('td', null, esc(r.end)), h('td', null, esc(r.lease_time || '12h')), h('td', null, h('button', { class: 'btn btn-sm btn-danger', 'on:click': async () => { if (!confirm('Remove range ' + r.start + ' - ' + r.end + '?')) return; const resp = await apiFetch('/api/dhcp/ranges', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ interface: r.interface || '', start: r.start, end: r.end }), }); if (resp.ok) { toast('Range removed', 'success'); await load(state); } else { toast(resp.error || 'Failed', 'error'); } }}, 'Remove'), ), )); const leaseRows = staticLeases.map((l, i) => h('tr', { key: i }, h('td', null, esc(l.mac)), h('td', null, esc(l.ip)), h('td', null, l.hostname || '-'), h('td', null, h('button', { class: 'btn btn-sm btn-danger', 'on:click': async () => { if (!confirm('Remove lease ' + l.mac + '?')) return; const resp = await apiFetch('/api/dhcp/static-lease/' + enc(l.mac), { method: 'DELETE' }); if (resp.ok) { toast('Lease removed', 'success'); await load(state); } else { toast(resp.error || 'Failed', 'error'); } }}, 'Remove'), ), )); const dnsRows = dnsRecords.map((rec, i) => h('tr', { key: i }, h('td', null, h('strong', null, esc(rec.name || 'unnamed'))), h('td', { class: 'text-sm' }, esc(rec.address || '-')), h('td', null, h('button', { class: 'btn btn-sm btn-danger', 'on:click': async () => { if (!confirm('Remove DNS record ' + (rec.name || 'unnamed') + '?')) return; const resp = await apiFetch('/api/dhcp/dns-record/' + enc(rec.name || ''), { method: 'DELETE' }); if (resp.ok) { toast('Record removed', 'success'); await load(state); } else { toast(resp.error || 'Failed', 'error'); } }}, 'Remove'), ), )); const tabNames = ['ranges', 'leases', 'dns', 'active']; const actions = h('div', { style: 'display:flex;gap:8px;' }, h('button', { class: 'btn btn-primary', 'on:click': () => addRangeModal(state) }, 'Add Range'), h('button', { class: 'btn btn-outline', 'on:click': () => addLeaseModal(state) }, 'Static Lease'), h('button', { class: 'btn btn-outline', 'on:click': () => addDnsModal(state) }, 'DNS Record'), h('button', { class: 'btn btn-outline', 'on:click': async () => { const resp = await apiFetch('/api/dhcp/apply', { method: 'POST' }); if (resp.ok) toast('dnsmasq applied', 'success'); else toast(resp.error || 'Failed', 'error'); }}, 'Apply'), ); return [ PageHeader({ title: 'DHCP & DNS', subtitle: 'Dnsmasq management', actions }), h('div', null, StatusDot({ status: isUp ? 'success' : 'danger' }), ' Dnsmasq ', Badge({ text: statusUp.state || 'unknown', variant: isUp ? 'success' : 'danger' }), ), h('div', { class: 'tabs' }, tabNames.map(t => h('span', { class: 'tab ' + (state.activeTab === t ? 'active' : ''), 'on:click': () => { state.activeTab = t; }, style: 'cursor:pointer;', }, t.charAt(0).toUpperCase() + t.slice(1))), ), state.activeTab === 'ranges' ? h('div', { class: 'card' }, h('table', { class: 'table' }, h('thead', null, h('tr', null, h('th', null, 'Interface'), h('th', null, 'Start'), h('th', null, 'End'), h('th', null, 'Lease'), h('th', { style: 'width:80px;' }, 'Action'), ), ), h('tbody', null, ...(rangesRows.length ? rangesRows : [ h('tr', null, h('td', { colspan: 5, class: 'text-muted text-sm' }, 'No DHCP ranges')), ]), ), )) : null, state.activeTab === 'leases' ? h('div', { class: 'card' }, h('table', { class: 'table' }, h('thead', null, h('tr', null, h('th', null, 'MAC'), h('th', null, 'IP'), h('th', null, 'Hostname'), h('th', { style: 'width:80px;' }, 'Action'), ), ), h('tbody', null, ...(leaseRows.length ? leaseRows : [ h('tr', null, h('td', { colspan: 4, class: 'text-muted text-sm' }, 'No static leases')), ]), ), )) : null, state.activeTab === 'dns' ? h('div', { class: 'card' }, h('table', { class: 'table' }, h('thead', null, h('tr', null, h('th', null, 'Name'), h('th', null, 'Address'), h('th', { style: 'width:80px;' }, 'Action'), ), ), h('tbody', null, ...(dnsRows.length ? dnsRows : [ h('tr', null, h('td', { colspan: 3, class: 'text-muted text-sm' }, 'No custom DNS records')), ]), ), )) : null, state.activeTab === 'active' ? h('div', { class: 'card' }, h('table', { class: 'table' }, h('thead', null, h('tr', null, h('th', null, 'MAC'), h('th', null, 'IP'), h('th', null, 'Hostname'), h('th', null, 'Expires'), ), ), h('tbody', null, (state.leases || []).map((l, i) => h('tr', { key: i }, h('td', null, esc(l.mac || '-')), h('td', null, esc(l.ip || '-')), h('td', null, esc(l.hostname || '-')), h('td', null, esc(l.expires || '-')), )), ), )) : null, ]; }, });