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
+45 -97
View File
@@ -1,4 +1,4 @@
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, openModal, closeModal, formModal, definePage, refactorLoad, ActionCell, certStatusBadge, poll } from '/static/hoover/index.js?v=6';
function issueCertModal(state) {
openModal((inner, idx) => {
@@ -13,14 +13,10 @@ function issueCertModal(state) {
label: 'Issue', cls: 'btn-primary', action: 's', handler: async () => {
const domain = ($val('ic-domain') || '').trim();
if (!domain) { toast('Domain is required', 'error'); return; }
const body = {
domain,
email: ($val('ic-email') || '').trim() || undefined,
};
const body = { domain, email: ($val('ic-email') || '').trim() || undefined };
const resp = await apiFetch('/api/certs/issue/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
body,
});
if (resp.ok) {
toast('Issuance started for ' + domain, 'success');
@@ -38,103 +34,63 @@ function issueCertModal(state) {
}
async function pollCertIssue(rid, state) {
let done = false;
const timer = setInterval(async () => {
if (done) return clearInterval(timer);
const r = await apiFetch('/api/certs/issue/' + enc(rid));
if (r.ok && r.data) {
if (r.data.status === 'completed') {
done = true;
clearInterval(timer);
toast('Certificate issued for ' + (r.data.domain || rid), 'success');
await load(state);
} else if (r.data.status === 'failed') {
done = true;
clearInterval(timer);
toast('Issuance failed: ' + (r.data.error || 'unknown'), 'error');
}
}
}, 2000);
poll({
url: '/api/certs/issue/' + enc(rid),
successKey: (d) => d.status === 'completed',
onErrorKey: (d) => d.status === 'failed',
onComplete: (d) => {
toast('Certificate issued for ' + (d.domain || rid), 'success');
load(state);
},
onError: (d) => {
toast('Issuance failed: ' + (d?.error || 'unknown'), 'error');
},
});
}
async function load(state, abortController, entry) {
if (state.certs?.length) state.refreshing = true;
else state.loading = true;
try {
const myId = entry ? entry.requestId : 0;
const sig = abortController?.signal;
const r = await apiFetch('/api/certs/list', { signal: sig });
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return;
if (r.ok) state.certs = r.data || [];
else state.error = r.error;
} catch (e) {
if (abortController?.signal.aborted) return;
state.error = String(e);
}
state.loading = false;
state.refreshing = false;
await refactorLoad(state,
s => s.certs?.length,
async (s, sig, isAborted) => {
const r = await apiFetch('/api/certs/list', { signal: sig });
if (isAborted()) return;
if (r.ok) s.certs = r.data || [];
else s.error = r.error;
},
{ entry, abortController },
);
}
export default definePage({
init() {
return { certs: [], loading: true, refreshing: false, error: null };
return { certs: [] };
},
subscribe: ['acme'],
load,
render(state) {
if (state.loading && !state.refreshing) {
return [
PageHeader({ title: 'Certificates' }),
h('div', { class: 'card', key: 'loading' },
h('div', { class: 'card-body loading' }, state.refreshing ? 'Refreshing...' : 'Loading...'),
),
];
}
if (state.error) {
return [
PageHeader({ title: 'Certificates' }),
h('div', { class: 'card', key: 'error' },
h('div', { class: 'card-body error-msg' }, state.error),
),
];
}
const guard = renderGuard(state, 'Certificates', 'ACME certificate management', state.certs);
if (guard) return guard;
const rows = state.certs.map(c => {
const days = c.days_remaining;
let badge;
if (c.expired || (days !== undefined && days <= 0)) {
badge = Badge({ text: 'Expired', variant: 'danger' });
} else if (days !== undefined && days <= 30) {
badge = Badge({ text: days + 'd left', variant: 'warning' });
} else {
badge = Badge({ text: days !== undefined ? days + 'd left' : 'N/A', variant: 'success' });
}
const badge = certStatusBadge({ expired: c.expired, daysRemaining: c.days_remaining });
return h('tr', { key: c.domain },
h('td', null, h('strong', null, esc(c.domain || 'unknown'))),
h('td', { class: 'text-sm' }, esc(c.issuer || '-')),
h('td', null, esc(c.expiry || 'N/A')),
h('td', null, badge),
h('td', null,
h('button', { class: 'btn btn-sm btn-outline', style: 'margin-right:4px;',
'on:click': async () => {
const resp = await apiFetch('/api/certs/' + enc(c.domain) + '/renew', { method: 'POST' });
if (resp.ok) toast('Renewal started for ' + c.domain, 'success');
else toast(resp.error || 'Failed', 'error');
}}, 'Renew'),
h('button', { class: 'btn btn-sm btn-danger',
'on:click': async () => {
if (!confirm('Remove certificate for ' + c.domain + '?')) return;
const resp = await apiFetch('/api/certs/' + enc(c.domain), { method: 'DELETE' });
if (resp.ok) {
toast('Certificate removed', 'success');
await load(state);
} else {
toast(resp.error || 'Failed', 'error');
}
}}, 'Remove'),
),
ActionCell({
editLabel: 'Renew',
editClick: async () => {
const resp = await apiFetch('/api/certs/' + enc(c.domain) + '/renew', { method: 'POST' });
if (resp.ok) toast('Renewal started for ' + c.domain, 'success');
else toast(resp.error || 'Failed', 'error');
},
removeUrl: '/api/certs/' + enc(c.domain),
removeMessage: 'Remove certificate for ' + c.domain + '?',
removeSuccess: 'Certificate removed',
removeReload: () => load(state),
}),
);
});
@@ -146,18 +102,10 @@ export default definePage({
'on:click': () => issueCertModal(state) }, 'Issue Certificate'),
}),
rows.length
? h('div', { class: 'card' }, h('table', { class: 'table' },
h('thead', null,
h('tr', null,
h('th', null, 'Domain'),
h('th', null, 'Issuer'),
h('th', null, 'Expiry'),
h('th', null, 'Status'),
h('th', { style: 'width:120px;' }, 'Actions'),
),
),
h('tbody', null, ...rows),
))
? Table({
columns: ['Domain', 'Issuer', 'Expiry', 'Status', 'Actions'],
rows,
})
: Empty({ text: 'No certificates found. Issue a certificate to get started.' }),
];
},