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:
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user