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
+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),
),
);
}