Changeset 0.4.1 (#33)
This commit is contained in:
548
src/js/debug.js
Normal file
548
src/js/debug.js
Normal file
@@ -0,0 +1,548 @@
|
||||
// ==========================================
|
||||
// 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 = {};
|
||||
|
||||
// Backend state (redact tokens)
|
||||
if (typeof Backend !== 'undefined') {
|
||||
snap.backend = {
|
||||
baseUrl: Backend.baseUrl || '(empty)',
|
||||
hasAccessToken: !!Backend.accessToken,
|
||||
hasRefreshToken: !!Backend.refreshToken,
|
||||
user: Backend.user ? {
|
||||
username: Backend.user.username,
|
||||
role: Backend.user.role,
|
||||
display_name: Backend.user.display_name
|
||||
} : null,
|
||||
isConnected: Backend.isConnected,
|
||||
isManaged: Backend.isManaged
|
||||
};
|
||||
}
|
||||
|
||||
// App state
|
||||
if (typeof State !== 'undefined') {
|
||||
snap.state = {
|
||||
chatCount: State.chats?.length || 0,
|
||||
currentChatId: State.currentChatId,
|
||||
modelCount: State.models?.length || 0,
|
||||
isGenerating: State.isGenerating,
|
||||
settings: {
|
||||
model: State.settings?.model || '?',
|
||||
stream: State.settings?.stream,
|
||||
apiEndpoint: State.settings?.apiEndpoint ? '(set)' : '(empty)',
|
||||
apiKey: State.settings?.apiKey ? '(set)' : '(empty)',
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// localStorage keys
|
||||
try {
|
||||
snap.storageKeys = Object.keys(localStorage).filter(k =>
|
||||
k.startsWith('chatSwitchboard') || k.startsWith('switchboard')
|
||||
);
|
||||
} catch (e) {
|
||||
snap.storageKeys = '(error reading)';
|
||||
}
|
||||
|
||||
return snap;
|
||||
},
|
||||
|
||||
// ── Connection Diagnostics ──────────────
|
||||
|
||||
async runDiagnostics() {
|
||||
this.log('DIAG', '── Starting connection diagnostics ──');
|
||||
|
||||
const baseUrl = (typeof Backend !== 'undefined' && Backend.baseUrl)
|
||||
? Backend.baseUrl
|
||||
: 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 Backend !== 'undefined' && Backend.accessToken) {
|
||||
this.log('DIAG', 'Test 3: Token validation');
|
||||
try {
|
||||
const resp = await this._origFetch(baseUrl + '/api/v1/profile', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${Backend.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)');
|
||||
}
|
||||
|
||||
this.log('DIAG', '── Diagnostics complete ──');
|
||||
this.render();
|
||||
},
|
||||
|
||||
// ── Badge ───────────────────────────────
|
||||
|
||||
_injectBadge() {
|
||||
const badge = document.createElement('div');
|
||||
badge.id = 'debugBadge';
|
||||
badge.innerHTML = '🐛';
|
||||
badge.title = 'Debug Log (Ctrl+Shift+D)';
|
||||
badge.style.cssText = `
|
||||
position: fixed; bottom: 12px; right: 12px; z-index: 100000;
|
||||
width: 36px; height: 36px; border-radius: 50%;
|
||||
background: var(--bg-secondary, #2a2a2a); border: 1px solid var(--border, #444);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
cursor: pointer; font-size: 18px; opacity: 0.5;
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
`;
|
||||
badge.addEventListener('mouseenter', () => { badge.style.opacity = '1'; badge.style.transform = 'scale(1.1)'; });
|
||||
badge.addEventListener('mouseleave', () => { badge.style.opacity = this._errorCount > 0 ? '0.9' : '0.5'; badge.style.transform = ''; });
|
||||
badge.addEventListener('click', () => openDebugModal());
|
||||
document.body.appendChild(badge);
|
||||
|
||||
// Keyboard shortcut
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.ctrlKey && e.shiftKey && e.key === 'D') {
|
||||
e.preventDefault();
|
||||
openDebugModal();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
_updateBadge() {
|
||||
const badge = document.getElementById('debugBadge');
|
||||
if (!badge) return;
|
||||
if (this._errorCount > 0) {
|
||||
badge.style.opacity = '0.9';
|
||||
badge.style.background = 'var(--danger, #c0392b)';
|
||||
badge.innerHTML = `🐛<span style="position:absolute;top:-4px;right:-4px;background:#e74c3c;color:#fff;font-size:10px;border-radius:50%;width:16px;height:16px;display:flex;align-items:center;justify-content:center;">${this._errorCount > 99 ? '99+' : this._errorCount}</span>`;
|
||||
badge.style.position = 'fixed'; // keep relative positioning for the counter
|
||||
}
|
||||
},
|
||||
|
||||
// ── 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();
|
||||
document.getElementById('debugModal').classList.add('active');
|
||||
}
|
||||
|
||||
function closeDebugModal() {
|
||||
document.getElementById('debugModal').classList.remove('active');
|
||||
}
|
||||
|
||||
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');
|
||||
document.getElementById(`debug${tab.charAt(0).toUpperCase() + tab.slice(1)}Tab`)?.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();
|
||||
Reference in New Issue
Block a user