From d78b90db00101a3d7add5d9470a53cb37cb43155 Mon Sep 17 00:00:00 2001 From: Mike Teehan Date: Thu, 3 Sep 2026 03:29:50 +0000 Subject: [PATCH] ui: toast per-type durations, Details modal for long errors, concise acme.sh failure summary --- docs/hoover.md | 10 +++++- lib/acme.py | 32 ++++++++++++++--- webui/static/hoover/api.js | 42 +++++++++++++++++++--- webui/static/hoover/components/toast.js | 47 +++++++++++++++++++++---- webui/static/style.css | 31 ++++++++++++++++ 5 files changed, 146 insertions(+), 16 deletions(-) diff --git a/docs/hoover.md b/docs/hoover.md index d4cda93..3a59e21 100644 --- a/docs/hoover.md +++ b/docs/hoover.md @@ -677,7 +677,15 @@ const res = await apiFetch('/api/firewall/zones', { method: 'GET' }); ### `toast(message, type, duration)` -Show a toast notification. Auto-dismisses after `duration` ms (default 4000). `type` is one of `'info'`, `'success'`, `'error'`, `'warning'`. Returns a toast ID. +Show a toast notification. `type` is one of `'info'`, `'success'`, `'error'`, `'warning'`. Returns a toast ID. + +When `duration` is omitted, per-type defaults apply: `'info'` and `'success'` auto-dismiss after 4000 ms, `'warning'` after 8000 ms, and `'error'` toasts **never** auto-dismiss (they stay until dismissed so long failure messages remain readable). Pass an explicit `duration` (ms, `0` = indefinite) to override the default. + +Toast behavior: + +- Dismissal is only via the `×` button (or `dismissToast(id)`); clicking the toast body does not dismiss it. +- The auto-dismiss timer pauses while the pointer is over the toast. +- Long messages (>200 chars or containing newlines) render compact — first line, ellipsized — with a **Details** button that opens a modal showing the full text in a scrollable mono block. ### `dismissToast(id)` diff --git a/lib/acme.py b/lib/acme.py index 752d459..41b3d50 100644 --- a/lib/acme.py +++ b/lib/acme.py @@ -98,11 +98,15 @@ def _run_acme(args: list[str]) -> str: *args, # Append the full transcript to $ACME_HOME/acme.sh.log so manual # runs (whose stdout is captured below) leave a persistent record - # of the raw CA exchange. Last on purpose: acme.sh treats the next - # token after --log as its optional file argument, so a trailing - # --log defaults the log to $LE_CONFIG_HOME/acme.sh.log and can - # never swallow a real argument. + # of the raw CA exchange. The log file is passed explicitly (never + # as a bare trailing --log): a valueless trailing --log makes + # acme.sh's arg loop double-shift under dash (the --log branch + # shifts once, then the loop's trailing `shift 1` runs with zero + # positional params) and fails with "shift: can't shift that many" + # (exit 2). The explicit path keeps the same default destination + # ($LE_CONFIG_HOME/acme.sh.log) and can never swallow a real arg. "--log", + str(Path(acme_home_env) / "acme.sh.log"), ] try: @@ -125,12 +129,30 @@ def _run_acme(args: list[str]) -> str: if result.returncode != 0: logger.error("acme.sh failed (rc=%d): %s", result.returncode, output.strip()) raise RuntimeError( - f"acme.sh failed with exit code {result.returncode}: {output.strip()}" + f"acme.sh failed with exit code {result.returncode}: " + f"{_summarize_acme_output(output)}" ) return output +def _summarize_acme_output(output: str) -> str: + """Reduce raw acme.sh output to a concise, human-readable summary. + + acme.sh prints timestamped transcript lines; the failure reason is + in the final lines (e.g. "The retryafter=86400 value is too large + (> 600), will not retry anymore."). Strips per-line timestamps and + the "Please check log file" pointer so the summary stays toast- + sized. The full transcript remains in the log and acme.sh.log. + """ + lines = [line.strip() for line in output.strip().splitlines() if line.strip()] + lines = [re.sub(r"^\[[^\]]*\] ", "", line) for line in lines] + lines = [line for line in lines if not line.startswith("Please check log file")] + if not lines: + return "(no output)" + return "; ".join(lines[-2:]) + + def set_email(email: str) -> None: """Configure the default ACME contact email. diff --git a/webui/static/hoover/api.js b/webui/static/hoover/api.js index 5f23cc3..ab438d7 100644 --- a/webui/static/hoover/api.js +++ b/webui/static/hoover/api.js @@ -107,19 +107,53 @@ export const _toasts = []; const _toastIds = { next: 1 }; /** - * Show a toast notification. Auto-dismisses after `duration` ms. + * Default auto-dismiss durations per toast type (ms). 0 = never + * auto-dismiss. Errors stay on screen until dismissed so long + * failure messages remain readable. + */ +const _TOAST_DEFAULT_DURATIONS = { + info: 4000, + success: 4000, + warning: 8000, + error: 0, +}; + +/** + * Auto-dismiss timer that pauses while the toast is hovered. + * Re-checks in 1s while hovered instead of dismissing. + */ +function _scheduleToastDismiss(id, delay) { + setTimeout(() => { + const t = _toasts.find(t => t.id === id); + if (!t) return; + if (t.hovered) _scheduleToastDismiss(id, 1000); + else dismissToast(id); + }, delay); +} + +/** + * Show a toast notification. + * + * When `duration` is omitted, per-type defaults apply: 'info' and + * 'success' auto-dismiss after 4000 ms, 'warning' after 8000 ms, and + * 'error' toasts never auto-dismiss. An explicit `duration` overrides + * the default. The auto-dismiss timer pauses while the toast is + * hovered. * * @param {string} message – Toast text * @param {string} [type] – 'info' | 'success' | 'error' | 'warning' * @param {number} [duration] – Auto-dismiss timeout in ms (0 = indefinite) * @returns {number} id */ -export function toast(message, type = 'info', duration = 4000) { +export function toast(message, type = 'info', duration) { const id = _toastIds.next++; - _toasts.push({ id, message, type, createdAt: Date.now(), duration }); + const dur = duration === undefined + ? (_TOAST_DEFAULT_DURATIONS[type] ?? 4000) + : duration; + _toasts.push({ id, message, type, createdAt: Date.now(), duration: dur, hovered: false }); requestUpdate(); - if (duration > 0) setTimeout(() => dismissToast(id), duration); + if (dur > 0) _scheduleToastDismiss(id, dur); return id; } diff --git a/webui/static/hoover/components/toast.js b/webui/static/hoover/components/toast.js index a251094..7443adc 100644 --- a/webui/static/hoover/components/toast.js +++ b/webui/static/hoover/components/toast.js @@ -3,10 +3,38 @@ * * ToastContainer component that renders queued toast notifications. * Uses the toast/dismissToast state from api.js. + * + * Long messages (>200 chars or containing newlines) render compact — + * first line with an ellipsis — plus a "Details" button that opens a + * modal with the full text. Dismissal is only via the × button; + * hovering the toast pauses its auto-dismiss timer. */ import { h } from '../vdom.js'; import { _toasts, dismissToast } from '../api.js'; +import { openModal } from './modal.js'; + +/** Messages longer than this (or containing newlines) render compact. */ +const _LONG_MESSAGE_CHARS = 200; + +function _isLong(message) { + return message.length > _LONG_MESSAGE_CHARS || message.includes('\n'); +} + +function _firstLine(message) { + return message.split('\n')[0].trim(); +} + +function _showDetails(t) { + openModal( + h('div', null, + h('h2', { class: 'modal-title' }, 'Details'), + h('div', { class: 'modal-body' }, + h('pre', { class: 'toast-details-msg' }, t.message), + ), + ), + ); +} /** * Render all pending toast notifications. @@ -24,19 +52,26 @@ export function ToastContainer() { }; return h('div', { class: 'toast' }, - ..._toasts.map(t => - h('div', { + ..._toasts.map(t => { + const long = _isLong(t.message); + return h('div', { class: `toast-message ${clsMap[t.type] || clsMap.info}`, - 'on:click': () => dismissToast(t.id), + 'on:mouseover': () => { t.hovered = true; }, + 'on:mouseout': () => { t.hovered = false; }, }, - h('span', { class: 'toast-text' }, t.message), + h('span', { class: 'toast-text' + (long ? ' toast-text-long' : '') }, + long ? _firstLine(t.message) : t.message), h('div', { class: 'toast-actions' }, + long ? h('button', { + class: 'toast-btn toast-details', + 'on:click': (e) => { e.stopPropagation(); _showDetails(t); }, + }, 'Details') : null, h('button', { class: 'toast-btn toast-close', 'on:click': (e) => { e.stopPropagation(); dismissToast(t.id); }, }, '\u00d7'), ), - ), - ), + ); + }), ); } diff --git a/webui/static/style.css b/webui/static/style.css index 34f1897..6db90d1 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -380,6 +380,37 @@ body { white-space: pre-wrap; } +/* Compact rendering for long messages: single line, ellipsized. */ +.toast-message .toast-text-long { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.toast-message .toast-details { + font-size: 12px; + text-decoration: underline; + text-underline-offset: 2px; + padding: 0 4px; +} + +/* Full message inside the toast Details modal. */ +.toast-details-msg { + font-family: monospace; + font-size: 0.8rem; + line-height: 1.4; + white-space: pre-wrap; + word-break: break-word; + margin: 0; + padding: 0.75rem; + background: rgba(0, 0, 0, 0.35); + border: 1px solid var(--border); + border-radius: 6px; + max-height: 60vh; + overflow-y: auto; + color: var(--text); +} + .toast-message .toast-actions { display: flex; gap: 2px;