ui: toast per-type durations, Details modal for long errors, concise acme.sh failure summary
This commit is contained in:
+9
-1
@@ -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)`
|
||||
|
||||
|
||||
+27
-5
@@ -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.
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user