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:
2026-06-21 04:29:27 +00:00
parent b8f20e99d9
commit 633505e7dc
29 changed files with 2558 additions and 1414 deletions
+89 -162
View File
@@ -1,143 +1,85 @@
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, Empty, Table, renderGuard, esc, enc, $val, apiFetch, toast, definePage, refactorLoad, ActionButton, ActionCell, certStatusBadge, ActionGroup, QuickModal } from '/static/hoover/index.js?v=6';
function addDomainModal(state) {
openModal((inner, idx) => {
formModal(inner, 'Add Proxy Domain',
[
{ label: 'Domain', id: 'p-domain', placeholder: 'example.com' },
{ label: 'Backend Host', id: 'p-host', placeholder: '192.168.1.10' },
{ label: 'Backend Port', id: 'p-port', type: 'number', placeholder: '8080' },
{ label: 'Protocol', id: 'p-proto', placeholder: 'http or https' },
{ label: 'Cert (optional)', id: 'p-cert', placeholder: 'acme' },
],
[
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
{
label: 'Add', cls: 'btn-primary', action: 's', handler: async () => {
const body = {
domain: ($val('p-domain') || '').trim(),
backend_host: ($val('p-host') || '').trim(),
backend_port: parseInt($val('p-port')),
backend_proto: ($val('p-proto') || 'http').trim() || 'http',
cert: ($val('p-cert') || '').trim() || undefined,
};
if (!body.domain || !body.backend_host || !body.backend_port) {
toast('Domain, host, and port are required', 'error');
return;
}
const resp = await apiFetch('/api/proxy/domains', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (resp.ok) {
toast('Domain added', 'success');
closeModal(idx);
await load(state);
} else {
toast(resp.error || 'Failed', 'error');
}
},
},
],
);
});
}
const addDomain = QuickModal({
title: 'Add Proxy Domain',
fields: [
{ label: 'Domain', id: 'p-domain', placeholder: 'example.com' },
{ label: 'Backend Host', id: 'p-host', placeholder: '192.168.1.10' },
{ label: 'Backend Port', id: 'p-port', type: 'number', placeholder: '8080' },
{ label: 'Protocol', id: 'p-proto', placeholder: 'http or https' },
{ label: 'Cert (optional)', id: 'p-cert', placeholder: 'acme' },
],
submit: {
url: '/api/proxy/domains',
body: () => ({
domain: ($val('p-domain') || '').trim(),
backend_host: ($val('p-host') || '').trim(),
backend_port: parseInt($val('p-port')),
backend_proto: ($val('p-proto') || 'http').trim() || 'http',
cert: ($val('p-cert') || '').trim() || undefined,
}),
validate: (b) => !b.domain || !b.backend_host || !b.backend_port ? 'Domain, host, and port are required' : null,
successMsg: 'Domain added',
},
reload: (s) => load(s),
});
function editDomainModal(domain, state) {
openModal((inner, idx) => {
formModal(inner, 'Edit: ' + domain.domain,
[
{ label: 'Backend Host', id: 'pe-host', value: domain.backend_host || '' },
{ label: 'Backend Port', id: 'pe-port', type: 'number', value: domain.backend_port || '' },
{ label: 'Protocol', id: 'pe-proto', value: domain.backend_proto || domain.protocol || 'http' },
{ label: 'Cert (optional)', id: 'pe-cert', value: domain.cert || '' },
],
[
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
{
label: 'Save', cls: 'btn-primary', action: 's', handler: async () => {
const body = {
backend_host: ($val('pe-host') || '').trim(),
backend_port: parseInt($val('pe-port')),
backend_proto: ($val('pe-proto') || 'http').trim(),
cert: ($val('pe-cert') || '').trim() || undefined,
};
const resp = await apiFetch('/api/proxy/domains/' + enc(domain.domain), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (resp.ok) {
toast('Domain updated', 'success');
closeModal(idx);
await load(state);
} else {
toast(resp.error || 'Failed', 'error');
}
},
},
],
);
});
}
const editDomain = QuickModal({
title: (d) => 'Edit: ' + d.domain,
fields: (d) => [
{ label: 'Backend Host', id: 'pe-host', value: d.backend_host || '' },
{ label: 'Backend Port', id: 'pe-port', type: 'number', value: d.backend_port || '' },
{ label: 'Protocol', id: 'pe-proto', value: d.backend_proto || d.protocol || 'http' },
{ label: 'Cert (optional)', id: 'pe-cert', value: d.cert || '' },
],
submit: {
url: (d) => '/api/proxy/domains/' + enc(d.domain),
method: 'PUT',
body: (d) => ({
backend_host: ($val('pe-host') || '').trim(),
backend_port: parseInt($val('pe-port')),
backend_proto: ($val('pe-proto') || 'http').trim(),
cert: ($val('pe-cert') || '').trim() || undefined,
}),
validate: (b) => !b.backend_host || !b.backend_port ? 'Host and port are required' : null,
successMsg: 'Domain updated',
},
reload: (s) => load(s._s),
});
async function load(state, abortController, entry) {
if (state.domains?.length) state.refreshing = true;
else state.loading = true;
try {
const myId = entry ? entry.requestId : 0;
const sig = abortController?.signal;
const domainsR = await apiFetch('/api/proxy/domains', { signal: sig });
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return;
if (domainsR.ok) state.domains = domainsR.data || [];
const certsR = await apiFetch('/api/certs/list', { signal: sig });
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return;
if (certsR.ok) state.certs = certsR.data || [];
} catch (e) {
if (abortController?.signal.aborted) return;
state.error = String(e);
}
state.loading = false;
state.refreshing = false;
await refactorLoad(state,
s => s.domains?.length,
async (s, sig, isAborted) => {
const domainsR = await apiFetch('/api/proxy/domains', { signal: sig });
if (isAborted()) return;
if (domainsR.ok) s.domains = domainsR.data || [];
else s.error = domainsR.error;
const certsR = await apiFetch('/api/certs/list', { signal: sig });
if (isAborted()) return;
if (certsR.ok) s.certs = certsR.data || [];
else if (!s.error) s.error = certsR.error;
},
{ entry, abortController },
);
}
export default definePage({
init() {
return { domains: [], certs: [], loading: true, refreshing: false, error: null };
return { domains: [], certs: [] };
},
subscribe: ['nginx', 'acme'],
load,
render(state) {
if (state.loading && !state.refreshing) {
return [
PageHeader({ title: 'Proxy' }),
h('div', { class: 'card', key: 'loading' },
h('div', { class: 'card-body loading' }, state.refreshing ? 'Refreshing...' : 'Loading...'),
),
];
}
if (state.error) {
return [
PageHeader({ title: 'Proxy' }),
h('div', { class: 'card', key: 'error' },
h('div', { class: 'card-body error-msg' }, state.error),
),
];
}
const guard = renderGuard(state, 'Proxy', 'Nginx reverse proxy', state.domains);
if (guard) return guard;
const rows = state.domains.map(d => {
let certBadge = Badge({ text: 'No cert', variant: 'info' });
if (d.cert_status === 'valid' || d.cert_status === 'active') {
certBadge = Badge({ text: 'Valid', variant: 'success' });
} else if (d.cert_status === 'expired' || (d.days_remaining !== undefined && d.days_remaining <= 0)) {
certBadge = Badge({ text: 'Expired', variant: 'danger' });
} else if (d.days_remaining !== undefined && d.days_remaining <= 30) {
certBadge = Badge({ text: d.days_remaining + 'd', variant: 'warning' });
} else if (d.days_remaining !== undefined) {
certBadge = Badge({ text: d.days_remaining + 'd', variant: 'success' });
}
const certBadge = certStatusBadge({
certStatus: d.cert_status,
daysRemaining: d.days_remaining,
expired: d.cert_status === 'expired',
});
return h('tr', { key: d.domain },
h('td', null, h('strong', null, esc(d.domain))),
@@ -145,50 +87,35 @@ export default definePage({
h('td', null, d.backend_port || '-'),
h('td', null, Badge({ text: d.backend_proto || d.protocol || 'http', variant: 'info' })),
h('td', null, certBadge),
h('td', null,
h('button', { class: 'btn btn-sm btn-outline', style: 'margin-right:4px;',
'on:click': () => editDomainModal(d, state) }, 'Edit'),
h('button', { class: 'btn btn-sm btn-danger',
'on:click': async () => {
if (!confirm('Remove proxy for ' + d.domain + '?')) return;
const r = await apiFetch('/api/proxy/domains/' + enc(d.domain), { method: 'DELETE' });
if (r.ok) {
toast('Domain removed', 'success');
await load(state);
} else {
toast(r.error || 'Failed', 'error');
}
}}, 'Delete'),
),
ActionCell({
editLabel: 'Edit',
editClick: () => editDomain({ ...d, _s: state }),
removeUrl: '/api/proxy/domains/' + enc(d.domain),
removeMessage: 'Remove proxy for ' + d.domain + '?',
removeSuccess: 'Domain removed',
removeReload: () => load(state),
removeLabel: 'Delete',
}),
);
});
const actions = h('div', { style: 'display:flex;gap:8px;' },
h('button', { class: 'btn btn-primary', 'on:click': () => addDomainModal(state) }, 'Add Domain'),
h('button', { class: 'btn btn-outline',
'on:click': async () => {
const resp = await apiFetch('/api/proxy/apply', { method: 'POST' });
if (resp.ok) toast('Nginx applied & reloaded', 'success');
else toast(resp.error || 'Failed', 'error');
}}, 'Apply'),
const actions = ActionGroup(
h('button', { class: 'btn btn-primary', 'on:click': () => addDomain(state) }, 'Add Domain'),
ActionButton({
url: '/api/proxy/apply',
successMsg: 'Nginx applied & reloaded',
label: 'Apply',
reload: () => load(state),
}),
);
return [
PageHeader({ title: 'Proxy', subtitle: 'Nginx reverse proxy', actions }),
rows.length
? h('div', { class: 'card' }, h('table', { class: 'table' },
h('thead', null,
h('tr', null,
h('th', null, 'Domain'),
h('th', null, 'Backend Host'),
h('th', null, 'Port'),
h('th', null, 'Proto'),
h('th', null, 'Cert'),
h('th', { style: 'width:140px;' }, 'Actions'),
),
),
h('tbody', null, ...rows),
))
? Table({
columns: ['Domain', 'Backend Host', 'Port', 'Proto', 'Cert', 'Actions'],
rows,
})
: Empty({ text: 'No proxy domains configured. Add a domain to start terminating SSL.' }),
];
},