551 lines
20 KiB
JavaScript
551 lines
20 KiB
JavaScript
// ==========================================
|
|
// Debug Logger & Modal
|
|
// ==========================================
|
|
// Console interceptor, fetch logger, state inspector.
|
|
// Adapted from ai-editor's error-logger.js + llm-debug-modal.js
|
|
// for no-devtools "hardmode" debugging.
|
|
//
|
|
// Activate: Ctrl+Shift+D or tap the 🐛 badge (appears after init)
|
|
// ==========================================
|
|
|
|
const DebugLog = {
|
|
entries: [],
|
|
maxEntries: 500,
|
|
networkLog: [],
|
|
maxNetwork: 200,
|
|
_origConsole: {},
|
|
_origFetch: null,
|
|
_initTime: Date.now(),
|
|
_errorCount: 0,
|
|
|
|
// ── Bootstrap ────────────────────────────
|
|
|
|
init() {
|
|
this._interceptConsole();
|
|
this._interceptFetch();
|
|
this._interceptErrors();
|
|
this._injectBadge();
|
|
this.log('DEBUG', '🐛 Debug logger initialized');
|
|
this.log('DEBUG', `Page: ${location.href}`);
|
|
this.log('DEBUG', `UA: ${navigator.userAgent.slice(0, 120)}`);
|
|
},
|
|
|
|
// ── Console Interception ────────────────
|
|
|
|
_interceptConsole() {
|
|
['log', 'warn', 'error', 'info', 'debug'].forEach(method => {
|
|
this._origConsole[method] = console[method].bind(console);
|
|
console[method] = (...args) => {
|
|
const type = method.toUpperCase();
|
|
const message = args.map(a => this._serialize(a)).join(' ');
|
|
this._addEntry(type, message);
|
|
this._origConsole[method](...args);
|
|
};
|
|
});
|
|
},
|
|
|
|
// ── Fetch Interception ──────────────────
|
|
|
|
_interceptFetch() {
|
|
this._origFetch = window.fetch.bind(window);
|
|
const self = this;
|
|
|
|
window.fetch = async function(input, init) {
|
|
const url = typeof input === 'string' ? input : input?.url || String(input);
|
|
const method = init?.method || 'GET';
|
|
const entry = {
|
|
id: Date.now(),
|
|
ts: new Date().toISOString(),
|
|
method,
|
|
url: url.slice(0, 300),
|
|
status: null,
|
|
statusText: '',
|
|
duration: null,
|
|
error: null,
|
|
responsePreview: null,
|
|
requestBodyPreview: null
|
|
};
|
|
|
|
// Capture request body preview (redact keys)
|
|
if (init?.body) {
|
|
try {
|
|
const bodyStr = typeof init.body === 'string' ? init.body : '';
|
|
const parsed = bodyStr ? JSON.parse(bodyStr) : {};
|
|
// Redact sensitive fields
|
|
const safe = { ...parsed };
|
|
['password', 'api_key', 'apiKey', 'refresh_token', 'access_token'].forEach(k => {
|
|
if (safe[k]) safe[k] = '***';
|
|
});
|
|
entry.requestBodyPreview = JSON.stringify(safe).slice(0, 300);
|
|
} catch (e) {
|
|
entry.requestBodyPreview = '(non-JSON body)';
|
|
}
|
|
}
|
|
|
|
self.networkLog.push(entry);
|
|
if (self.networkLog.length > self.maxNetwork) {
|
|
self.networkLog.shift();
|
|
}
|
|
|
|
const start = performance.now();
|
|
try {
|
|
const resp = await self._origFetch(input, init);
|
|
entry.status = resp.status;
|
|
entry.statusText = resp.statusText;
|
|
entry.duration = Math.round(performance.now() - start);
|
|
|
|
// Log non-ok responses at higher severity
|
|
const logType = resp.ok ? 'NET' : 'NET:ERR';
|
|
self._addEntry(logType, `${method} ${url.slice(0, 80)} → ${resp.status} (${entry.duration}ms)`);
|
|
|
|
// Clone and peek at error responses
|
|
if (!resp.ok) {
|
|
try {
|
|
const clone = resp.clone();
|
|
const text = await clone.text();
|
|
entry.responsePreview = text.slice(0, 500);
|
|
} catch (e) { /* can't peek */ }
|
|
}
|
|
|
|
return resp;
|
|
} catch (err) {
|
|
entry.duration = Math.round(performance.now() - start);
|
|
entry.error = err.message || String(err);
|
|
self._addEntry('NET:ERR', `${method} ${url.slice(0, 80)} → FAILED: ${entry.error} (${entry.duration}ms)`);
|
|
throw err;
|
|
}
|
|
};
|
|
},
|
|
|
|
// ── Global Error Handlers ───────────────
|
|
|
|
_interceptErrors() {
|
|
window.addEventListener('error', (e) => {
|
|
this._addEntry('EXCEPTION', `${e.message} at ${e.filename || '?'}:${e.lineno || 0}:${e.colno || 0}`);
|
|
this._errorCount++;
|
|
this._updateBadge();
|
|
});
|
|
|
|
window.addEventListener('unhandledrejection', (e) => {
|
|
const msg = e.reason?.message || e.reason || 'Unknown rejection';
|
|
this._addEntry('REJECTION', String(msg));
|
|
this._errorCount++;
|
|
this._updateBadge();
|
|
});
|
|
},
|
|
|
|
// ── Entry Management ────────────────────
|
|
|
|
_addEntry(type, message) {
|
|
const entry = {
|
|
ts: new Date().toISOString(),
|
|
elapsed: Date.now() - this._initTime,
|
|
type,
|
|
message
|
|
};
|
|
this.entries.push(entry);
|
|
if (this.entries.length > this.maxEntries) {
|
|
this.entries.shift();
|
|
}
|
|
|
|
if (type === 'ERROR' || type === 'EXCEPTION' || type === 'REJECTION' || type === 'NET:ERR') {
|
|
this._errorCount++;
|
|
this._updateBadge();
|
|
}
|
|
|
|
// Auto-refresh if modal is open
|
|
if (document.getElementById('debugModal')?.classList.contains('active')) {
|
|
this._scheduleRender();
|
|
}
|
|
},
|
|
|
|
_renderTimer: null,
|
|
_scheduleRender() {
|
|
if (this._renderTimer) return;
|
|
this._renderTimer = setTimeout(() => {
|
|
this._renderTimer = null;
|
|
this.render();
|
|
}, 250);
|
|
},
|
|
|
|
log(type, message) {
|
|
this._addEntry(type, message);
|
|
},
|
|
|
|
clear() {
|
|
this.entries = [];
|
|
this.networkLog = [];
|
|
this._errorCount = 0;
|
|
this._updateBadge();
|
|
},
|
|
|
|
// ── Serialization ───────────────────────
|
|
|
|
_serialize(arg) {
|
|
if (arg === null) return 'null';
|
|
if (arg === undefined) return 'undefined';
|
|
if (arg instanceof Error) {
|
|
return `${arg.name}: ${arg.message}`;
|
|
}
|
|
if (typeof arg === 'object') {
|
|
try { return JSON.stringify(arg, null, 0); }
|
|
catch (e) { return String(arg); }
|
|
}
|
|
return String(arg);
|
|
},
|
|
|
|
// ── State Snapshot ──────────────────────
|
|
|
|
getStateSnapshot() {
|
|
const snap = {};
|
|
|
|
// API client state (redact tokens)
|
|
if (typeof API !== 'undefined') {
|
|
snap.api = {
|
|
hasAccessToken: !!API.accessToken,
|
|
hasRefreshToken: !!API.refreshToken,
|
|
user: API.user ? {
|
|
username: API.user.username,
|
|
role: API.user.role,
|
|
display_name: API.user.display_name
|
|
} : null,
|
|
isAuthed: API.isAuthed,
|
|
isAdmin: API.isAdmin
|
|
};
|
|
}
|
|
|
|
// App state
|
|
if (typeof App !== 'undefined') {
|
|
snap.app = {
|
|
chatCount: App.chats?.length || 0,
|
|
currentChatId: App.currentChatId,
|
|
modelCount: App.models?.length || 0,
|
|
isGenerating: App.isGenerating,
|
|
settings: {
|
|
model: App.settings?.model || '?',
|
|
stream: App.settings?.stream,
|
|
showThinking: App.settings?.showThinking
|
|
}
|
|
};
|
|
}
|
|
|
|
// localStorage keys
|
|
try {
|
|
snap.storageKeys = Object.keys(localStorage).filter(k =>
|
|
k.startsWith('chatSwitchboard') || k.startsWith('switchboard') || k.startsWith('sb_')
|
|
);
|
|
} catch (e) {
|
|
snap.storageKeys = '(error reading)';
|
|
}
|
|
|
|
// EventBus state
|
|
if (typeof Events !== 'undefined') {
|
|
snap.eventBus = {
|
|
wsConnected: Events.connected,
|
|
subscriptions: Events.debug(),
|
|
queueLength: Events._wsQueue?.length || 0
|
|
};
|
|
}
|
|
|
|
return snap;
|
|
},
|
|
|
|
// ── Connection Diagnostics ──────────────
|
|
|
|
async runDiagnostics() {
|
|
this.log('DIAG', '── Starting connection diagnostics ──');
|
|
|
|
const baseUrl = (typeof API !== 'undefined' && API._base)
|
|
? API._base
|
|
: window.location.origin;
|
|
|
|
// Test 1: Basic fetch to same origin
|
|
this.log('DIAG', `Test 1: Health check → ${baseUrl}/api/v1/health`);
|
|
try {
|
|
const start = performance.now();
|
|
const resp = await this._origFetch(baseUrl + '/api/v1/health', {
|
|
signal: AbortSignal.timeout(10000)
|
|
});
|
|
const dur = Math.round(performance.now() - start);
|
|
const contentType = resp.headers.get('content-type') || '(none)';
|
|
const text = await resp.text();
|
|
this.log('DIAG', ` Status: ${resp.status} (${dur}ms)`);
|
|
this.log('DIAG', ` Content-Type: ${contentType}`);
|
|
this.log('DIAG', ` Body: ${text.slice(0, 300)}`);
|
|
|
|
if (contentType.includes('text/html')) {
|
|
this.log('DIAG', ' ⚠️ Got HTML instead of JSON — likely a PROXY interception page');
|
|
}
|
|
if (!resp.ok) {
|
|
this.log('DIAG', ` ⚠️ Non-200 status: ${resp.status} ${resp.statusText}`);
|
|
}
|
|
try {
|
|
const json = JSON.parse(text);
|
|
this.log('DIAG', ` ✅ Valid JSON: database=${json.database}, registration=${json.registration_enabled}`);
|
|
} catch (e) {
|
|
this.log('DIAG', ` ❌ NOT valid JSON — proxy or wrong endpoint`);
|
|
}
|
|
} catch (err) {
|
|
this.log('DIAG', ` ❌ Fetch failed: ${err.message}`);
|
|
if (err.name === 'TimeoutError' || err.name === 'AbortError') {
|
|
this.log('DIAG', ' ⚠️ Request timed out — proxy may be blocking');
|
|
}
|
|
}
|
|
|
|
// Test 2: Check if we're getting CORS issues
|
|
this.log('DIAG', 'Test 2: OPTIONS preflight check');
|
|
try {
|
|
const resp = await this._origFetch(baseUrl + '/api/v1/health', {
|
|
method: 'OPTIONS',
|
|
signal: AbortSignal.timeout(5000)
|
|
});
|
|
this.log('DIAG', ` Status: ${resp.status}`);
|
|
this.log('DIAG', ` CORS headers: ${resp.headers.get('access-control-allow-origin') || '(none)'}`);
|
|
} catch (err) {
|
|
this.log('DIAG', ` ❌ OPTIONS failed: ${err.message}`);
|
|
}
|
|
|
|
// Test 3: Token validity
|
|
if (typeof API !== 'undefined' && API.accessToken) {
|
|
this.log('DIAG', 'Test 3: Token validation');
|
|
try {
|
|
const resp = await this._origFetch(baseUrl + '/api/v1/profile', {
|
|
headers: {
|
|
'Authorization': `Bearer ${API.accessToken}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
signal: AbortSignal.timeout(5000)
|
|
});
|
|
this.log('DIAG', ` Profile endpoint: ${resp.status}`);
|
|
if (resp.status === 401) {
|
|
this.log('DIAG', ' ⚠️ Token expired or invalid');
|
|
}
|
|
} catch (err) {
|
|
this.log('DIAG', ` ❌ Token check failed: ${err.message}`);
|
|
}
|
|
} else {
|
|
this.log('DIAG', 'Test 3: Skipped (no token)');
|
|
}
|
|
|
|
// Test 4: WebSocket connectivity
|
|
this.log('DIAG', `Test 4: EventBus WebSocket = ${typeof Events !== 'undefined' ? (Events.connected ? 'connected' : 'disconnected') : 'N/A'}`);
|
|
if (typeof Events !== 'undefined') {
|
|
this.log('DIAG', ` Subscriptions: ${JSON.stringify(Events.debug())}`);
|
|
this.log('DIAG', ` Queue depth: ${Events._wsQueue?.length || 0}`);
|
|
}
|
|
|
|
this.log('DIAG', '── Diagnostics complete ──');
|
|
this.render();
|
|
},
|
|
|
|
// ── Badge ───────────────────────────────
|
|
|
|
_injectBadge() {
|
|
// Keyboard shortcut only — visual indicator is the avatar 🐛
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.ctrlKey && e.shiftKey && e.key === 'L') {
|
|
e.preventDefault();
|
|
openDebugModal();
|
|
}
|
|
});
|
|
},
|
|
|
|
_updateBadge() {
|
|
// Show error count on avatar bug in sidebar
|
|
const bug = document.querySelector('.avatar-bug');
|
|
if (!bug) return;
|
|
if (this._errorCount > 0) {
|
|
bug.textContent = '🐛';
|
|
bug.title = `${this._errorCount} error${this._errorCount > 1 ? 's' : ''} — click for Debug Log`;
|
|
bug.classList.add('has-errors');
|
|
} else {
|
|
bug.textContent = '🐛';
|
|
bug.title = 'Debug available';
|
|
bug.classList.remove('has-errors');
|
|
}
|
|
},
|
|
|
|
// ── Modal Rendering ─────────────────────
|
|
|
|
render() {
|
|
this._renderConsoleTab();
|
|
this._renderNetworkTab();
|
|
this._renderStateTab();
|
|
},
|
|
|
|
_renderConsoleTab() {
|
|
const container = document.getElementById('debugConsoleContent');
|
|
if (!container) return;
|
|
|
|
const count = document.getElementById('debugConsoleCount');
|
|
if (count) count.textContent = this.entries.length;
|
|
|
|
if (this.entries.length === 0) {
|
|
container.innerHTML = '<div class="debug-empty">No log entries yet</div>';
|
|
return;
|
|
}
|
|
|
|
const typeColors = {
|
|
'ERROR': '#e74c3c', 'EXCEPTION': '#e74c3c', 'REJECTION': '#e74c3c',
|
|
'NET:ERR': '#e74c3c',
|
|
'WARN': '#f39c12', 'WARNING': '#f39c12',
|
|
'LOG': '#95a5a6', 'INFO': '#3498db', 'DEBUG': '#7f8c8d',
|
|
'NET': '#2ecc71',
|
|
'DIAG': '#9b59b6'
|
|
};
|
|
|
|
let html = '';
|
|
const entries = document.getElementById('debugFilterErrors')?.checked
|
|
? this.entries.filter(e => ['ERROR', 'EXCEPTION', 'REJECTION', 'NET:ERR', 'WARN'].includes(e.type))
|
|
: this.entries;
|
|
|
|
for (const entry of entries) {
|
|
const color = typeColors[entry.type] || '#95a5a6';
|
|
const time = entry.ts.slice(11, 23);
|
|
const elapsed = (entry.elapsed / 1000).toFixed(1);
|
|
html += `<div class="debug-entry">`;
|
|
html += `<span class="debug-time">${time} +${elapsed}s</span> `;
|
|
html += `<span style="color:${color};font-weight:bold;">[${this._esc(entry.type)}]</span> `;
|
|
html += `<span class="debug-msg">${this._esc(entry.message)}</span>`;
|
|
html += `</div>`;
|
|
}
|
|
container.innerHTML = html;
|
|
|
|
if (document.getElementById('debugAutoScroll')?.checked) {
|
|
container.scrollTop = container.scrollHeight;
|
|
}
|
|
},
|
|
|
|
_renderNetworkTab() {
|
|
const container = document.getElementById('debugNetworkContent');
|
|
if (!container) return;
|
|
|
|
const count = document.getElementById('debugNetworkCount');
|
|
if (count) count.textContent = this.networkLog.length;
|
|
|
|
if (this.networkLog.length === 0) {
|
|
container.innerHTML = '<div class="debug-empty">No network requests logged</div>';
|
|
return;
|
|
}
|
|
|
|
let html = '';
|
|
for (const req of [...this.networkLog].reverse()) {
|
|
const isErr = req.error || (req.status && req.status >= 400);
|
|
const statusColor = isErr ? '#e74c3c' : '#2ecc71';
|
|
const statusText = req.error
|
|
? `❌ ${req.error}`
|
|
: (req.status ? `${req.status} ${req.statusText}` : '⏳ pending');
|
|
|
|
html += `<details class="debug-net-entry">`;
|
|
html += `<summary>`;
|
|
html += `<span class="debug-net-method">${req.method}</span> `;
|
|
html += `<span class="debug-net-url">${this._esc(req.url)}</span> `;
|
|
html += `<span style="color:${statusColor}">${statusText}</span>`;
|
|
if (req.duration != null) html += ` <span class="debug-time">${req.duration}ms</span>`;
|
|
html += `</summary>`;
|
|
html += `<div class="debug-net-detail">`;
|
|
if (req.requestBodyPreview) {
|
|
html += `<div><strong>Request:</strong> <code>${this._esc(req.requestBodyPreview)}</code></div>`;
|
|
}
|
|
if (req.responsePreview) {
|
|
html += `<div><strong>Response:</strong> <pre class="debug-pre">${this._esc(req.responsePreview)}</pre></div>`;
|
|
}
|
|
html += `<div class="debug-time">${req.ts}</div>`;
|
|
html += `</div></details>`;
|
|
}
|
|
container.innerHTML = html;
|
|
},
|
|
|
|
_renderStateTab() {
|
|
const container = document.getElementById('debugStateContent');
|
|
if (!container) return;
|
|
|
|
const snap = this.getStateSnapshot();
|
|
container.innerHTML = `<pre class="debug-pre debug-state-pre">${this._esc(JSON.stringify(snap, null, 2))}</pre>`;
|
|
},
|
|
|
|
_esc(s) {
|
|
if (!s) return '';
|
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
},
|
|
|
|
// ── Export ───────────────────────────────
|
|
|
|
exportText() {
|
|
let text = `=== Chat Switchboard Debug Export ===\n`;
|
|
text += `Exported: ${new Date().toISOString()}\n`;
|
|
text += `URL: ${location.href}\n`;
|
|
text += `UA: ${navigator.userAgent}\n\n`;
|
|
|
|
text += `--- STATE ---\n`;
|
|
text += JSON.stringify(this.getStateSnapshot(), null, 2) + '\n\n';
|
|
|
|
text += `--- CONSOLE LOG (${this.entries.length}) ---\n`;
|
|
for (const e of this.entries) {
|
|
text += `[${e.ts}] [${e.type}] ${e.message}\n`;
|
|
}
|
|
text += '\n';
|
|
|
|
text += `--- NETWORK LOG (${this.networkLog.length}) ---\n`;
|
|
for (const r of this.networkLog) {
|
|
text += `[${r.ts}] ${r.method} ${r.url} → ${r.status || 'FAILED'} ${r.error || ''} (${r.duration || '?'}ms)\n`;
|
|
if (r.responsePreview) text += ` Response: ${r.responsePreview.slice(0, 200)}\n`;
|
|
}
|
|
|
|
return text;
|
|
}
|
|
};
|
|
|
|
// ── Modal Controls ──────────────────────────
|
|
|
|
function openDebugModal() {
|
|
DebugLog.render();
|
|
openModal('debugModal');
|
|
}
|
|
|
|
function closeDebugModal() {
|
|
closeModal('debugModal');
|
|
}
|
|
|
|
function switchDebugTab(tab) {
|
|
document.querySelectorAll('.debug-tab').forEach(t => t.classList.remove('active'));
|
|
document.querySelectorAll('.debug-tab-content').forEach(c => c.style.display = 'none');
|
|
document.querySelector(`.debug-tab[data-tab="${tab}"]`)?.classList.add('active');
|
|
const panel = document.getElementById(`debug${tab.charAt(0).toUpperCase() + tab.slice(1)}Tab`);
|
|
if (panel) panel.style.display = '';
|
|
DebugLog.render();
|
|
}
|
|
|
|
function clearDebugLog() {
|
|
if (confirm('Clear all debug logs?')) {
|
|
DebugLog.clear();
|
|
DebugLog.render();
|
|
}
|
|
}
|
|
|
|
function copyDebugLog() {
|
|
const text = DebugLog.exportText();
|
|
navigator.clipboard.writeText(text)
|
|
.then(() => { if (typeof showToast === 'function') showToast('📋 Debug log copied', 'success'); })
|
|
.catch(() => { alert('Failed to copy'); });
|
|
}
|
|
|
|
function exportDebugLog() {
|
|
const text = DebugLog.exportText();
|
|
const blob = new Blob([text], { type: 'text/plain' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `switchboard-debug-${new Date().toISOString().replace(/[:.]/g, '-')}.txt`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
function runDebugDiagnostics() {
|
|
switchDebugTab('console');
|
|
DebugLog.runDiagnostics();
|
|
}
|
|
|
|
// Initialize ASAP — before app.js init() so we capture everything
|
|
DebugLog.init();
|