diff --git a/solstone/convey/apps.py b/solstone/convey/apps.py index 279297d34..08f2dfe57 100644 --- a/solstone/convey/apps.py +++ b/solstone/convey/apps.py @@ -234,6 +234,8 @@ def register_app_context(app: Flask, registry: AppRegistry) -> None: @app.context_processor def inject_app_context() -> dict: """Inject app registry and facets context for new app system.""" + from solstone.convey import copy as convey_copy + from .config import apply_app_order, load_convey_config, reporting_enabled # Parse URL path: /app/{app_name}/{day}/... @@ -330,6 +332,11 @@ def register_app_context(app: Flask, registry: AppRegistry) -> None: "chat_bar_attention": chat_bar_attention, "chat_bar_sol_request": chat_bar_sol_request, "convey_settings": {"reporting_enabled": reporting_enabled()}, + "CONVEY_COPY": { + name.removeprefix("CONVEY_"): getattr(convey_copy, name) + for name in convey_copy.__all__ + if name.startswith("CONVEY_") + }, } @app.context_processor diff --git a/solstone/convey/copy.py b/solstone/convey/copy.py index 48580cc08..5250ca60b 100644 --- a/solstone/convey/copy.py +++ b/solstone/convey/copy.py @@ -11,6 +11,25 @@ CONVEY_ACTION_TRY_AGAIN = "Try again" # generic load-failure retry button CONVEY_ACTION_RELOAD = "Reload" # full-surface reload affordance CONVEY_ACTION_RECONNECT = "Reconnect" # transport/socket reconnection affordance CONVEY_ACTION_RESTART = "Restart" # service/observer restart affordance +CONVEY_CONSOLE_HEADING = "system messages" +CONVEY_CONSOLE_EMPTY = "I haven't seen any system messages this session." +CONVEY_CONSOLE_TAB_ALL = "all" +CONVEY_CONSOLE_TAB_ERRORS = "errors" +CONVEY_CONSOLE_TAB_WARNINGS = "warnings" +CONVEY_CONSOLE_TAB_INFO = "info" +CONVEY_CONSOLE_LINK_LABEL = "system messages" +CONVEY_CONSOLE_ACTION_SEND = "Send" +CONVEY_CONSOLE_ACTION_SEND_ALL = "Send all" +CONVEY_CONSOLE_ACTION_CLEAR = "Clear" +CONVEY_CONSOLE_ACTION_SHOW_DETAILS = "Show details" +CONVEY_CONSOLE_ACTION_CLOSE = "Close" +CONVEY_CONSOLE_REPORTING_OFF = "I can show these messages, but sending reports is off." +CONVEY_CONSOLE_SNAPSHOT_HEADING = "snapshot of {count} system messages" +CONVEY_CONSOLE_REPORT_HEADING = "Last {count} console entries:" +CONVEY_CONSOLE_TRUNCATED = "[older entries truncated]" +CONVEY_CONSOLE_DETAIL_MESSAGE_LABEL = "message" +CONVEY_CONSOLE_DETAIL_STACK_LABEL = "stack" +CONVEY_CONSOLE_DETAIL_LOCATION_LABEL = "location" CONVEY_REPORT_TITLE = "I can send this report to sol pbc" CONVEY_REPORT_DEFAULT_SUBJECT = "convey error" CONVEY_REPORT_SUBJECT_LABEL = "subject" @@ -84,6 +103,25 @@ __all__ = [ "CONVEY_ACTION_RELOAD", "CONVEY_ACTION_RECONNECT", "CONVEY_ACTION_RESTART", + "CONVEY_CONSOLE_ACTION_CLEAR", + "CONVEY_CONSOLE_ACTION_CLOSE", + "CONVEY_CONSOLE_ACTION_SEND", + "CONVEY_CONSOLE_ACTION_SEND_ALL", + "CONVEY_CONSOLE_ACTION_SHOW_DETAILS", + "CONVEY_CONSOLE_DETAIL_LOCATION_LABEL", + "CONVEY_CONSOLE_DETAIL_MESSAGE_LABEL", + "CONVEY_CONSOLE_DETAIL_STACK_LABEL", + "CONVEY_CONSOLE_EMPTY", + "CONVEY_CONSOLE_HEADING", + "CONVEY_CONSOLE_LINK_LABEL", + "CONVEY_CONSOLE_REPORTING_OFF", + "CONVEY_CONSOLE_REPORT_HEADING", + "CONVEY_CONSOLE_SNAPSHOT_HEADING", + "CONVEY_CONSOLE_TAB_ALL", + "CONVEY_CONSOLE_TAB_ERRORS", + "CONVEY_CONSOLE_TAB_INFO", + "CONVEY_CONSOLE_TAB_WARNINGS", + "CONVEY_CONSOLE_TRUNCATED", "CONVEY_RELOAD_HINT", "CONVEY_REPORT_ACTION_CANCEL", "CONVEY_REPORT_ACTION_CLOSE", diff --git a/solstone/convey/static/api.js b/solstone/convey/static/api.js index 412b19e41..f2cdf5fd1 100644 --- a/solstone/convey/static/api.js +++ b/solstone/convey/static/api.js @@ -126,6 +126,31 @@ return readControlValue(el); } + function pushApiErrorToConsole(apiError, url, fetchOptions) { + const diagnosticConsole = window.convey?.diagnosticConsole; + if (!diagnosticConsole || typeof diagnosticConsole.push !== 'function') { + return; + } + const method = String(fetchOptions?.method || 'GET').toUpperCase(); + diagnosticConsole.push({ + severity: 'error', + source: 'api', + summary: `${method} ${apiError.status} ${url}`, + detail: { + apiError: { + status: apiError.status, + statusText: apiError.statusText, + serverMessage: apiError.serverMessage, + url: apiError.url, + correlationId: apiError.correlationId, + timestamp: apiError.timestamp, + reasonCode: apiError.reasonCode, + rawDetail: apiError.rawDetail + } + } + }); + } + function getExistingControlError(el, errorHost) { if (errorHost) { return errorHost.querySelector('[data-control-save-error]'); @@ -166,7 +191,7 @@ const correlationId = response?.headers?.get('X-Solstone-Request-Id') || ''; const timestamp = Date.now(); window.location.href = '/'; - throw new ApiError({ + const apiError = new ApiError({ status: response.status, statusText: response.statusText, serverMessage: 'Authentication required', @@ -174,6 +199,8 @@ correlationId, timestamp }); + pushApiErrorToConsole(apiError, url, fetchOptions); + throw apiError; } const text = await response.text(); @@ -191,7 +218,7 @@ const timestamp = Date.now(); const reasonCode = payload?.reason_code ?? null; const rawDetail = payload?.detail ?? null; - throw new ApiError({ + const apiError = new ApiError({ status: response.status, statusText: response.statusText, serverMessage, @@ -201,6 +228,8 @@ reasonCode, rawDetail }); + pushApiErrorToConsole(apiError, url, fetchOptions); + throw apiError; } const parsed = parseJsonPayload(text); @@ -210,7 +239,7 @@ const correlationId = response.headers.get('X-Solstone-Request-Id') || ''; const timestamp = Date.now(); - throw new ApiError({ + const apiError = new ApiError({ status: response.status, statusText: response.statusText, serverMessage: 'Malformed server response', @@ -219,6 +248,8 @@ correlationId, timestamp }); + pushApiErrorToConsole(apiError, url, fetchOptions); + throw apiError; } function saveControl({ diff --git a/solstone/convey/static/app.css b/solstone/convey/static/app.css index 270d44782..3f875d0a4 100644 --- a/solstone/convey/static/app.css +++ b/solstone/convey/static/app.css @@ -127,15 +127,6 @@ body.has-date-nav .workspace { margin-top: calc(var(--facet-bar-height) + var(--date-nav-height)); } -/* Reserve space when error log is visible */ -body.has-error-log .workspace { - margin-bottom: 30vh; -} - -body.has-app-bar.has-error-log .workspace { - margin-bottom: calc(var(--app-bar-height) + 80px + 30vh); -} - /* Standard workspace containers */ .workspace-content { max-width: 1200px; @@ -740,6 +731,247 @@ body:has(.status-pane.visible) .facet-bar::after { overflow-y: auto; } +.status-pane-footer { + margin-top: 14px; + padding-top: 10px; + border-top: 1px solid rgba(0, 0, 0, 0.08); +} + +.status-pane-footer-link { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 3px 8px; + margin: 0 -8px; + border-radius: 4px; + color: inherit; + text-decoration: none; + font-size: 13px; + transition: background 0.15s ease; +} + +.status-pane-footer-link:hover { + background: rgba(0, 0, 0, 0.05); +} + +.status-pane-footer-link:active { + background: rgba(0, 0, 0, 0.08); +} + +/* Diagnostic console opens on the left so it can coexist with the right-side status pane. */ +.diagnostic-console { + position: fixed; + top: var(--facet-bar-height); + left: 0; + width: min(520px, calc(100vw - 24px)); + background: white; + border-right: 1px solid var(--facet-border, #e5e0db); + border-bottom: 1px solid var(--facet-border, #e5e0db); + border-radius: 0 0 12px 0; + box-shadow: 4px 4px 8px -2px rgba(0, 0, 0, 0.1); + z-index: var(--z-notifications); + overflow: hidden; + max-height: 0; + opacity: 0; + transition: max-height 0.25s ease, opacity 0.25s ease; +} + +.diagnostic-console.visible { + max-height: calc(100vh - var(--facet-bar-height) - 16px); + opacity: 1; +} + +.diagnostic-console-content { + padding: 16px; + max-height: calc(100vh - var(--facet-bar-height) - 48px); + overflow-y: auto; +} + +.diagnostic-console-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.diagnostic-console-header h3 { + margin: 0; + font-size: 15px; + font-weight: 600; +} + +.diagnostic-console-actions { + display: flex; + align-items: center; + gap: 6px; +} + +.diagnostic-console-actions button, +.diagnostic-console-entry button { + border: 1px solid #d1d5db; + border-radius: 4px; + background: #fff; + color: #374151; + cursor: pointer; + font: inherit; + font-size: 12px; + padding: 3px 8px; +} + +.diagnostic-console-actions button:hover, +.diagnostic-console-entry button:hover { + background: rgba(0, 0, 0, 0.05); +} + +.diagnostic-console-close { + width: 28px; + height: 28px; + padding: 0; + line-height: 1; +} + +.diagnostic-console-reporting-off { + margin: 0 0 12px; + color: #6b7280; + font-size: 13px; +} + +.diagnostic-console-tabs { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 12px; +} + +.diagnostic-console-tabs button { + border: 1px solid #d1d5db; + border-radius: 4px; + background: #fff; + color: #374151; + cursor: pointer; + font: inherit; + font-size: 12px; + padding: 4px 8px; +} + +.diagnostic-console-tabs button[aria-selected="true"] { + background: rgba(0, 0, 0, 0.08); + border-color: #9ca3af; +} + +.diagnostic-console-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.diagnostic-console-empty { + margin: 12px 0 0; + color: #6b7280; + font-size: 13px; +} + +.diagnostic-console-entry { + border: 1px solid #e5e7eb; + border-radius: 6px; + background: rgba(255, 255, 255, 0.72); +} + +.diagnostic-console-entry summary { + display: grid; + grid-template-columns: auto auto auto minmax(0, 1fr) auto auto; + align-items: center; + gap: 8px; + padding: 8px; + cursor: pointer; + list-style: none; + font-size: 13px; +} + +.diagnostic-console-entry summary::-webkit-details-marker { + display: none; +} + +.diagnostic-console-entry-time { + color: #9ca3af; + font-size: 11px; +} + +.diagnostic-console-entry-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 50%; + font-size: 11px; + font-weight: 700; +} + +.diagnostic-console-entry-icon--error { + background: #fee2e2; + color: #b91c1c; +} + +.diagnostic-console-entry-icon--warning { + background: #fef3c7; + color: #92400e; +} + +.diagnostic-console-entry-icon--info { + background: #e0f2fe; + color: #075985; +} + +.diagnostic-console-entry-source { + color: #6b7280; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 11px; +} + +.diagnostic-console-entry-summary { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.diagnostic-console-entry-toggle { + color: #6b7280; + font-size: 11px; +} + +.diagnostic-console-entry[open] .diagnostic-console-entry-toggle { + display: none; +} + +.diagnostic-console-entry-detail { + border-top: 1px solid #e5e7eb; + padding: 8px; + color: #4b5563; + font-size: 12px; +} + +.diagnostic-console-entry-detail div + div { + margin-top: 4px; +} + +.diagnostic-console-entry-detail span { + color: #374151; + font-weight: 600; +} + +.diagnostic-console-entry-detail pre { + margin: 4px 0 0; + padding: 8px; + overflow-x: auto; + border-radius: 4px; + background: #111827; + color: #f9fafb; + font-size: 11px; + white-space: pre-wrap; +} + .status-pane-content h3 { margin: 0 0 12px 0; font-size: 13px; @@ -1835,44 +2067,6 @@ body.menu-all .menu-bar { } } -/* Error log display */ -#error-log { - display: none; - position: fixed; - bottom: 0; - left: 0; - right: 0; - background: #111; - color: #eee; - padding: 1em; - font: 12px monospace; - max-height: 30vh; - overflow-y: auto; - border-top: 2px solid #ff3b30; - z-index: var(--z-notifications); - box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.5); -} - -#error-log-dismiss { - position: sticky; - top: 0; - float: right; - background: #222; - color: #eee; - border: 1px solid #444; - padding: 2px 8px; - font: 12px monospace; - cursor: pointer; -} - -#error-log-dismiss:hover { - background: #333; -} - -#error-log-dismiss:active { - background: #444; -} - /* Notification Center */ .notification-center { position: fixed; diff --git a/solstone/convey/static/convey_copy.js b/solstone/convey/static/convey_copy.js index b579ff744..a5fb9bff8 100644 --- a/solstone/convey/static/convey_copy.js +++ b/solstone/convey/static/convey_copy.js @@ -6,6 +6,25 @@ RELOAD_HINT: "reload to try again.", ACTION_RECONNECT: "Reconnect", ACTION_RESTART: "Restart", + CONSOLE_HEADING: "system messages", + CONSOLE_EMPTY: "I haven't seen any system messages this session.", + CONSOLE_TAB_ALL: "all", + CONSOLE_TAB_ERRORS: "errors", + CONSOLE_TAB_WARNINGS: "warnings", + CONSOLE_TAB_INFO: "info", + CONSOLE_LINK_LABEL: "system messages", + CONSOLE_ACTION_SEND: "Send", + CONSOLE_ACTION_SEND_ALL: "Send all", + CONSOLE_ACTION_CLEAR: "Clear", + CONSOLE_ACTION_SHOW_DETAILS: "Show details", + CONSOLE_ACTION_CLOSE: "Close", + CONSOLE_REPORTING_OFF: "I can show these messages, but sending reports is off.", + CONSOLE_SNAPSHOT_HEADING: "snapshot of {count} system messages", + CONSOLE_REPORT_HEADING: "Last {count} console entries:", + CONSOLE_TRUNCATED: "[older entries truncated]", + CONSOLE_DETAIL_MESSAGE_LABEL: "message", + CONSOLE_DETAIL_STACK_LABEL: "stack", + CONSOLE_DETAIL_LOCATION_LABEL: "location", REPORT_TITLE: "I can send this report to sol pbc", REPORT_DEFAULT_SUBJECT: "convey error", REPORT_SUBJECT_LABEL: "subject", diff --git a/solstone/convey/static/diagnostic-console.js b/solstone/convey/static/diagnostic-console.js new file mode 100644 index 000000000..4f0f5d186 --- /dev/null +++ b/solstone/convey/static/diagnostic-console.js @@ -0,0 +1,544 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (c) 2026 sol pbc + +(function() { + const STORAGE_KEY = 'solstone:diagnostic-console'; + const CONSOLE_MAX_ENTRIES = 200; + const STORAGE_VERSION = 1; + + window.convey = window.convey || {}; + + let entries = []; + let unreadCursor = 0; + let idCounter = 0; + let flushVersion = 0; + let activeFilter = 'all'; + let drawerOpen = false; + let drawerBound = false; + + function copyText(key, fallback) { + return window.CONVEY_COPY?.[key] || fallback || key; + } + + function reportingEnabled() { + return !(window.CONVEY_SETTINGS && window.CONVEY_SETTINGS.reportingEnabled === false); + } + + function escapeHtml(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function errorToPlain(error, seen) { + const plain = { + message: error.message || String(error), + stack: error.stack || '' + }; + Object.keys(error).forEach(key => { + const value = normalizeForJson(error[key], seen); + if (value !== undefined) { + plain[key] = value; + } + }); + return roundTrip(plain); + } + + function roundTrip(value) { + try { + const json = JSON.stringify(value); + if (json === undefined) { + return undefined; + } + return JSON.parse(json); + } catch (_) { + return undefined; + } + } + + function normalizeForJson(value, seen = new WeakSet()) { + if (value instanceof Error) { + return errorToPlain(value, seen); + } + if (value && typeof value === 'object') { + if (seen.has(value)) { + return undefined; + } + seen.add(value); + if (Array.isArray(value)) { + const items = value + .map(item => normalizeForJson(item, seen)) + .filter(item => item !== undefined); + seen.delete(value); + return roundTrip(items); + } + const plain = {}; + Object.keys(value).forEach(key => { + const item = normalizeForJson(value[key], seen); + if (item !== undefined) { + plain[key] = item; + } + }); + seen.delete(value); + return roundTrip(plain); + } + return roundTrip(value); + } + + function safeContext(value) { + if (value && typeof value === 'object' && !(value instanceof Error) && !Array.isArray(value)) { + const plain = {}; + Object.keys(value).forEach(key => { + const item = value[key] instanceof Error + ? errorToPlain(value[key], new WeakSet()) + : roundTrip(value[key]); + if (item !== undefined) { + plain[key] = item; + } + }); + return plain; + } + const normalized = normalizeForJson(value); + return normalized === undefined ? {} : normalized; + } + + function cloneEntry(entry) { + return safeContext(entry); + } + + function normalizeSeverity(value) { + return ['error', 'warning', 'info'].includes(value) ? value : 'error'; + } + + function normalizeEntry(entry) { + const source = typeof entry?.source === 'string' && entry.source.trim() + ? entry.source.trim() + : 'manual'; + const ts = Number.isFinite(Number(entry?.ts)) ? Number(entry.ts) : Date.now(); + const id = typeof entry?.id === 'string' && entry.id.trim() + ? entry.id.trim() + : `dc-${ts}-${idCounter++}`; + return { + id, + ts, + severity: normalizeSeverity(entry?.severity || entry?.category), + source, + summary: String(entry?.summary || copyText('CONSOLE_HEADING', 'system messages')), + detail: safeContext(entry?.detail || {}) + }; + } + + function storageWrite(payload) { + try { + window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(payload)); + } catch (_) { + // Session diagnostics are best-effort; storage failures must not recurse into logError. + } + } + + function storageRemove() { + try { + window.sessionStorage.removeItem(STORAGE_KEY); + } catch (_) { + // Best-effort cleanup only. + } + } + + function loadEntries() { + try { + const raw = window.sessionStorage.getItem(STORAGE_KEY); + if (!raw) { + return; + } + const parsed = JSON.parse(raw); + if (!parsed || parsed.version !== STORAGE_VERSION || !Array.isArray(parsed.entries)) { + entries = []; + unreadCursor = 0; + return; + } + entries = parsed.entries.map(normalizeEntry).slice(-CONSOLE_MAX_ENTRIES); + unreadCursor = Math.max(0, Math.min(Number(parsed.unreadCursor) || 0, entries.length)); + idCounter = entries.length; + } catch (_) { + entries = []; + unreadCursor = 0; + } + } + + function flushNow() { + storageWrite({ + version: STORAGE_VERSION, + unreadCursor, + entries + }); + } + + function scheduleFlush() { + flushVersion += 1; + const version = flushVersion; + Promise.resolve().then(() => { + if (version === flushVersion) { + flushNow(); + } + }); + } + + function dispatchUpdate() { + try { + window.dispatchEvent(new CustomEvent('diagnostic-console-updated')); + } catch (_) { + // Event dispatch is advisory. + } + } + + function countBySeverity(severity) { + if (severity === 'all') { + return entries.length; + } + return entries.filter(entry => entry.severity === severity).length; + } + + function filteredEntries(filter = {}) { + const severity = filter.severity; + const source = filter.source; + return entries.filter(entry => { + if (severity && severity !== 'all' && entry.severity !== severity) { + return false; + } + if (source && entry.source !== source) { + return false; + } + return true; + }); + } + + function formatTime(ts) { + const date = new Date(ts); + if (Number.isNaN(date.getTime())) { + return ''; + } + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } + + function formatDateTime(ts) { + const date = new Date(ts); + if (Number.isNaN(date.getTime())) { + return ''; + } + return date.toLocaleString(); + } + + function severityIcon(severity) { + if (severity === 'warning') { + return '!'; + } + if (severity === 'info') { + return 'i'; + } + return 'x'; + } + + function formatValue(value) { + if (value === undefined || value === null || value === '') { + return ''; + } + if (typeof value === 'object') { + try { + return JSON.stringify(value); + } catch (_) { + return String(value); + } + } + return String(value); + } + + function detailRow(label, value) { + const formatted = formatValue(value); + if (!formatted) { + return ''; + } + return `
${escapeHtml(label)}: ${escapeHtml(formatted)}
`; + } + + function renderApiDetail(apiError) { + const lines = []; + if (apiError.status || apiError.statusText || apiError.url) { + lines.push(`
HTTP ${escapeHtml(apiError.status || '')} ${escapeHtml(apiError.statusText || '')} · ${escapeHtml(apiError.url || '')}
`); + } + lines.push(detailRow('Server reason', apiError.rawDetail || apiError.serverMessage)); + lines.push(detailRow('Time', formatDateTime(apiError.timestamp))); + lines.push(detailRow('Reference', apiError.correlationId)); + lines.push(detailRow('Reason code', apiError.reasonCode)); + return lines.filter(Boolean).join(''); + } + + function renderJsDetail(detail) { + const messageLabel = copyText('CONSOLE_DETAIL_MESSAGE_LABEL', 'message'); + const stackLabel = copyText('CONSOLE_DETAIL_STACK_LABEL', 'stack'); + const locationLabel = copyText('CONSOLE_DETAIL_LOCATION_LABEL', 'location'); + const lines = []; + lines.push(detailRow(messageLabel, detail.message)); + if (detail.filename || detail.lineno || detail.colno) { + const location = `${detail.filename || ''}${detail.lineno ? `:${detail.lineno}` : ''}${detail.colno ? `:${detail.colno}` : ''}`; + lines.push(detailRow(locationLabel, location)); + } + if (detail.context && Object.keys(detail.context).length) { + lines.push(detailRow('context', detail.context)); + } + if (detail.stack) { + lines.push(`
${escapeHtml(stackLabel)}:
${escapeHtml(detail.stack)}
`); + } + return lines.filter(Boolean).join(''); + } + + function renderEntryDetail(entry) { + const detail = entry.detail || {}; + if (detail.apiError) { + return renderApiDetail(detail.apiError); + } + return renderJsDetail(detail); + } + + function renderEntry(entry) { + const sendButton = reportingEnabled() + ? `` + : ''; + return `
+ + ${escapeHtml(formatTime(entry.ts))} + ${escapeHtml(severityIcon(entry.severity))} + ${escapeHtml(entry.source)} + ${escapeHtml(entry.summary)} + ${escapeHtml(copyText('CONSOLE_ACTION_SHOW_DETAILS', 'Show details'))} + ${sendButton} + +
${renderEntryDetail(entry)}
+
`; + } + + function visibleEntries() { + return filteredEntries({ severity: activeFilter === 'all' ? null : activeFilter }); + } + + function renderDrawer() { + const root = document.getElementById('diagnostic-console'); + if (!root) { + return; + } + const list = document.getElementById('diagnostic-console-list'); + const empty = document.getElementById('diagnostic-console-empty'); + const sendAll = root.querySelector('[data-diagnostic-action="send-all"]'); + const reportingOff = root.querySelector('[data-diagnostic-reporting-off]'); + const canReport = reportingEnabled(); + + root.querySelectorAll('[data-diagnostic-count]').forEach(node => { + const severity = node.getAttribute('data-diagnostic-count'); + node.textContent = String(countBySeverity(severity)); + }); + root.querySelectorAll('[data-diagnostic-filter]').forEach(button => { + button.setAttribute('aria-selected', button.getAttribute('data-diagnostic-filter') === activeFilter ? 'true' : 'false'); + }); + + const currentEntries = visibleEntries().slice().reverse(); + if (list) { + list.innerHTML = currentEntries.map(renderEntry).join(''); + } + if (empty) { + empty.hidden = currentEntries.length !== 0; + } + if (sendAll) { + sendAll.hidden = !canReport; + sendAll.disabled = currentEntries.length === 0; + } + if (reportingOff) { + reportingOff.hidden = canReport; + } + } + + function openDrawer() { + const root = document.getElementById('diagnostic-console'); + if (!root) { + return; + } + drawerOpen = true; + root.classList.add('visible'); + root.setAttribute('aria-hidden', 'false'); + renderDrawer(); + api.markAllRead(); + root.focus(); + } + + function closeDrawer() { + const root = document.getElementById('diagnostic-console'); + if (!root) { + return; + } + drawerOpen = false; + root.classList.remove('visible'); + root.setAttribute('aria-hidden', 'true'); + } + + function sendEntry(entryId) { + if (!reportingEnabled() || !window.convey?.reportError) { + return; + } + const entry = entries.find(item => item.id === entryId); + if (!entry) { + return; + } + window.convey.reportError({ + source: 'console-entry', + heading: entry.summary, + apiError: entry.detail?.apiError || null, + consoleEntries: api.neighborhood(entry.id, 5), + customDetail: '' + }); + } + + function sendAllVisible() { + if (!reportingEnabled() || !window.convey?.reportError) { + return; + } + const currentEntries = visibleEntries().map(cloneEntry); + const template = copyText('CONSOLE_SNAPSHOT_HEADING', 'snapshot of {count} system messages'); + window.convey.reportError({ + source: 'manual', + heading: template.replace('{count}', String(currentEntries.length)), + apiError: null, + consoleEntries: currentEntries, + customDetail: '' + }); + } + + function bindDrawer() { + if (drawerBound) { + return; + } + const root = document.getElementById('diagnostic-console'); + if (!root) { + return; + } + drawerBound = true; + root.addEventListener('click', event => { + const target = event.target instanceof Element ? event.target : null; + if (!target) { + return; + } + const action = target.closest('[data-diagnostic-action]')?.getAttribute('data-diagnostic-action'); + if (action === 'close') { + closeDrawer(); + return; + } + if (action === 'clear') { + api.clear(); + return; + } + if (action === 'send-all') { + sendAllVisible(); + return; + } + const filter = target.closest('[data-diagnostic-filter]')?.getAttribute('data-diagnostic-filter'); + if (filter) { + activeFilter = filter; + renderDrawer(); + return; + } + const sendId = target.closest('[data-diagnostic-send]')?.getAttribute('data-diagnostic-send'); + if (sendId) { + event.preventDefault(); + event.stopPropagation(); + sendEntry(sendId); + } + }); + + document.addEventListener('keydown', event => { + if (event.key === 'Escape' && drawerOpen) { + closeDrawer(); + } + }); + + document.addEventListener('click', event => { + const target = event.target instanceof Element ? event.target : null; + if ( + drawerOpen + && root + && target + && !root.contains(target) + && !target.closest('#status-pane-console-link') + ) { + closeDrawer(); + } + }); + } + + const api = { + push(entry) { + const normalized = normalizeEntry(entry); + entries.push(normalized); + if (entries.length > CONSOLE_MAX_ENTRIES) { + const removed = entries.length - CONSOLE_MAX_ENTRIES; + entries = entries.slice(removed); + unreadCursor = Math.max(0, unreadCursor - removed); + } + unreadCursor = Math.min(unreadCursor, entries.length); + scheduleFlush(); + renderDrawer(); + dispatchUpdate(); + return cloneEntry(normalized); + }, + + list(filter = {}) { + return filteredEntries(filter).map(cloneEntry); + }, + + clear() { + entries = []; + unreadCursor = 0; + flushVersion += 1; + storageRemove(); + renderDrawer(); + dispatchUpdate(); + }, + + markAllRead() { + unreadCursor = entries.length; + scheduleFlush(); + dispatchUpdate(); + }, + + unreadCount() { + return Math.max(0, entries.length - unreadCursor); + }, + + neighborhood(entryId, span = 5) { + const index = entries.findIndex(entry => entry.id === entryId); + if (index === -1) { + return []; + } + const width = Math.max(0, Number(span) || 0); + return entries.slice(Math.max(0, index - width), index + width + 1).map(cloneEntry); + }, + + open: openDrawer, + close: closeDrawer + }; + + window.convey.diagnosticConsole = api; + + loadEntries(); + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + bindDrawer(); + renderDrawer(); + dispatchUpdate(); + }, { once: true }); + } else { + bindDrawer(); + renderDrawer(); + dispatchUpdate(); + } +})(); diff --git a/solstone/convey/static/error-handler.js b/solstone/convey/static/error-handler.js index 0d1422bcc..4824b1a71 100644 --- a/solstone/convey/static/error-handler.js +++ b/solstone/convey/static/error-handler.js @@ -8,64 +8,86 @@ * Features: * - Catches window errors and unhandled promise rejections * - Adds error glow to status icon via .error class - * - Displays error log at bottom of viewport + * - Routes diagnostics into the diagnostic console * - Provides modal for manual error display via window.showError() */ (function(){ - const statusIcon = document.querySelector('.facet-bar .status-icon'); - const errorLog = document.getElementById('error-log'); - const errorModal = document.getElementById('errorModal'); - const errorMessage = document.getElementById('errorMessage'); - const closeButton = errorModal ? errorModal.querySelector('.close') : null; - - // Escape HTML to prevent XSS - function escapeHtml(text) { - return String(text).replace(//g, '>'); + function roundTrip(value) { + try { + const json = JSON.stringify(value); + if (json === undefined) { + return undefined; + } + return JSON.parse(json); + } catch (_) { + return undefined; + } } - // Log error to bottom panel - function appendLogLine(text) { - if (errorLog) { - if (!document.getElementById('error-log-dismiss')) { - var btn = document.createElement('button'); - btn.id = 'error-log-dismiss'; - btn.textContent = 'clear'; - btn.setAttribute('aria-label', 'dismiss error log'); - btn.onclick = function() { - errorLog.innerHTML = ''; - errorLog.style.display = 'none'; - document.body.classList.remove('has-error-log'); - }; - errorLog.insertAdjacentElement('afterbegin', btn); - } - errorLog.insertAdjacentHTML( - 'beforeend', - escapeHtml(text) + '
' - ); - errorLog.style.display = 'block'; - document.body.classList.add('has-error-log'); + function normalizeContextValue(value) { + if (value instanceof Error) { + return roundTrip({ + message: value.message, + stack: value.stack || '' + }); } + return roundTrip(value); } - function formatErrorText(error, context) { - const prefix = context && context.context ? `[${context.context}] ` : ''; - if (typeof error === 'string') { - return prefix + error; + function safeContext(context) { + if (!context || typeof context !== 'object') { + return {}; } + const result = {}; + Object.keys(context).forEach(key => { + const value = normalizeContextValue(context[key]); + if (value !== undefined) { + result[key] = value; + } + }); + return result; + } + + function messageFromError(error) { if (error instanceof Error) { - return prefix + error.message; + return error.message || String(error); + } + if (typeof error === 'string') { + return error; } - return prefix + String(error ?? 'unknown error'); + return String(error ?? 'unknown error'); } window.logError = (error, context) => { markError(); - appendLogLine(formatErrorText(error, context)); + if (window.console && typeof window.console.error === 'function') { + window.console.error(error, context || ''); + } + const safe = safeContext(context); + const message = messageFromError(error); + const diagnosticConsole = window.convey?.diagnosticConsole; + if (diagnosticConsole && typeof diagnosticConsole.push === 'function') { + diagnosticConsole.push({ + severity: 'error', + source: 'js', + summary: safe.context ? `${safe.context}: ${message}` : message, + detail: { + message, + stack: error instanceof Error ? (error.stack || '') : '', + filename: safe.filename, + lineno: safe.lineno, + colno: safe.colno, + kind: safe.kind || 'logError', + context: safe + } + }); + } }; // Mark status icon as error state (red with glow) function markError() { + const statusIcon = document.querySelector('.facet-bar .status-icon'); if (statusIcon) { statusIcon.classList.add('error'); } @@ -73,32 +95,50 @@ // Global error handler window.addEventListener('error', (e) => { - window.logError(`❌ ${e.message} @ ${e.filename}:${e.lineno}`); + const error = e.error instanceof Error ? e.error : new Error(e.message || 'unknown error'); + window.logError(error, { + kind: 'error', + filename: e.filename, + lineno: e.lineno, + colno: e.colno + }); }); // Unhandled promise rejection handler window.addEventListener('unhandledrejection', (e) => { - window.logError(`⚠️ Promise: ${e.reason}`); + const error = e.reason instanceof Error ? e.reason : new Error(String(e.reason ?? 'unknown rejection')); + window.logError(error, { kind: 'unhandled-rejection' }); }); - // Modal controls - if (errorModal && closeButton && errorMessage) { - // Provide global function for manual error display - window.showError = (text) => { + window.showError = (text) => { + const errorModal = document.getElementById('errorModal'); + const errorMessage = document.getElementById('errorMessage'); + if (errorModal && errorMessage) { errorMessage.textContent = text; errorModal.style.display = 'block'; - }; + } + }; - // Close button + function bindModalControls() { + const errorModal = document.getElementById('errorModal'); + const closeButton = errorModal ? errorModal.querySelector('.close') : null; + if (!errorModal || !closeButton) { + return; + } closeButton.onclick = () => { errorModal.style.display = 'none'; }; - // Click outside to close window.addEventListener('click', (e) => { if (e.target === errorModal) { errorModal.style.display = 'none'; } }); } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', bindModalControls, { once: true }); + } else { + bindModalControls(); + } })(); diff --git a/solstone/convey/static/report-error.js b/solstone/convey/static/report-error.js index e541defa7..741c7bd83 100644 --- a/solstone/convey/static/report-error.js +++ b/solstone/convey/static/report-error.js @@ -3,6 +3,7 @@ (function() { const MAILTO_LIMIT = 1800; + const CONSOLE_ENTRY_LIMIT = 20000; const SUPPORT_EMAIL = 'support@solpbc.org'; const TICKET_URL = '/app/support/'; const REPORT_CATEGORY = 'error_report'; @@ -39,7 +40,101 @@ return date.toISOString(); } - function formatDiagnosticBundle(apiError) { + function formatEntryValue(value) { + if (!hasValue(value)) { + return ''; + } + if (typeof value === 'object') { + try { + return JSON.stringify(value); + } catch (_) { + return String(value); + } + } + return String(value); + } + + function entryApiError(entry) { + return entry?.detail?.apiError || null; + } + + function formatConsoleEntry(entry) { + const lines = [ + `${formatTimestamp(entry.ts)} · ${entry.severity || 'error'} · ${entry.source || 'unknown'}`, + `summary: ${entry.summary || ''}`, + ]; + const apiError = entryApiError(entry); + if (apiError) { + const bits = []; + if (hasValue(apiError.status)) { + bits.push(`HTTP ${apiError.status}`); + } + if (hasValue(apiError.url)) { + bits.push(apiError.url); + } + if (hasValue(apiError.serverMessage)) { + bits.push(apiError.serverMessage); + } + if (bits.length) { + lines.push(bits.join(' · ')); + } + if (hasValue(apiError.correlationId)) { + lines.push(`reference: ${apiError.correlationId}`); + } + if (hasValue(apiError.reasonCode)) { + lines.push(`reason code: ${apiError.reasonCode}`); + } + } else { + const detail = entry?.detail || {}; + if (hasValue(detail.message)) { + lines.push(`message: ${detail.message}`); + } + if (hasValue(detail.filename) || hasValue(detail.lineno)) { + const location = `${detail.filename || ''}${detail.lineno ? `:${detail.lineno}` : ''}${detail.colno ? `:${detail.colno}` : ''}`; + lines.push(`location: ${location}`); + } + if (hasValue(detail.stack)) { + lines.push('stack:'); + String(detail.stack).split('\n').slice(0, 6).forEach(line => { + lines.push(` ${line}`); + }); + } + if (detail.context && Object.keys(detail.context).length) { + lines.push(`context: ${formatEntryValue(detail.context)}`); + } + } + return lines.slice(0, 10).join('\n'); + } + + function formatConsoleEntryLines(consoleEntries) { + const entries = Array.isArray(consoleEntries) ? consoleEntries.filter(Boolean) : []; + if (!entries.length) { + return [ + copyText('REPORT_DIAGNOSTIC_CONSOLE_HEADING'), + ` ${copyText('REPORT_NO_CONSOLE')}`, + ]; + } + + let rendered = entries.map(formatConsoleEntry); + let truncated = false; + while (rendered.length > 0 && rendered.join('\n\n').length > CONSOLE_ENTRY_LIMIT) { + rendered.shift(); + truncated = true; + } + + const heading = copyText('CONSOLE_REPORT_HEADING').replace('{count}', String(rendered.length)); + const lines = [heading]; + if (truncated) { + lines.push(copyText('CONSOLE_TRUNCATED')); + } + rendered.forEach(block => { + lines.push(''); + lines.push(block); + }); + return lines; + } + + function formatDiagnosticBundle(apiError, consoleEntries) { const lines = [copyText('REPORT_DIAGNOSTIC_SUMMARY')]; if (apiError) { if (hasValue(apiError.status)) { @@ -68,8 +163,7 @@ lines.push(`${copyText('REPORT_DIAGNOSTIC_RAW_DETAIL_LABEL')}: ${apiError.rawDetail}`); } } - lines.push(copyText('REPORT_DIAGNOSTIC_CONSOLE_HEADING')); - lines.push(` ${copyText('REPORT_NO_CONSOLE')}`); + lines.push(...formatConsoleEntryLines(consoleEntries)); lines.push(copyText('REPORT_DIAGNOSTIC_FOOTER')); return lines.join('\n'); } @@ -94,9 +188,9 @@ }); } - function buildFormState({ subject, detail, severity, anonymous, apiError }) { + function buildFormState({ subject, detail, severity, anonymous, apiError, consoleEntries }) { const safeSubject = String(subject || '').trim() || copyText('REPORT_DEFAULT_SUBJECT'); - const bundle = formatDiagnosticBundle(apiError); + const bundle = formatDiagnosticBundle(apiError, consoleEntries); const description = `${detail || ''}\n\n---\n\n${bundle}`; const subjectLabel = copyText('REPORT_SUBJECT_LABEL'); return { @@ -105,6 +199,7 @@ severity: severity || 'low', anonymous: Boolean(anonymous), apiError: apiError || null, + consoleEntries: Array.isArray(consoleEntries) ? consoleEntries : null, bundle, description, fullReport: `${subjectLabel}: ${safeSubject}\n\n${description}`, @@ -144,7 +239,7 @@ const subject = context.heading || copyText('REPORT_DEFAULT_SUBJECT'); const detail = context.customDetail || ''; - const bundle = formatDiagnosticBundle(context.apiError); + const bundle = formatDiagnosticBundle(context.apiError, context.consoleEntries); const modal = document.createElement('div'); modal.className = 'modal report-error-modal'; @@ -251,6 +346,7 @@ severity: severity.value, anonymous: anonymous.checked, apiError: context.apiError, + consoleEntries: context.consoleEntries, }); submitReport(formState, { cancel, send }).catch(error => { logUnexpected('submit unexpected', error); @@ -423,6 +519,7 @@ heading: safeContext.heading || copyText('REPORT_DEFAULT_SUBJECT'), apiError: safeContext.apiError || null, customDetail: safeContext.customDetail || '', + consoleEntries: Array.isArray(safeContext.consoleEntries) ? safeContext.consoleEntries : null, }; try { openModal(normalized); @@ -434,6 +531,7 @@ severity: 'low', anonymous: false, apiError: normalized.apiError, + consoleEntries: normalized.consoleEntries, })); } }; diff --git a/solstone/convey/static/tests/README.md b/solstone/convey/static/tests/README.md index 91858f531..6f2a32397 100644 --- a/solstone/convey/static/tests/README.md +++ b/solstone/convey/static/tests/README.md @@ -1,4 +1,5 @@ Open `solstone/convey/static/tests/api.html` in a browser; each assertion reports pass/fail inline. +Open `solstone/convey/static/tests/diagnostic-console.html` in a browser; each assertion reports pass/fail inline. Open `solstone/convey/static/tests/relative-time.html` in a browser; each assertion reports pass/fail inline. Open `solstone/convey/static/tests/surface-state.html` in a browser; each assertion reports pass/fail inline. Open `solstone/convey/static/tests/ws-listen.html` in a browser; each assertion reports pass/fail inline. diff --git a/solstone/convey/static/tests/diagnostic-console.html b/solstone/convey/static/tests/diagnostic-console.html new file mode 100644 index 000000000..63ed1b2d3 --- /dev/null +++ b/solstone/convey/static/tests/diagnostic-console.html @@ -0,0 +1,179 @@ + + + +diagnostic console smoke + +
+ + +
+ + + + + diff --git a/solstone/convey/templates/app.html b/solstone/convey/templates/app.html index fb3874539..24765a799 100644 --- a/solstone/convey/templates/app.html +++ b/solstone/convey/templates/app.html @@ -11,6 +11,7 @@ + @@ -114,6 +115,7 @@ {% include "status_pane.html" %} +{% include "diagnostic_console.html" %}
@@ -1043,8 +1045,5 @@ - -
- diff --git a/solstone/convey/templates/diagnostic_console.html b/solstone/convey/templates/diagnostic_console.html new file mode 100644 index 000000000..65276a101 --- /dev/null +++ b/solstone/convey/templates/diagnostic_console.html @@ -0,0 +1,24 @@ + diff --git a/solstone/convey/templates/status_pane.html b/solstone/convey/templates/status_pane.html index 15e72e277..f11a5bdff 100644 --- a/solstone/convey/templates/status_pane.html +++ b/solstone/convey/templates/status_pane.html @@ -33,6 +33,10 @@
no recent activity
+ + @@ -41,6 +45,7 @@ (function(){ const statusIcon = document.querySelector('.facet-bar .status-icon'); const statusPane = document.querySelector('.status-pane'); + const statusConsoleLink = document.getElementById('status-pane-console-link'); const liveRegion = document.getElementById('status-live-region'); let statusPaneOpen = false; let _lastHistoryLen = -1; @@ -55,9 +60,17 @@ const expanded = statusIcon.getAttribute('aria-expanded') === 'true'; statusIcon.setAttribute('aria-label', 'system status: ' + state + ', ' + (expanded ? 'expanded' : 'collapsed')); - if (liveRegion) liveRegion.textContent = state; + if (liveRegion) liveRegion.textContent = state; }; + function updateDiagnosticConsoleLink() { + if (!statusConsoleLink) return; + const base = window.CONVEY_COPY?.CONSOLE_LINK_LABEL || 'system messages'; + const unread = window.convey?.diagnosticConsole?.unreadCount?.() || 0; + statusConsoleLink.textContent = unread > 0 ? `${base} (${unread})` : base; + statusConsoleLink.setAttribute('aria-label', unread > 0 ? `${base}, ${unread} unread` : base); + } + if (statusIcon && statusPane) { statusIcon.addEventListener('click', (e) => { e.stopPropagation(); @@ -102,12 +115,28 @@ if (_systemStatusInterval) { clearInterval(_systemStatusInterval); _systemStatusInterval = null; } window.updateStatusLabel(); statusIcon.focus(); - } - }); + } + }); + + if (statusConsoleLink) { + statusConsoleLink.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + window.convey?.diagnosticConsole?.open?.(); + window.convey?.diagnosticConsole?.markAllRead?.(); + updateDiagnosticConsoleLink(); + statusPaneOpen = false; + statusPane.classList.remove('visible'); + statusIcon.setAttribute('aria-expanded', 'false'); + if (_systemStatusInterval) { clearInterval(_systemStatusInterval); _systemStatusInterval = null; } + window.updateStatusLabel(); + }); + } } // Update status pane metrics function updateStatusPane() { + updateDiagnosticConsoleLink(); if (!window.appEvents) return; if (!statusPaneOpen) return; @@ -429,8 +458,9 @@ }); } - // Update status pane every second - setInterval(updateStatusPane, 1000); + // Update status pane every second + setInterval(updateStatusPane, 1000); + window.addEventListener('diagnostic-console-updated', updateDiagnosticConsoleLink); // Initial update setTimeout(updateStatusPane, 100); })(); diff --git a/tests/test_convey_copy_console_parity.py b/tests/test_convey_copy_console_parity.py new file mode 100644 index 000000000..daabb2b1c --- /dev/null +++ b/tests/test_convey_copy_console_parity.py @@ -0,0 +1,26 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +from __future__ import annotations + +import json +import re +from pathlib import Path + +from solstone.convey import copy + + +def test_console_copy_matches_browser_mirror() -> None: + text = Path("solstone/convey/static/convey_copy.js").read_text(encoding="utf-8") + + for python_name in copy.__all__: + if not python_name.startswith("CONVEY_CONSOLE_"): + continue + js_name = python_name.removeprefix("CONVEY_") + assert _js_constant(text, js_name) == getattr(copy, python_name) + + +def _js_constant(text: str, name: str) -> str: + match = re.search(rf'{name}: "((?:[^"\\]|\\.)*)"', text) + assert match is not None, name + return json.loads(f'"{match.group(1)}"')