94705490b9
- POST /acme/renew returns a request_id and spawns a background task (renew/deploy/refresh steps); dedups per-domain like issue - completes as "skipped" when acme.sh reports the renewal window has not been reached (no --force) - new GET /acme/renew/status endpoint (iface + handler + blueprint) - run acme.sh subprocesses off the event loop (asyncio.to_thread) in both issuance and renewal - ActionCell: busy/busyLabel props; certs page disables the Renew button and polls renewal status with toasts for success/skip/fail
376 lines
14 KiB
JavaScript
376 lines
14 KiB
JavaScript
/**
|
||
* 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),
|
||
),
|
||
);
|
||
}
|