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
+73 -148
View File
@@ -1,43 +1,26 @@
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, StatusDot, Empty, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, apiSubmit, refactorLoad, ActionButton, ActionCell, MonoText, ActionGroup, QuickModal, downloadBlob } from '/static/hoover/index.js?v=6';
function addPeerModal(state) {
openModal((inner, idx) => {
formModal(inner, 'Add WireGuard Peer',
[
{ label: 'Name', id: 'wg-name', placeholder: 'client-name' },
{ label: 'Endpoint (optional)', id: 'wg-endpoint', placeholder: '1.2.3.4:51820' },
{ label: 'Allowed IPs (optional)', id: 'wg-allowed', placeholder: '10.0.0.2/32' },
{ label: 'Persistent Keepalive (optional)', id: 'wg-keepalive', type: 'number', placeholder: '25' },
],
[
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
{
label: 'Add', cls: 'btn-primary', action: 's', handler: async () => {
const body = {
name: ($val('wg-name') || '').trim(),
endpoint: ($val('wg-endpoint') || '').trim() || undefined,
allowed_ips: ($val('wg-allowed') || '').trim() ? [($val('wg-allowed') || '').trim()] : [],
persistent_keepalive: $val('wg-keepalive') ? parseInt($val('wg-keepalive')) : undefined,
};
if (!body.name) { toast('Name is required', 'error'); return; }
const resp = await apiFetch('/api/wireguard/peers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (resp.ok) {
toast('Peer added', 'success');
closeModal(idx);
await load(state);
} else {
toast(resp.error || 'Failed', 'error');
}
},
},
],
);
});
}
const addPeer = QuickModal({
title: 'Add WireGuard Peer',
fields: [
{ label: 'Name', id: 'wg-name', placeholder: 'client-name' },
{ label: 'Endpoint (optional)', id: 'wg-endpoint', placeholder: '1.2.3.4:51820' },
{ label: 'Allowed IPs (optional)', id: 'wg-allowed', placeholder: '10.0.0.2/32' },
{ label: 'Persistent Keepalive (optional)', id: 'wg-keepalive', type: 'number', placeholder: '25' },
],
submit: {
url: '/api/wireguard/peers',
body: () => ({
name: ($val('wg-name') || '').trim(),
endpoint: ($val('wg-endpoint') || '').trim() || undefined,
allowed_ips: ($val('wg-allowed') || '').trim() ? [($val('wg-allowed') || '').trim()] : [],
persistent_keepalive: $val('wg-keepalive') ? parseInt($val('wg-keepalive')) : undefined,
}),
validate: (b) => !b.name ? 'Name is required' : null,
successMsg: 'Peer added',
},
reload: (s) => load(s),
});
function downloadConfigModal(peerName, config, state) {
openModal((inner, idx) => {
@@ -51,19 +34,10 @@ function downloadConfigModal(peerName, config, state) {
if (!endpoint) { toast('Server endpoint is required', 'error'); return; }
const resp = await apiFetch('/api/wireguard/generate-client', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: peerName, server_endpoint: endpoint }),
body: { name: peerName, server_endpoint: endpoint },
});
if (resp.ok && resp.data?.config) {
const blob = new Blob([resp.data.config], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = peerName + '.conf';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
downloadBlob(new Blob([resp.data.config], { type: 'text/plain' }), peerName + '.conf');
toast('Config downloaded', 'success');
closeModal(idx);
} else {
@@ -77,52 +51,35 @@ function downloadConfigModal(peerName, config, state) {
}
async function load(state, abortController, entry) {
if (state.peers?.length) state.refreshing = true;
else state.loading = true;
try {
const myId = entry ? entry.requestId : 0;
const sig = abortController?.signal;
const stR = await apiFetch('/api/wireguard/status', { signal: sig });
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return;
if (stR.ok) state.status = stR.data || {};
const pR = await apiFetch('/api/wireguard/peers', { signal: sig });
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return;
if (pR.ok) state.peers = pR.data || [];
const cfgR = await apiFetch('/api/wireguard/config', { signal: sig });
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return;
if (cfgR.ok) state.config = cfgR.data || {};
} catch (e) {
if (abortController?.signal.aborted) return;
state.error = String(e);
}
state.loading = false;
state.refreshing = false;
await refactorLoad(state,
s => s.peers?.length,
async (s, sig, isAborted) => {
const stR = await apiFetch('/api/wireguard/status', { signal: sig });
if (isAborted()) return;
if (stR.ok) s.status = stR.data || {};
else s.error = stR.error;
const pR = await apiFetch('/api/wireguard/peers', { signal: sig });
if (isAborted()) return;
if (pR.ok) s.peers = pR.data || [];
else if (!s.error) s.error = pR.error;
const cfgR = await apiFetch('/api/wireguard/config', { signal: sig });
if (isAborted()) return;
if (cfgR.ok) s.config = cfgR.data || {};
else if (!s.error) s.error = cfgR.error;
},
{ entry, abortController },
);
}
export default definePage({
init() {
return { status: {}, peers: [], config: {}, loading: true, refreshing: false, error: null };
return { status: {}, peers: [], config: {} };
},
subscribe: ['wireguard'],
load,
render(state) {
if (state.loading && !state.refreshing) {
return [
PageHeader({ title: 'WireGuard' }),
h('div', { class: 'card', key: 'loading' },
h('div', { class: 'card-body loading' }, state.refreshing ? 'Refreshing...' : 'Loading...'),
),
];
}
if (state.error) {
return [
PageHeader({ title: 'WireGuard' }),
h('div', { class: 'card', key: 'error' },
h('div', { class: 'card-body error-msg' }, state.error),
),
];
}
const guard = renderGuard(state, 'WireGuard', 'Tunnel: ' + ((state.status || {}).state || 'unknown') + ', Listen: ' + ((state.config?.interface || {}).listen_port || '-'), state.peers);
if (guard) return guard;
const st = state.status || {};
const isUp = st.state === 'up';
@@ -135,10 +92,7 @@ export default definePage({
StatusDot({ status: hasHandshake ? 'success' : 'danger' }),
h('strong', null, esc(p.name || 'unnamed')),
),
h('td', { style: 'font-family:monospace;font-size:11px;' },
esc((p.public_key || 'N/A').substring(0, 20)) +
(p.public_key && p.public_key.length > 20 ? '...' : ''),
),
h('td', null, MonoText({ text: p.public_key || 'N/A', maxLength: 20 })),
h('td', { class: 'text-sm' }, esc(p.allowed_ips || '-')),
h('td', { class: 'text-sm' }, esc(p.endpoint || '-')),
h('td', { class: 'text-sm' }, esc(p.latest_handshake || 'Never')),
@@ -147,46 +101,31 @@ export default definePage({
h('br'),
'Sent: ' + esc(p.transfer_sent || '0'),
),
h('td', null,
h('button', { class: 'btn btn-sm btn-outline', style: 'margin-right:4px;',
'on:click': () => downloadConfigModal(p.name, state.config, state) }, 'Config'),
h('button', { class: 'btn btn-sm btn-danger',
'on:click': async () => {
if (!confirm('Remove peer ' + p.name + '?')) return;
const resp = await apiFetch('/api/wireguard/peers/' + enc(p.name), { method: 'DELETE' });
if (resp.ok) {
toast('Peer removed', 'success');
await load(state);
} else {
toast(resp.error || 'Failed', 'error');
}
}}, 'Remove'),
),
ActionCell({
editLabel: 'Config',
editClick: () => downloadConfigModal(p.name, state.config, state),
removeUrl: '/api/wireguard/peers/' + enc(p.name),
removeMessage: 'Remove peer ' + p.name + '?',
removeSuccess: 'Peer removed',
removeReload: () => load(state),
}),
);
});
const actions = h('div', { style: 'display:flex;gap:8px;' },
h('button', { class: 'btn btn-primary', 'on:click': () => addPeerModal(state) }, 'Add Peer'),
h('button', { class: 'btn btn-outline',
'on:click': async () => {
const resp = await apiFetch('/api/wireguard/' + (isUp ? 'down' : 'up'), { method: 'POST' });
if (resp.ok) {
toast('Tunnel ' + (isUp ? 'stopped' : 'started'), 'success');
await load(state);
} else {
toast(resp.error || 'Failed', 'error');
}
}}, isUp ? 'Stop' : 'Start'),
h('button', { class: 'btn btn-outline',
'on:click': async () => {
const resp = await apiFetch('/api/wireguard/apply', { method: 'POST' });
if (resp.ok) {
toast('Config applied', 'success');
await load(state);
} else {
toast(resp.error || 'Failed', 'error');
}
}}, 'Apply'),
const actions = ActionGroup(
h('button', { class: 'btn btn-primary', 'on:click': () => addPeer(state) }, 'Add Peer'),
ActionButton({
url: '/api/wireguard/' + (isUp ? 'down' : 'up'),
labelOn: 'Stop', labelOff: 'Start', condition: isUp,
successMsg: 'Tunnel ' + (isUp ? 'stopped' : 'started'),
reload: () => load(state),
}),
ActionButton({
url: '/api/wireguard/apply',
successMsg: 'Config applied',
label: 'Apply',
reload: () => load(state),
}),
);
return [
@@ -195,26 +134,12 @@ export default definePage({
subtitle: 'Tunnel: ' + (st.state || 'unknown') + ', Listen: ' + listenPort,
actions,
}),
h('div', null,
StatusDot({ status: isUp ? 'success' : 'danger' }),
' ',
Badge({ text: st.state || 'down', variant: isUp ? 'success' : 'danger' }),
),
ServiceStatus({ state: st.state || 'down' }),
peerRows.length
? h('div', { class: 'card' }, h('table', { class: 'table' },
h('thead', null,
h('tr', null,
h('th', null, 'Peer'),
h('th', null, 'Public Key'),
h('th', null, 'Allowed IPs'),
h('th', null, 'Endpoint'),
h('th', null, 'Handshake'),
h('th', null, 'Transfer'),
h('th', { style: 'width:120px;' }, 'Actions'),
),
),
h('tbody', null, ...peerRows),
))
? Table({
columns: ['Peer', 'Public Key', 'Allowed IPs', 'Endpoint', 'Handshake', 'Transfer', 'Actions'],
rows: peerRows,
})
: Empty({ text: 'No peers configured. Add a peer above.' }),
];
},