refactor: modernize frontend with hoover framework components and docs
- Add quick modal, table, service status, and confirmation dialog components - Refactor all pages (certs, dhcp, proxy, etc.) to use new component patterns - Introduce refactor load utility and render guard for consistent UX - Add hoover documentation and update AGENTS.md, architecture, overview
This commit is contained in:
+127
-282
@@ -1,335 +1,180 @@
|
||||
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';
|
||||
import { h, PageHeader, Badge, ConfirmDelete, Tabs, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, definePage, refactorLoad, ActionButton, ActionGroup, QuickModal } from '/static/hoover/index.js?v=6';
|
||||
|
||||
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');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
const addRange = QuickModal({
|
||||
title: 'Add DHCP Range',
|
||||
fields: [
|
||||
{ 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' },
|
||||
],
|
||||
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',
|
||||
},
|
||||
reload: (s) => load(s),
|
||||
});
|
||||
|
||||
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');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
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',
|
||||
},
|
||||
reload: (s) => load(s),
|
||||
});
|
||||
|
||||
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');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
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',
|
||||
},
|
||||
reload: (s) => load(s),
|
||||
});
|
||||
|
||||
async function load(state, abortController, entry) {
|
||||
if (Object.keys(state.config || {}).length) state.refreshing = true;
|
||||
else state.loading = true;
|
||||
try {
|
||||
const myId = entry ? entry.requestId : 0;
|
||||
const sig = abortController?.signal;
|
||||
const cfgR = await apiFetch('/api/dhcp/config', { signal: sig });
|
||||
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return;
|
||||
if (cfgR.ok) state.config = cfgR.data || {};
|
||||
const stR = await apiFetch('/api/dhcp/status', { signal: sig });
|
||||
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return;
|
||||
if (stR.ok) state.status = stR.data || {};
|
||||
const lsR = await apiFetch('/api/dhcp/leases', { signal: sig });
|
||||
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return;
|
||||
if (lsR.ok) state.leases = lsR.data || [];
|
||||
} catch (e) {
|
||||
if (abortController?.signal.aborted) return;
|
||||
state.error = String(e);
|
||||
}
|
||||
state.loading = false;
|
||||
state.refreshing = false;
|
||||
await refactorLoad(state,
|
||||
s => Object.keys(s.config || {}).length,
|
||||
async (s, sig, isAborted) => {
|
||||
const [cfgR, stR, lsR] = await Promise.allSettled([
|
||||
apiFetch('/api/dhcp/config', { signal: sig }),
|
||||
apiFetch('/api/dhcp/status', { signal: sig }),
|
||||
apiFetch('/api/dhcp/leases', { signal: sig }),
|
||||
]);
|
||||
if (isAborted()) return;
|
||||
const errors = [];
|
||||
if (cfgR.status === 'rejected') errors.push(cfgR.reason?.message || 'Failed');
|
||||
else if (!cfgR.value.ok) errors.push(cfgR.value.error || 'Failed');
|
||||
if (stR.status === 'rejected') errors.push(stR.reason?.message || 'Failed');
|
||||
else if (!stR.value.ok) errors.push(stR.value.error || 'Failed');
|
||||
if (lsR.status === 'rejected') errors.push(lsR.reason?.message || 'Failed');
|
||||
else if (!lsR.value.ok) errors.push(lsR.value.error || 'Failed');
|
||||
if (errors.length) {
|
||||
s.error = errors[0];
|
||||
return;
|
||||
}
|
||||
s.config = cfgR.value.data || {};
|
||||
s.status = stR.value.data || {};
|
||||
s.leases = lsR.value.data || [];
|
||||
},
|
||||
{ entry, abortController },
|
||||
);
|
||||
}
|
||||
|
||||
export default definePage({
|
||||
init() {
|
||||
return { config: {}, status: {}, leases: [], loading: true, refreshing: false, error: null, activeTab: 'ranges' };
|
||||
return { config: {}, status: {}, leases: [], activeTab: 'ranges' };
|
||||
},
|
||||
subscribe: ['dnsmasq'],
|
||||
load,
|
||||
render(state) {
|
||||
if (state.loading && !state.refreshing) {
|
||||
return [
|
||||
PageHeader({ title: 'DHCP & DNS' }),
|
||||
h('div', { class: 'card', key: 'loading' },
|
||||
h('div', { class: 'card-body loading' }, state.refreshing ? 'Refreshing...' : '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 guard = renderGuard(state, 'DHCP & DNS', null, state.leases);
|
||||
if (guard) return guard;
|
||||
|
||||
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 },
|
||||
const rangesRows = ranges.map((r, i) => h('tr', { key: (r.interface || '_g') + '-' + r.start + '-' + r.end },
|
||||
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'),
|
||||
ConfirmDelete({
|
||||
url: '/api/dhcp/ranges',
|
||||
message: 'Remove range ' + r.start + ' - ' + r.end + '?',
|
||||
body: { interface: r.interface || '', start: r.start, end: r.end },
|
||||
success: 'Range removed',
|
||||
reload: () => load(state),
|
||||
}),
|
||||
),
|
||||
));
|
||||
|
||||
const leaseRows = staticLeases.map((l, i) => h('tr', { key: i },
|
||||
const leaseRows = staticLeases.map((l, i) => h('tr', { key: l.mac },
|
||||
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'),
|
||||
ConfirmDelete({
|
||||
url: '/api/dhcp/static-lease/' + enc(l.mac),
|
||||
message: 'Remove lease ' + l.mac + '?',
|
||||
success: 'Lease removed',
|
||||
reload: () => load(state),
|
||||
}),
|
||||
),
|
||||
));
|
||||
|
||||
const dnsRows = dnsRecords.map((rec, i) => h('tr', { key: i },
|
||||
const dnsRows = dnsRecords.map((rec, i) => h('tr', { key: rec.name },
|
||||
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'),
|
||||
ConfirmDelete({
|
||||
url: '/api/dhcp/dns-record/' + enc(rec.name || ''),
|
||||
message: 'Remove DNS record ' + (rec.name || 'unnamed') + '?',
|
||||
success: 'Record removed',
|
||||
reload: () => load(state),
|
||||
}),
|
||||
),
|
||||
));
|
||||
|
||||
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'),
|
||||
const actions = ActionGroup(
|
||||
h('button', { class: 'btn btn-primary', 'on:click': () => addRange(state) }, 'Add Range'),
|
||||
h('button', { class: 'btn btn-outline', 'on:click': () => addLease(state) }, 'Static Lease'),
|
||||
h('button', { class: 'btn btn-outline', 'on:click': () => addDns(state) }, 'DNS Record'),
|
||||
ActionButton({
|
||||
url: '/api/dhcp/apply',
|
||||
successMsg: 'dnsmasq applied',
|
||||
label: 'Apply',
|
||||
reload: () => load(state),
|
||||
}),
|
||||
);
|
||||
|
||||
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))),
|
||||
),
|
||||
ServiceStatus({ state: statusUp.state || 'down', label: 'Dnsmasq' }),
|
||||
Tabs({ state, tabs: tabNames }),
|
||||
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,
|
||||
? Table({ columns: ['Interface', 'Start', 'End', 'Lease', 'Action'], rows: rangesRows, emptyText: '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,
|
||||
? Table({ columns: ['MAC', 'IP', 'Hostname', 'Action'], rows: leaseRows, emptyText: '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,
|
||||
? Table({ columns: ['Name', 'Address', 'Action'], rows: dnsRows, emptyText: '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,
|
||||
? Table({ columns: ['MAC', 'IP', 'Hostname', 'Expires'], rows: (state.leases || []).map((l, i) => h('tr', { key: l.mac || 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 || '-')),
|
||||
)), emptyText: 'No active leases' }) : null,
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user