/** * Hoover — components/data.js * * Data display components: Badge, StatusDot, Empty, Card. */ import { h } from '../vdom.js'; import { esc } from '../helpers.js'; import { apiFetch, toast } from '../api.js'; import { requestUpdate } from '../reactivity.js'; const _actionPending = new Map(); const _confirmPending = new Map(); export const _deleting = new Set(); /** * Colored badge/span. * * @param {object} props * @param {string} props.text – Badge text * @param {string} [props.variant] – 'info' | 'success' | 'warning' | 'danger' */ export function Badge(props = {}) { return h('span', { class: `badge badge-${props.variant || 'info'}` }, String(props.text || '')); } /** * Status indicator dot. * * @param {object} props * @param {string} props.status – 'success' | 'up' | 'danger' | 'down' | 'pending' */ export function StatusDot(props = {}) { const v = ['success', 'up'].includes(props.status) ? 'up' : ['danger', 'down'].includes(props.status) ? 'down' : 'pending'; return h('span', { class: `status-dot status-${v}` }); } /** * Empty-state placeholder card. * * @param {object} props * @param {string} [props.text] */ export function Empty(props = {}) { return h('div', { class: 'card' }, h('div', { class: 'text-muted text-sm' }, props.text || 'No data available'), ); } /** * Card wrapper with optional header and body content. * * @param {object} props * @param {string} [props.header] * @param {VNode[]} [props.children] */ export function Card(props = {}) { const key = props.key !== undefined ? { key: props.key } : {}; if (props.header) { 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', ...key }, props.children || []); } /** * A Remove button that confirms, deletes via API, and toasts. State-store * models update from the daemon's WS delta — no explicit refresh. When the * response includes a ``synced`` array (list of subsystem names that were * auto-updated), appends them to the success toast. * * @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 {string|string[]} [props.refresh] - Legacy, ignored (accepted for backward compat) * @param {string} [props.label] - Button text (default: 'Remove') * @param {object} [props.body] - Optional JSON body to send with DELETE * @param {string} [props.deleteKey] - Unique ID for pending-delete row styling * @param {function} [props.onComplete] - Callback after successful deletion */ export function ConfirmDelete(props = {}) { const opts = { method: 'DELETE' }; if (props.body) opts.body = props.body; const deleteKey = props.url + (props.body ? '::' + JSON.stringify(props.body) : ''); const pending = _confirmPending.get(deleteKey) || false; return h('button', { class: 'btn btn-sm btn-danger', disabled: pending, 'on:click': async () => { if (!confirm(props.message)) return; _confirmPending.set(deleteKey, true); requestUpdate(); try { const r = await apiFetch(props.url, opts); if (r.ok) { const synced = r.data?.synced; let msg = props.success || 'Removed'; if (synced && synced.length) { msg += ' (auto-synced: ' + synced.join(', ') + ')'; } toast(msg, 'success'); if (props.deleteKey) { _deleting.add(props.deleteKey); // The WS delta (~50ms) removes the deleted item from // model.data and re-renders the row away. This timeout // purges _deleting if the delta is slow or the row was // already unmounted. setTimeout(() => _deleting.delete(props.deleteKey), 2000); } if (props.onComplete) props.onComplete(); // No modelFetch — WS delta updates state store models. } else { toast(r.error || 'Failed', 'error'); } } finally { _confirmPending.delete(deleteKey); requestUpdate(); } } }, pending ? h('span', { class: 'btn-spinner' }) : (props.label || 'Remove')); } /** * An action button that POSTs to an API endpoint and toasts on result. * Supports toggle labels for on/off buttons. State-store models update from * the daemon's WS delta — no explicit refresh. When the response includes a * ``synced`` array (list of subsystem names that were auto-updated), appends * them to the success toast. * * @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 {string|string[]} [props.refresh] - Legacy, ignored (accepted for backward compat) * @param {function} [props.onSuccess] - Callback after the success toast * @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'; const pending = _actionPending.get(props.url) || false; return h('button', { class: cls, disabled: !!props.disabled || pending, 'on:click': async () => { if (pending) return; _actionPending.set(props.url, true); requestUpdate(); try { 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) { const synced = resp.data?.synced; let msg = props.successMsg || ''; if (synced && synced.length) { if (msg) msg += ' '; msg += '(auto-synced: ' + synced.join(', ') + ')'; } if (msg) toast(msg, 'success'); if (props.onSuccess) props.onSuccess(); // No modelFetch — WS delta updates state store models. } else { toast(resp.error || 'Failed', props.errorType || 'error'); } } finally { _actionPending.delete(props.url); requestUpdate(); } } }, pending ? h('span', { class: 'btn-spinner' }) : 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 {string|string[]} [props.removeRefresh] - Legacy, ignored (accepted for backward compat) * @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') * @param {boolean} [props.busy] - When true the action button is disabled (in-flight operation) * @param {string} [props.busyLabel] - Label shown while busy (default: editLabel + '…') * @param {string} [props.deleteKey] - Unique ID forwarded to ConfirmDelete for pending-delete styling */ export function ActionCell(props = {}) { return h('td', null, h('button', { class: props.editCls || 'btn btn-sm btn-outline', style: 'margin-right:4px;', disabled: !!props.busy, 'on:click': props.busy ? undefined : props.editClick, }, props.busy ? (props.busyLabel || (props.editLabel + '…')) : props.editLabel), ConfirmDelete({ url: props.removeUrl, message: props.removeMessage, success: props.removeSuccess, refresh: props.removeRefresh, label: props.removeLabel || 'Remove', body: props.removeBody, deleteKey: props.deleteKey, }), ); } /** * 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), ), ); }