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 @@
-
-
-