Files
vacuum-wall/webui/static/hoover/components/data.js
T
mteehan 75b86fd60d fix: ACME ownership self-heal + daily timer, apply-all force, firewall baseline re-stamp
acme:
- acme.sh chmods its tree to owner-only (700/600) every run, which
  broke the two-user model: a tree left owner-only by one user made
  every acme.sh call of the other exit 2
- normalize_acme_home() reopens group access (sudo chmod g+rwX,
  files only — setgid dirs trip RestrictSUIDSGID); _run_acme_preflight
  is the choke point before every daemon acme.sh call + startup
- acme service now runs as the daemon user; --log persists the raw CA
  transcript; SYS_LOG=6 journals manual issue/renew runs
- timer daily-only: two runs/day landed inside ZeroSSL's 24h
  validation backoff (Retry-After: 86400) — a permanent renewal lockout
- _collect_acme no longer raises on cert-list failure; reports
  status.error (AcmeState.status) so the certs page can surface it

firewall: re-stamp the applied baseline on live zone mutations
(interfaces/services/rich-rules/masquerade/forward-ports) so cancel-all
reverts to post-mutation state, not a stale install-era snapshot;
set_masquerade syncs the declarative config for existing zones;
add_forward_port records toaddr only with toport

status: apply-all accepts {"force": true} (forwarded to the firewall
apply only); ApplyConfirm force checkbox; applyResultToasts() — the
errors map wins over the 200; ActionButton checks errors before the
success toast; dashboard uses ApplyConfirm

system_import: drift re-imports carry the existing apply-meta; first
import stamps the adopted content as applied (it is the running state)
— no phantom pending changes

nginx: get_config only re-saves when migration actually changed the
config (no more owner/mtime churn on every read)

install: repair mis-owned top-level system dirs (tmpfiles
unsafe-path-transition), warn with a full-repair command for deeper
mis-ownership

daemon/server: loop.get_exception_handler() (aiohttp API fix)

tests: 888 pytest + 24 node passing; ruff clean
2026-09-01 02:35:04 +00:00

385 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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) {
// Batch endpoints (e.g. /api/status/apply-all) return 200
// with an `errors` map when some operations failed —
// `resp.ok` alone is not a success signal.
const errs = (resp.data && typeof resp.data.errors === 'object') ? resp.data.errors : null;
const errEntries = errs ? Object.entries(errs) : [];
if (errEntries.length) {
toast('Failed: ' + errEntries.map(([k, v]) => `${k}${v}`).join('; '), 'error', 8000);
} else {
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),
),
);
}