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
+148 -1
View File
@@ -6,7 +6,7 @@
* ToastContainer component for rendering queued toasts.
*/
import { h } from './vdom.js';
import { h } from './vdom.js?v=6';
/**
* JSON-friendly fetch wrapper.
@@ -96,3 +96,150 @@ export function ToastContainer() {
),
);
}
/**
* Create an abort-checking function from an AbortController.
*
* @param {AbortController} ac
* @returns {function} () => boolean
*/
export function checkAbort(ac) {
return () => ac?.signal?.aborted || false;
}
/**
* Standard data loading wrapper with state management and abort handling.
*
* Sets loading=true before, loading=false after, tracks errors.
*
* @param {object} state - Reactive state object
* @param {function} dataKey - (s) => any, current data to compare for refresh detection
* @param {function} fetchFn - (state, signal, isAborted) => Promise
* @param {object} [opts] - Additional options
* @param {object} [opts.entry] - Component entry for requestId tracking
* @param {AbortController} [opts.abortController] - Fresh abort controller
*/
export async function refactorLoad(state, dataKey, fetchFn, opts = {}) {
const entry = opts.entry;
const myId = entry ? entry.requestId : 0;
const ab = opts.abortController;
const isAborted = ab ? checkAbort(ab) : () => false;
const signal = ab ? ab.signal : null;
if (entry) {
if (dataKey(state) !== undefined) state.refreshing = true;
else state.loading = true;
}
state.error = null;
try {
await fetchFn(state, signal, isAborted);
} catch (e) {
if (!isAborted()) state.error = e.message || 'Request failed';
} finally {
if (!isAborted()) {
if (entry) {
state.loading = false;
state.refreshing = false;
}
}
}
}
/**
* Poll a URL until success or error condition is met.
*
* @param {object} opts
* @param {string} opts.url - URL to poll
* @param {function} opts.successKey - (data) => boolean, when true poll succeeds
* @param {function} opts.onErrorKey - (data) => boolean, when true poll fails
* @param {function} [opts.onComplete] - (data) => void, called on success
* @param {function} [opts.onError] - (data) => void, called on failure
* @param {number} [opts.interval] - Poll interval in ms (default: 3000)
* @param {number} [opts.timeout] - Overall timeout in ms (default: 60000)
*/
export async function poll(opts) {
const {
url,
successKey,
onErrorKey,
onComplete,
onError,
interval = 3000,
timeout = 60000,
} = opts;
const start = Date.now();
const timer = setInterval(async () => {
if (Date.now() - start > timeout) {
clearInterval(timer);
if (onError) onError(null);
return;
}
const res = await apiFetch(url);
if (!res.ok) {
clearInterval(timer);
if (onError) onError(res);
return;
}
if (successKey(res.data)) {
clearInterval(timer);
if (onComplete) onComplete(res.data);
} else if (onErrorKey(res.data)) {
clearInterval(timer);
if (onError) onError(res.data);
}
}, interval);
}
/**
* Generate action button descriptors for modal form submission.
*
* Returns an array of action descriptors that can be spread into the
* actions array passed to formModal. First item is the submit button.
*
* @param {object} opts
* @param {string} opts.url - API URL to POST/PUT to
* @param {string} [opts.method] - HTTP method (default: 'POST')
* @param {function} [opts.body] - () => object, body builder
* @param {function} [opts.validate] - (body) => string|null, validation function
* @param {string} [opts.successMsg] - Success toast message
* @param {function} [opts.reload] - () => Promise, data reload function
* @param {string} [opts.submitText] - Submit button text (default: 'Submit')
* @returns {object[]} Array of action descriptors
*/
export function apiSubmit(opts) {
const {
url,
method = 'POST',
body,
validate,
successMsg = 'Saved',
reload,
submitText = 'Submit',
closeModal,
} = opts;
return [
{
label: submitText,
cls: 'btn-primary',
action: 's',
handler: async () => {
const b = body ? body() : {};
if (validate) {
const err = validate(b);
if (err) { toast(err, 'error'); return; }
}
const res = await apiFetch(url, { method, body: b });
if (res.ok) {
toast(successMsg, 'success');
if (closeModal) closeModal();
if (reload) await reload();
} else {
toast(res.error || 'Failed', 'error');
}
},
},
];
}