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');
}
},
},
];
}
+18 -9
View File
@@ -16,9 +16,9 @@
* });
*/
import { reactive } from './reactivity.js';
import { h } from './vdom.js';
import { _compExpandedCache } from './render.js';
import { reactive } from './reactivity.js?v=6';
import { h } from './vdom.js?v=6';
import { _compExpandedCache } from './render.js?v=6';
/** Registry of mounted components: key → { state, subscriptions, loadAbort, entry, isLoading } */
const _mounted = new Map();
@@ -32,6 +32,15 @@ export function isComponentStateMounted(state) {
return false;
}
/** Get the full mounted entry for a state object.
* Used by websocket.js to abort in-flight loads before triggering a refresh. */
export function getComponentEntry(state) {
for (const entry of _mounted.values()) {
if (entry.state === state) return entry;
}
return null;
}
/** External subscribe function from websocket.js.
* Set via setSubscribeFn() when the websocket module initializes.
*/
@@ -79,12 +88,11 @@ export function mountComponent(key, renderer) {
let entry = _mounted.get(key);
if (entry) {
// Re-mount of an already-mounted page: restart load with fresh AbortController
if (entry.loadAbort) {
entry.loadAbort.abort();
}
entry.requestId++;
entry.loadAbort = null;
// Re-mount: component already exists with its data and subscriptions.
// Don't abort or restart loads — that re-render was triggered by a
// state change (load completion, reactive update, etc). Let existing
// in-flight loads complete naturally. WS handles auto-refresh.
return;
} else {
// Fresh mount
entry = {
@@ -101,6 +109,7 @@ export function mountComponent(key, renderer) {
// Fire load with fresh AbortController
if (pd.load) {
if (entry.isLoading) return;
const abortController = new AbortController();
entry.loadAbort = abortController;
entry.requestId++;
+253 -3
View File
@@ -4,7 +4,9 @@
* Data display components: Badge, StatusDot, Empty, Card.
*/
import { h } from '../vdom.js';
import { h } from '../vdom.js?v=6';
import { esc } from '../helpers.js?v=6';
import { apiFetch, toast } from '../api.js?v=6';
/**
* Colored badge/span.
@@ -49,11 +51,259 @@ export function Empty(props = {}) {
* @param {VNode[]} [props.children]
*/
export function Card(props = {}) {
const key = props.key !== undefined ? { key: props.key } : {};
if (props.header) {
return h('div', { class: 'card' },
return h('div', { class: 'card', ...key },
h('div', { class: 'card-header' }, props.header),
h('div', { class: 'card-body' }, props.children || []),
);
}
return h('div', { class: 'card' }, props.children || []);
return h('div', { class: 'card', ...key }, props.children || []);
}
/**
* A Remove button that confirms, deletes via API, toasts, and reloads.
*
* @param {object} props
* @param {string} props.url - API DELETE URL
* @param {string} props.message - Confirmation prompt text
* @param {string} [props.success] - Success toast message (default: 'Removed')
* @param {function} [props.reload] - Function to call on success (e.g., load)
* @param {string} [props.label] - Button text (default: 'Remove')
* @param {object} [props.body] - Optional JSON body to send with DELETE
*/
export function ConfirmDelete(props = {}) {
const opts = { method: 'DELETE' };
if (props.body) opts.body = props.body;
return h('button', { class: 'btn btn-sm btn-danger',
'on:click': async () => {
if (!confirm(props.message)) return;
const r = await apiFetch(props.url, opts);
if (r.ok) {
toast(props.success || 'Removed', 'success');
if (props.reload) await props.reload();
} else {
toast(r.error || 'Failed', 'error');
}
}}, props.label || 'Remove');
}
/**
* An action button that POSTs to an API endpoint, toasts on result,
* and optionally reloads state. Supports toggle labels for on/off buttons.
*
* @param {object} props
* @param {string} props.url - API URL
* @param {string} [props.method] - HTTP method (default: 'POST')
* @param {function} [props.body] - () => body, or undefined for no body
* @param {string} [props.label] - Button text
* @param {string} [props.labelOn] - Label when condition is true (toggle)
* @param {string} [props.labelOff] - Label when condition is false (toggle)
* @param {boolean} [props.condition] - Toggle condition for labelOn/labelOff
* @param {string} [props.successMsg] - Success toast message
* @param {string} [props.errorType] - Toast type for errors (default: 'error')
* @param {function} [props.reload] - () => Promise, called on success
* @param {string} [props.cls] - Button CSS classes (default: 'btn btn-outline')
* @param {boolean} [props.disabled] - Disabled state
*/
export function ActionButton(props = {}) {
const label = props.label !== undefined ? props.label :
(props.labelOn !== undefined && props.labelOff !== undefined
? (props.condition ? props.labelOn : props.labelOff)
: 'Action');
const cls = props.cls || 'btn btn-outline';
return h('button', {
class: cls,
disabled: props.disabled,
'on:click': async () => {
const body = props.body ? props.body() : undefined;
const opts = { method: props.method || 'POST' };
if (body !== undefined) opts.body = body;
const resp = await apiFetch(props.url, opts);
if (resp.ok) {
if (props.successMsg) toast(props.successMsg, 'success');
if (props.reload) await props.reload();
} else {
toast(resp.error || 'Failed', props.errorType || 'error');
}
}
}, label);
}
/**
* Table wrapper with header, body, and empty-state row.
*
* @param {object} props
* @param {string[]} props.columns - Column header labels
* @param {VNode[]} props.rows - Body row vnodes
* @param {string} [props.emptyText] - Empty-state message
* @param {boolean} [props.wrapCard] - Wrap in div.card (default: true)
* @param {string} [props.key] - VNode key
*/
export function Table(props = {}) {
const cols = props.columns || [];
const ths = cols.map(c => h('th', null, c));
const table = h('table', { class: 'table' },
h('thead', null, h('tr', null, ...ths)),
h('tbody', null,
props.rows.length ? props.rows : [
h('tr', null,
h('td', { colspan: cols.length, class: 'text-muted text-sm' },
props.emptyText || 'No data'),
),
],
),
);
const key = props.key !== undefined ? { key: props.key } : {};
if (props.wrapCard !== false) {
return h('div', { class: 'card', ...key }, table);
}
return h('div', key, table);
}
/**
* Dashboard stat card.
*
* @param {object} props
* @param {string} props.label
* @param {*} props.value
* @param {*} [props.meta]
*/
export function StatCard(props = {}) {
return h('div', { class: 'stat-card' },
h('div', { class: 'label' }, props.label),
h('div', { class: 'value' }, props.value),
props.meta ? h('div', { class: 'meta' }, props.meta) : null,
);
}
/**
* StatusDot + human-readable label.
*
* @param {object} props
* @param {string} props.status
*/
export function StatusText(props = {}) {
const status = props.status || 'down';
const label = status === 'up' ? 'Up' : status === 'pending' ? 'Pending' : 'Down';
return [StatusDot({ status }), ' ', label];
}
/**
* Badge for certificate status based on expiry data.
*
* @param {object} props
* @param {number} [props.daysRemaining] - Days until expiry
* @param {boolean} [props.expired] - Explicitly expired flag
* @param {string} [props.certStatus] - Status string (e.g. 'valid', 'active', 'expired')
*/
export function certStatusBadge(props = {}) {
const { daysRemaining, expired, certStatus } = props;
if (certStatus === 'valid' || certStatus === 'active')
return Badge({ text: 'Valid', variant: 'success' });
if (expired || certStatus === 'expired' || (daysRemaining !== undefined && daysRemaining <= 0))
return Badge({ text: 'Expired', variant: 'danger' });
if (daysRemaining !== undefined && daysRemaining <= 30)
return Badge({ text: daysRemaining + 'd left', variant: 'warning' });
if (daysRemaining !== undefined)
return Badge({ text: daysRemaining + 'd left', variant: 'success' });
return Badge({ text: certStatus || 'N/A', variant: 'info' });
}
/**
* StatusDot + Badge pair for a service state string.
*
* @param {object} props
* @param {string} props.state - Service state (e.g. 'up', 'down')
*/
export function serviceStatusBadge(props = {}) {
const state = props.state || 'down';
const isUp = state === 'up';
return [
StatusDot({ status: isUp ? 'success' : 'danger' }),
' ',
Badge({ text: state, variant: isUp ? 'success' : 'danger' }),
];
}
/**
* ServiceStatusBadge + label in a single vnode.
*
* @param {object} props
* @param {string} props.state - Service state string
* @param {string} [props.label] - Optional label text after the badge
*/
export function ServiceStatus(props = {}) {
return h('span', { class: 'service-status' },
...serviceStatusBadge({ state: props.state }),
props.label ? ' ' + props.label : null,
);
}
/**
* ActionCell — standardizes "action button + ConfirmDelete" in a table cell.
*
* @param {object} props
* @param {string} props.editLabel - First button text
* @param {function} props.editClick - First button click handler
* @param {string} props.removeUrl - API DELETE URL
* @param {string} props.removeMessage - Confirmation prompt text
* @param {string} [props.removeSuccess] - Success toast message
* @param {function} [props.removeReload] - Reload function
* @param {string} [props.removeLabel] - Delete button label (default: 'Remove')
* @param {object} [props.removeBody] - Optional JSON body to send with DELETE
* @param {string} [props.editCls] - Override classes for edit button (default: 'btn btn-sm btn-outline')
*/
export function ActionCell(props = {}) {
return h('td', null,
h('button', {
class: props.editCls || 'btn btn-sm btn-outline',
style: 'margin-right:4px;',
'on:click': props.editClick,
}, props.editLabel),
ConfirmDelete({
url: props.removeUrl,
message: props.removeMessage,
success: props.removeSuccess,
reload: props.removeReload,
label: props.removeLabel || 'Remove',
body: props.removeBody,
}),
);
}
/**
* Monospace text with optional truncation.
*
* @param {object} props
* @param {string} props.text
* @param {number} [props.maxLength] - Truncate with "..." if longer
*/
export function MonoText(props = {}) {
const text = String(props.text || '');
const display = props.maxLength && text.length > props.maxLength
? text.substring(0, props.maxLength) + '...'
: text;
return h('span', { class: 'mono-text' }, esc(display));
}
/**
* Dropdown to select a firewall zone.
*
* @param {object} props
* @param {string[]} props.zones - Available zone names
* @param {string} [props.value] - Currently selected zone
* @param {function} [props.onChange] - (zone) => void
* @param {string} [props.placeholder]
*/
export function ZoneSelect(props = {}) {
return h('select', {
class: 'form-select',
'on:change': (e) => props.onChange?.(e.target.value),
},
props.placeholder ? h('option', { value: '' }, props.placeholder) : null,
props.zones.map(z =>
h('option', { value: z, selected: z === props.value }, z),
),
);
}
+111 -3
View File
@@ -1,11 +1,11 @@
/**
* Hoover — components/layout.js
*
* Layout components: PageHeader for page titles with optional subtitles
* and action buttons.
* Layout components: PageHeader, Tabs, SectionTitle, DataTableSection, ActionGroup.
*/
import { h } from '../vdom.js';
import { h } from '../vdom.js?v=6';
import { Table } from './data.js?v=6';
/**
* Page header with title, optional subtitle, and action buttons.
@@ -24,3 +24,111 @@ export function PageHeader(props = {}) {
props.actions ? h('div', { class: 'page-actions' }, props.actions) : null,
);
}
/**
* Handle loading/error/no-data states and return early if applicable.
* Returns null when data is ready for the page to render its content.
*
* @param {object} state - Page state with loading/error flags
* @param {string} title - Page header title
* @param {string} [subtitle] - Page header subtitle
* @param {*} [data] - Data presence check for "no data" state
* @returns {VNode[]|null}
*/
export function renderGuard(state, title, subtitle, data) {
if (state.loading && !state.refreshing) {
return [
PageHeader({ title, subtitle }),
h('div', { class: 'card', key: 'loading' },
h('div', { class: 'card-body loading' },
state.refreshing ? 'Refreshing...' : 'Loading...',
),
),
];
}
if (state.error) {
return [
PageHeader({ title, subtitle }),
h('div', { class: 'card', key: 'error' },
h('div', { class: 'card-body error-msg' }, state.error),
),
];
}
if ((data === undefined || data === null) && !state.loading) {
return [
PageHeader({ title, subtitle }),
h('div', { class: 'card', key: 'no-data' },
h('div', { class: 'card-body loading' }, 'No data available'),
),
];
}
return null;
}
/**
* Tab bar component. Writes to state[prop] on tab click.
* The caller is responsible for rendering tab body content.
*
* @param {object} props
* @param {object} props.state - Reactive state object
* @param {string[]} props.tabs - Array of tab keys (e.g. ['ranges', 'leases'])
* @param {string} [props.prop] - State property name for active tab (default: 'activeTab')
* @param {function} [props.formatLabel] - (key) => label string (default: capitalize)
* @param {function} [props.onTabClick] - (key) => void, called after state update (for async side effects)
*/
export function Tabs(props = {}) {
const tabKeys = props.tabs || [];
const prop = props.prop || 'activeTab';
const formatLabel = props.formatLabel || ((k) => k.charAt(0).toUpperCase() + k.slice(1));
return h('div', { class: 'tabs' },
tabKeys.map(t => h('span', {
class: 'tab ' + (props.state[prop] === t ? 'active' : ''),
'on:click': () => {
props.state[prop] = t;
if (props.onTabClick) props.onTabClick(t);
},
style: 'cursor:pointer;',
}, formatLabel(t))),
);
}
/**
* Section header.
*
* @param {object} props
* @param {string} props.title
*/
export function SectionTitle(props = {}) {
return h('h3', { class: 'section-title' }, props.title);
}
/**
* Flex button container with 8px gap.
*
* @param {VNode[]} children
*/
export function ActionGroup(...children) {
return h('div', { style: 'display:flex;gap:8px;' }, ...children);
}
/**
* DataTableSection — SectionTitle heading followed by a Table.
*
* @param {object} props
* @param {string} props.title - Section heading
* @param {string[]} props.columns
* @param {VNode[]} props.rows
* @param {string} [props.emptyText]
* @param {string} [props.key]
*/
export function DataTableSection(props = {}) {
const key = props.key !== undefined ? { key: props.key } : {};
return h('div', { class: 'data-table-section', ...key },
SectionTitle({ title: props.title }),
Table({
columns: props.columns,
rows: props.rows,
emptyText: props.emptyText,
}),
);
}
+105 -3
View File
@@ -6,8 +6,9 @@
* avoid fighting with the main render cycle.
*/
import { esc } from '../helpers.js';
import { att_esc } from '../helpers.js';
import { esc } from '../helpers.js?v=6';
import { att_esc } from '../helpers.js?v=6';
import { apiSubmit } from '../api.js?v=6';
const _modalQueue = [];
@@ -78,7 +79,8 @@ export function formModal(inner, title, fields, actions) {
inner.innerHTML = '<h2 class="modal-title">' + esc(title) + '</h2><div class="modal-body">'
+ fields.map(f => {
if (f.tag === 'select')
return '<div class="form-group"><label>' + esc(f.label) + '</label><select id="' + att_esc(f.id) + '">'
return '<div class="form-group"><label>' + esc(f.label) + '</label><select id="' + att_esc(f.id) + '"'
+ (f.multiple ? ' multiple' : '') + '>'
+ (f.options || []).map(o =>
typeof o === 'string'
? '<option value="' + att_esc(o) + '">' + esc(o) + '</option>'
@@ -101,3 +103,103 @@ export function formModal(inner, title, fields, actions) {
if (btn) btn.addEventListener('click', a.handler);
});
}
/**
* Factory that returns a function to open a multi-select modal.
*
* @param {object} props
* @param {string} props.title - Modal title
* @param {string} props.url - API POST URL
* @param {string[]} props.options - All selectable options
* @param {string[]} props.selected - Currently selected values
* @param {string} props.fieldKey - JSON key for the field
* @param {string} [props.successMsg] - Success toast message
* @param {function} [props.reload] - () => Promise, called on success
* @returns {function} () => void, calls openModal
*/
export function MultiSelectModal(props = {}) {
return () => {
const selectId = 'ms-' + props.fieldKey;
openModal((inner) => {
formModal(inner, props.title,
[{
label: props.fieldKey,
id: selectId,
tag: 'select',
multiple: true,
options: (props.options || []).map(o => [o, (props.selected || []).includes(o)]),
}],
[
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() },
...apiSubmit({
url: props.url,
body: () => ({
[props.fieldKey]: Array.from(document.getElementById(selectId).selectedOptions)
.map(o => o.value),
}),
successMsg: props.successMsg || 'Updated',
reload: props.reload,
closeModal: () => closeModal(),
}),
],
);
});
};
}
/**
* Factory that returns a function to open a modal with form fields and apiSubmit.
* Accepts an optional `data` argument forwarded to title, fields, submit.url, submit.body resolvers.
*
* @param {object} props
* @param {string|function} props.title - Modal title or (data) => string
* @param {object[]|function} props.fields - Form field descriptors or (data) => object[]
* @param {object} props.submit - Submit configuration
* @param {string|function} props.submit.url - API URL or (data) => string
* @param {string} [props.submit.method] - HTTP method (default: 'POST')
* @param {function} [props.submit.body] - (data) => object
* @param {function} [props.submit.validate] - (body) => string|null
* @param {string|function} [props.submit.successMsg] - Toast message or (data) => string
* @param {function} [props.reload] - (data) => Promise, called on success with the data argument
* @param {function} [props.handler] - Custom submit handler override (bypasses apiSubmit)
* @param {string} [props.submitLabel] - Submit button label (default: 'Submit')
* @returns {function} (data) => void, calls openModal
*/
export function QuickModal(props = {}) {
return (data) => {
const title = typeof props.title === 'function' ? props.title(data) : props.title;
const fields = typeof props.fields === 'function' ? props.fields(data) : props.fields;
const url = typeof props.submit.url === 'function' ? props.submit.url(data) : props.submit.url;
openModal((inner) => {
let actions;
if (props.handler) {
actions = [
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() },
{
label: props.submitLabel || 'Submit',
cls: 'btn-primary',
action: 's',
handler: () => props.handler(data, () => closeModal()),
},
];
} else {
actions = [
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() },
...apiSubmit({
url,
method: props.submit.method || 'POST',
body: props.submit.body ? () => props.submit.body(data) : undefined,
validate: props.submit.validate,
successMsg: typeof props.submit.successMsg === 'function'
? props.submit.successMsg(data)
: (props.submit.successMsg || 'Done'),
reload: props.reload ? () => props.reload(data) : undefined,
closeModal: () => closeModal(),
}),
];
}
formModal(inner, title, fields, actions);
});
};
}
+2 -2
View File
@@ -5,8 +5,8 @@
* Uses the toast/dismissToast state from api.js.
*/
import { h } from '../vdom.js';
import { _toasts, dismissToast } from '../api.js';
import { h } from '../vdom.js?v=6';
import { _toasts, dismissToast } from '../api.js?v=6';
/**
* Render all pending toast notifications.
+17
View File
@@ -49,3 +49,20 @@ export function parseZones(data) {
z = Object.values(z).map(i => i?.name || i);
return Array.isArray(z) ? z : [];
}
/**
* Trigger a browser file download from a Blob.
*
* @param {Blob} blob
* @param {string} filename
*/
export function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
+12 -12
View File
@@ -5,37 +5,37 @@
*/
/* ── Reactivity ──────────────────────────────────────────────── */
export { reactive, requestUpdate } from './reactivity.js';
export { reactive, requestUpdate } from './reactivity.js?v=6';
/* ── VDOM ────────────────────────────────────────────────────── */
export { h } from './vdom.js';
export { h } from './vdom.js?v=6';
/* ── Render ──────────────────────────────────────────────────── */
export { render } from './render.js';
export { render } from './render.js?v=6';
/* ── Component ───────────────────────────────────────────────── */
export { definePage, hComp } from './component.js';
export { definePage, hComp } from './component.js?v=6';
/* ── Router ──────────────────────────────────────────────────── */
export { createRouter, Link } from './router.js';
export { createRouter, Link } from './router.js?v=6';
/* ── WebSocket ───────────────────────────────────────────────── */
export { connect, onMessage } from './websocket.js';
export { connect, onMessage } from './websocket.js?v=6';
/* ── API & Toast ─────────────────────────────────────────────── */
export { apiFetch, toast, dismissToast } from './api.js';
export { apiFetch, toast, dismissToast, apiSubmit, refactorLoad, checkAbort, poll } from './api.js?v=6';
/* ── Helpers ─────────────────────────────────────────────────── */
export { esc, att_esc, enc, $val, parseZones } from './helpers.js';
export { esc, att_esc, enc, $val, parseZones, downloadBlob } from './helpers.js?v=6';
/* ── UI Components: Layout ───────────────────────────────────── */
export { PageHeader } from './components/layout.js';
export { PageHeader, renderGuard, Tabs, SectionTitle, ActionGroup, DataTableSection } from './components/layout.js?v=6';
/* ── UI Components: Data ─────────────────────────────────────── */
export { Badge, StatusDot, Empty, Card } from './components/data.js';
export { Badge, StatusDot, Empty, Card, ConfirmDelete, Table, ActionButton, certStatusBadge, serviceStatusBadge, StatCard, StatusText, ServiceStatus, ActionCell, MonoText, ZoneSelect } from './components/data.js?v=6';
/* ── UI Components: Modal ────────────────────────────────────── */
export { openModal, closeModal, closeAllModals, formModal } from './components/modal.js';
export { openModal, closeModal, closeAllModals, formModal, MultiSelectModal, QuickModal } from './components/modal.js?v=6';
/* ── UI Components: Toast ────────────────────────────────────── */
export { ToastContainer } from './components/toast.js';
export { ToastContainer } from './components/toast.js?v=6';
+36 -12
View File
@@ -5,12 +5,12 @@
* batched re-render loop integration with reactivity.js.
*/
import { requestUpdate, setCommitFn } from './reactivity.js';
import { requestUpdate, setCommitFn } from './reactivity.js?v=6';
import {
_vnodeDom, createDom, getDom, patchNode,
_vnodeDom, createDom, getDom, patchNode, sweepDom,
setMountFn, setUnmountFn,
} from './vdom.js';
import { mountComponent, unmountComponent } from './component.js';
} from './vdom.js?v=6';
import { mountComponent, unmountComponent } from './component.js?v=6';
/** Container → previous root vnodes */
export const _renderSlots = new Map();
@@ -21,6 +21,9 @@ export const _renderFns = new Map();
/** Component key → last normalized #comp output (for _vnodeDom preservation) */
export const _compExpandedCache = new Map();
/** Component key → renderer function (survives normalization that expands #comp) */
const _compRegistry = new Map();
/**
* Set up lifecycle callback hooks from vdom.js.
* Called once during render initialization.
@@ -87,7 +90,7 @@ function commit(container) {
* and manage component lifecycle based on key changes.
*/
function normalizeVNodesWithLifecycle(result, prevVnodes) {
const oldEntries = prevVnodes ? collectCompEntries(prevVnodes, []) : [];
const oldEntries = [..._compRegistry.entries()].map(([key, renderer]) => ({ key, renderer }));
const oldKeyMap = new Map(oldEntries.map(e => [e.key, e]));
const newEntries = [];
@@ -104,6 +107,16 @@ function normalizeVNodesWithLifecycle(result, prevVnodes) {
}
}
// Sync registry with current render (prevVnodes are normalized and lack #comp tags,
// so collectCompEntries always returns [] after the first render)
const newKeySet = new Set(newEntries.map(e => e.key));
for (const [key] of _compRegistry) {
if (!newKeySet.has(key)) _compRegistry.delete(key);
}
for (const entry of newEntries) {
_compRegistry.set(entry.key, entry.renderer);
}
return normalized;
}
@@ -199,6 +212,7 @@ function diffContainer(container, prev, vnodes) {
if (d?.parentNode) {
if (d.nodeType === Node.ELEMENT_NODE) sweepDom(d);
d.parentNode.removeChild(d);
lastDom = i > 0 ? getDom(prev[i - 1]) : null;
}
continue;
}
@@ -214,15 +228,25 @@ function diffContainer(container, prev, vnodes) {
if (oldDom && oldV.tag === newV.tag) {
patchNode(oldDom.nodeType === 1 ? oldDom.parentNode : container, oldV, newV, null);
lastDom = getDom(newV);
} else {
if (oldDom?.nodeType === Node.ELEMENT_NODE) sweepDom(oldDom);
} else if (oldDom && !oldDom.parentNode) {
// oldDom exists in _vnodeDom but detached from the tree
if (oldDom.nodeType === Node.ELEMENT_NODE) sweepDom(oldDom);
const nd = createDom(newV);
_vnodeDom.set(newV, nd);
if (oldDom?.parentNode) {
oldDom.parentNode.replaceChild(nd, oldDom);
} else if (nd.parentNode !== container) {
container.insertBefore(nd, lastDom ? lastDom.nextSibling : null);
}
container.insertBefore(nd, lastDom ? lastDom.nextSibling : null);
lastDom = nd;
} else if (oldDom && oldV.tag !== newV.tag) {
// tag mismatch — replace old DOM with new
if (oldDom.nodeType === Node.ELEMENT_NODE) sweepDom(oldDom);
const nd = createDom(newV);
_vnodeDom.set(newV, nd);
oldDom.parentNode.replaceChild(nd, oldDom);
lastDom = nd;
} else {
// oldDom is null — create and insert new DOM
const nd = createDom(newV);
_vnodeDom.set(newV, nd);
container.insertBefore(nd, lastDom ? lastDom.nextSibling : null);
lastDom = nd;
}
}
+2 -2
View File
@@ -5,8 +5,8 @@
* navigation). Link component for client-side navigation.
*/
import { reactive } from './reactivity.js';
import { h } from './vdom.js';
import { reactive } from './reactivity.js?v=6';
import { h } from './vdom.js?v=6';
/**
* Hash-based router.
+1
View File
@@ -198,6 +198,7 @@ export function patchUnkeyed(parent, oldCh, newCh) {
if (d?.parentNode) {
if (d.nodeType === Node.ELEMENT_NODE) sweepDom(d);
d.parentNode.removeChild(d);
lastDom = i > 0 ? getDom(oldCh[i - 1]) : null;
}
continue;
}
+77 -9
View File
@@ -8,7 +8,7 @@
* auto-refresh messages from the backend can trigger page reloads.
*/
import { setSubscribeFn, isComponentStateMounted } from './component.js';
import { setSubscribeFn, isComponentStateMounted, getComponentEntry } from './component.js?v=6';
const _wsSubs = new Map();
let _wsConn = null;
@@ -52,6 +52,43 @@ function _wsConnect() {
};
}
/** Per-state debounce timer (shared across all subscriptions for that state). */
const _wsDebounceTimers = new Map();
/**
* Fire the debounced load for a component state.
*
* Only one load fires per state regardless of how many subscriptions
* matched. Passes the mount entry so refactorLoad can toggle
* loading / refreshing flags correctly.
*/
function debouncedLoad(state, entry) {
if (!isComponentStateMounted(state)) return;
// Abort any in-flight load for this component
if (entry && entry.loadAbort) entry.loadAbort.abort();
const ac = new AbortController();
const firstSub = [..._wsSubs.values()]
.find(s => !s.unsubscribed && s.state === state);
if (firstSub) {
firstSub.loadFn(state, ac, entry);
}
}
/**
* Debounce helper: coalesces all matching subscriptions for the same
* component state into a single reload, keyed by state object.
*/
function scheduleReload(state) {
if (_wsDebounceTimers.has(state)) {
clearTimeout(_wsDebounceTimers.get(state));
}
_wsDebounceTimers.set(state, setTimeout(() => {
_wsDebounceTimers.delete(state);
const entry = getComponentEntry(state);
debouncedLoad(state, entry);
}, 300));
}
/**
* Route an incoming WS message to subscribed components.
*
@@ -61,6 +98,11 @@ function _wsConnect() {
* { type: 'status', topic: 'firewall', … }
*
* Components subscribed to wildcard ('*') match every topic.
*
* Uses per-component-state debouncing (300ms) to prevent a burst of WS
* messages or multiple matching topics from triggering overlapping
* loads. All subscriptions that share the same state object are
* coalesced into a single debounced reload.
*/
function handleMessage(msg) {
const topics = [];
@@ -73,13 +115,20 @@ function handleMessage(msg) {
topics.push(msg.topic || '*');
}
// Track which states have already been scheduled to avoid
// double-scheduling when multiple subscriptions of the same
// component match the same message.
const scheduled = new Set();
for (const s of _wsSubs.values()) {
if (s.unsubscribed || !isComponentStateMounted(s.state)) continue;
if (s.topic === '*') {
s.loadFn(s.state);
} else if (topics.some(t => t === s.topic || t === '*')) {
s.loadFn(s.state);
}
const matched = s.topic === '*' || topics.some(t => t === s.topic || t === '*');
if (!matched) continue;
if (scheduled.has(s.state)) continue;
scheduled.add(s.state);
scheduleReload(s.state);
}
}
@@ -89,6 +138,9 @@ function handleMessage(msg) {
* Called by component.js on mount. Returns an unsubscribe function
* called by component.js on unmount.
*
* Key is `componentFn + ':' + topic` so a component can subscribe to
* multiple topics without overwriting previous subscriptions.
*
* @param {function} componentFn The page renderer function (used as map key)
* @param {string} topic Topic to listen for ('*' = all)
* @param {function} loadFn Function to call when topic updates
@@ -96,12 +148,21 @@ function handleMessage(msg) {
* @returns {function} unsubscribe
*/
function subscribe(componentFn, topic, loadFn, state) {
const key = componentFn + ':' + topic;
const entry = { componentFn, topic, loadFn, state, unsubscribed: false };
_wsSubs.set(componentFn, entry);
_wsSubs.set(key, entry);
return () => {
entry.unsubscribed = true;
_wsSubs.delete(componentFn);
// Clear per-state debounce timer if this was the last active
// subscription for that state
const remaining = [..._wsSubs.values()]
.some(s => !s.unsubscribed && s.state === entry.state);
if (!remaining && _wsDebounceTimers.has(entry.state)) {
clearTimeout(_wsDebounceTimers.get(entry.state));
_wsDebounceTimers.delete(entry.state);
}
_wsSubs.delete(key);
};
}
@@ -128,7 +189,14 @@ export function onMessage(topics, handler) {
unsubscribed: false
};
_wsSubs.set(handler + ':' + t, entry);
fns.push(() => { entry.unsubscribed = true; _wsSubs.delete(handler + ':' + t); });
fns.push(() => {
entry.unsubscribed = true;
if (_wsDebounceTimers.has(entry.state)) {
clearTimeout(_wsDebounceTimers.get(entry.state));
_wsDebounceTimers.delete(entry.state);
}
_wsSubs.delete(handler + ':' + t);
});
}
return () => fns.forEach(f => f());
}