Data Backup
Export or import your user data (memories, presets, settings, skills, preferences) as a JSON file.
diff --git a/static/js/admin.js b/static/js/admin.js
index 82b90b737..2c4288b40 100644
--- a/static/js/admin.js
+++ b/static/js/admin.js
@@ -2488,12 +2488,206 @@ function initDangerZone() {
});
}
+/* ═══════════════════════════════════════════
+ TERMINAL LOGS VIEWER
+ ═══════════════════════════════════════════ */
+let logsPollInterval = null;
+let isLogsPolling = false;
+let cachedLogs = [];
+let logsAbortController = null;
+
+function renderLogs(isAutoPoll = false) {
+ const consoleContainer = el('log-console-container');
+ const levelSelect = el('log-level-select');
+ const searchInput = el('log-search-input');
+
+ if (!consoleContainer) return;
+
+ const levelFilter = levelSelect ? levelSelect.value : 'ALL';
+ const searchQuery = searchInput ? searchInput.value.trim().toLowerCase() : '';
+
+ let logs = cachedLogs;
+
+ // Filter by level locally
+ if (levelFilter !== 'ALL') {
+ logs = logs.filter(line => line.includes(` - ${levelFilter} - `));
+ }
+
+ // Filter by search query locally
+ if (searchQuery) {
+ logs = logs.filter(line => line.toLowerCase().includes(searchQuery));
+ }
+
+ if (logs.length === 0) {
+ consoleContainer.innerHTML = '
No logs found matching current filters.
';
+ return;
+ }
+
+ // Preserve scroll position if user is reading previous logs
+ const atBottom = consoleContainer.scrollHeight - consoleContainer.scrollTop - consoleContainer.clientHeight < 40;
+
+ consoleContainer.innerHTML = logs.map(line => {
+ let levelClass = 'log-line-default';
+
+ if (line.includes(' - INFO - ')) {
+ levelClass = 'log-line-info';
+ } else if (line.includes(' - WARNING - ')) {
+ levelClass = 'log-line-warning';
+ } else if (line.includes(' - ERROR - ') || line.includes(' - CRITICAL - ')) {
+ levelClass = 'log-line-error';
+ } else if (line.includes(' - DEBUG - ')) {
+ levelClass = 'log-line-debug';
+ }
+
+ // XSS safe escape
+ const escaped = line
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+
+ return `
${escaped}
`;
+ }).join('');
+
+ if (!isAutoPoll || atBottom) {
+ consoleContainer.scrollTop = consoleContainer.scrollHeight;
+ }
+}
+
+async function loadLogs(isAutoPoll = false) {
+ const consoleContainer = el('log-console-container');
+ const limitSelect = el('log-limit-select');
+
+ if (!consoleContainer) return;
+
+ const limit = limitSelect ? limitSelect.value : 200;
+
+ if (logsAbortController) {
+ logsAbortController.abort();
+ }
+ logsAbortController = new AbortController();
+ const { signal } = logsAbortController;
+
+ try {
+ const res = await fetch(`/api/diagnostics/logs?limit=${limit}`, {
+ credentials: 'same-origin',
+ signal
+ });
+
+ if (!res.ok) {
+ if (!isAutoPoll) {
+ consoleContainer.innerHTML = '';
+ const errDiv = document.createElement('div');
+ errDiv.style.color = 'var(--red)';
+ errDiv.style.fontWeight = '600';
+ errDiv.textContent = `Failed to load logs: HTTP ${res.status}`;
+ consoleContainer.appendChild(errDiv);
+ }
+ return;
+ }
+
+ const data = await res.json();
+ if (data.status !== 'success' || !data.logs) {
+ if (!isAutoPoll) {
+ consoleContainer.innerHTML = '';
+ const errDiv = document.createElement('div');
+ errDiv.style.color = 'var(--red)';
+ errDiv.style.fontWeight = '600';
+ errDiv.textContent = 'Failed to parse logs data';
+ consoleContainer.appendChild(errDiv);
+ }
+ return;
+ }
+
+ cachedLogs = data.logs;
+ renderLogs(isAutoPoll);
+ } catch (err) {
+ if (err.name === 'AbortError') {
+ return; // Silently ignore deliberate abort
+ }
+ if (!isAutoPoll) {
+ consoleContainer.innerHTML = '';
+ const errDiv = document.createElement('div');
+ errDiv.style.color = 'var(--red)';
+ errDiv.style.fontWeight = '600';
+ errDiv.textContent = `Error retrieving logs: ${err.message}`;
+ consoleContainer.appendChild(errDiv);
+ }
+ } finally {
+ if (logsAbortController?.signal === signal) {
+ logsAbortController = null;
+ }
+ }
+}
+
+function startLogsPolling() {
+ if (isLogsPolling) return;
+ isLogsPolling = true;
+ const toggle = el('log-auto-refresh-toggle');
+ if (toggle) toggle.checked = true;
+
+ logsPollInterval = setInterval(() => {
+ const modal = el('settings-modal');
+ const systemPanel = el('settings-modal')?.querySelector('[data-settings-panel="system"]');
+
+ // Safe self-cleanup if modal or panel is hidden/closed
+ if (!modal || modal.classList.contains('hidden') || !systemPanel || systemPanel.classList.contains('hidden')) {
+ stopLogsPolling();
+ return;
+ }
+
+ loadLogs(true);
+ }, 3000);
+}
+
+function stopLogsPolling() {
+ if (!isLogsPolling) return;
+ isLogsPolling = false;
+ if (logsPollInterval) {
+ clearInterval(logsPollInterval);
+ logsPollInterval = null;
+ }
+ const toggle = el('log-auto-refresh-toggle');
+ if (toggle) toggle.checked = false;
+}
+
+function initLogsView() {
+ const refreshBtn = el('log-refresh-btn');
+ const levelSelect = el('log-level-select');
+ const limitSelect = el('log-limit-select');
+ const searchInput = el('log-search-input');
+ const autoRefreshToggle = el('log-auto-refresh-toggle');
+
+ if (refreshBtn) refreshBtn.addEventListener('click', () => loadLogs(false));
+ if (levelSelect) levelSelect.addEventListener('change', () => renderLogs(false));
+ if (limitSelect) limitSelect.addEventListener('change', () => loadLogs(false));
+ if (searchInput) searchInput.addEventListener('input', () => renderLogs(false));
+
+ if (autoRefreshToggle) {
+ autoRefreshToggle.addEventListener('change', (e) => {
+ if (e.target.checked) {
+ startLogsPolling();
+ } else {
+ stopLogsPolling();
+ }
+ });
+ }
+
+ // Initial fetch on view loading
+ loadLogs(false);
+}
+
/* ═══════════════════════════════════════════
INIT & REFRESH
═══════════════════════════════════════════ */
function initAll() {
modalEl = el('settings-modal');
- const inits = [initSignupToggle, initAddUser, initEndpointForm, initMcpForm, initCalDAV, initBackup, initDangerZone, initTokenForm, () => settingsModule.initIntegrations()];
+ const inits = [
+ initSignupToggle, initAddUser, initEndpointForm, initMcpForm,
+ initCalDAV, initBackup, initDangerZone, initTokenForm, initLogsView,
+ () => settingsModule.initIntegrations()
+ ];
for (const fn of inits) {
try { fn(); } catch (e) { console.error('Admin init error in', fn.name || 'anonymous', e); }
}
@@ -2507,6 +2701,7 @@ function refreshAll() {
loadBuiltinTools();
loadMcpServers();
loadTokens();
+ loadLogs(false);
}
/* ═══════════════════════════════════════════
@@ -2523,6 +2718,7 @@ export function open(tab) {
}
export function close() {
+ stopLogsPolling();
settingsModule.close();
}
diff --git a/static/style.css b/static/style.css
index 39d21021d..599ef1dca 100644
--- a/static/style.css
+++ b/static/style.css
@@ -36678,3 +36678,102 @@ body.theme-frosted .modal {
border-top: 1px solid var(--border);
}
.workspace-note { margin: 0 0 8px; font-size: 11px; line-height: 1.4; }
+
+/* Real-time Diagnostics Log Terminal UI Styles */
+.settings-system-logs-svg {
+ vertical-align: -2px;
+ margin-right: 5px;
+ opacity: 0.6;
+}
+.settings-system-logs-toggle-sub {
+ margin-bottom: 12px;
+}
+.settings-system-logs-col {
+ gap: 10px;
+}
+.settings-system-logs-controls {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+ align-items: center;
+}
+.settings-system-logs-search {
+ padding: 6px 8px;
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ color: var(--fg);
+ font-family: inherit;
+ font-size: 11px;
+ flex: 1;
+ min-width: 140px;
+}
+.settings-system-logs-select {
+ padding: 5px 8px;
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ color: var(--fg);
+ font-family: inherit;
+ font-size: 11px;
+ min-width: 90px;
+}
+#log-refresh-btn {
+ height: 27px;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 0 8px;
+}
+.settings-system-logs-refresh-svg {
+ pointer-events: none;
+}
+.settings-system-logs-autopoll-container {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 11px;
+ user-select: none;
+ margin-left: auto;
+}
+#log-console-container {
+ background: #13151a;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 12px;
+ font-family: Consolas, 'Fira Code', Monaco, 'Courier New', monospace;
+ font-size: 11px;
+ height: 280px;
+ max-height: 280px;
+ overflow-y: auto;
+ white-space: pre-wrap;
+ word-break: break-all;
+ color: #d1d4e0;
+ box-shadow: inset 0 2px 8px rgba(0,0,0,0.5);
+}
+.settings-system-logs-placeholder {
+ color: var(--color-text-dim, #7f8c8d);
+ font-style: italic;
+ font-family: inherit;
+}
+.log-line {
+ margin-bottom: 3px;
+ line-height: 1.4;
+ font-size: 11px;
+ font-family: inherit;
+}
+.log-line-info {
+ color: var(--green, #50fa7b);
+}
+.log-line-warning {
+ color: var(--warn, #f0ad4e);
+}
+.log-line-error {
+ color: var(--red, #e06c75);
+}
+.log-line-debug {
+ color: var(--color-muted, #888);
+}
+.log-line-default {
+ color: var(--fg, #9cdef2);
+}
diff --git a/tests/test_diagnostics_logs.py b/tests/test_diagnostics_logs.py
new file mode 100644
index 000000000..ac8f66af5
--- /dev/null
+++ b/tests/test_diagnostics_logs.py
@@ -0,0 +1,110 @@
+"""Route-level regression tests for GET /api/diagnostics/logs."""
+
+import pytest
+
+fastapi = pytest.importorskip("fastapi")
+pytest.importorskip("starlette.testclient")
+
+from fastapi import FastAPI, HTTPException, Request
+from starlette.testclient import TestClient
+
+# Importing the route module pulls a few app deps; skip cleanly if unavailable.
+diag = pytest.importorskip("routes.diagnostics_routes")
+
+
+def _client_with_admin_gate(monkeypatch, gate, tmp_path=None):
+ """Mount the diagnostics router with a mock require_admin and DATA_DIR."""
+ monkeypatch.setattr(diag, "require_admin", gate)
+ if tmp_path:
+ monkeypatch.setattr(diag, "DATA_DIR", str(tmp_path))
+
+ app = FastAPI()
+ app.include_router(diag.setup_diagnostics_routes(
+ rag_manager=None, rag_available=False, research_handler=None,
+ memory_vector=None))
+ return TestClient(app, raise_server_exceptions=False)
+
+
+def test_logs_unauthenticated_rejected(monkeypatch):
+ def gate(_request: Request):
+ raise HTTPException(401, "Not authenticated")
+ client = _client_with_admin_gate(monkeypatch, gate)
+ r = client.get("/api/diagnostics/logs")
+ assert r.status_code == 401
+
+
+def test_logs_non_admin_forbidden(monkeypatch):
+ def gate(_request: Request):
+ raise HTTPException(403, "Admin only")
+ client = _client_with_admin_gate(monkeypatch, gate)
+ r = client.get("/api/diagnostics/logs")
+ assert r.status_code == 403
+
+
+def test_logs_missing_file(monkeypatch, tmp_path):
+ def gate(_request: Request):
+ return None
+ client = _client_with_admin_gate(monkeypatch, gate, tmp_path)
+ r = client.get("/api/diagnostics/logs")
+ assert r.status_code == 200
+ body = r.json()
+ assert body["status"] == "success"
+ assert body["logs"] == []
+
+
+def test_logs_tailing_and_clamping(monkeypatch, tmp_path):
+ # Setup mock log file
+ log_dir = tmp_path / "logs"
+ log_dir.mkdir(parents=True, exist_ok=True)
+ log_file = log_dir / "app.log"
+
+ # Write 1500 log lines
+ lines = [f"Log line {i}\n" for i in range(1, 1501)]
+ log_file.write_text("".join(lines), encoding="utf-8")
+
+ def gate(_request: Request):
+ return None
+ client = _client_with_admin_gate(monkeypatch, gate, tmp_path)
+
+ # 1. Default limit (200)
+ r = client.get("/api/diagnostics/logs")
+ assert r.status_code == 200
+ body = r.json()
+ assert len(body["logs"]) == 200
+ assert body["logs"][-1] == "Log line 1500"
+ assert body["logs"][0] == "Log line 1301"
+
+ # 2. Clamped upper bound (limit=2000 -> clamps to 1000)
+ r = client.get("/api/diagnostics/logs?limit=2000")
+ assert r.status_code == 200
+ body = r.json()
+ assert len(body["logs"]) == 1000
+ assert body["logs"][-1] == "Log line 1500"
+ assert body["logs"][0] == "Log line 501"
+
+ # 3. Clamped lower bound (limit=-5 -> clamps to 1)
+ r = client.get("/api/diagnostics/logs?limit=-5")
+ assert r.status_code == 200
+ body = r.json()
+ assert len(body["logs"]) == 1
+ assert body["logs"][0] == "Log line 1500"
+
+ # 4. Clamp limit=0 -> clamps to 1
+ r = client.get("/api/diagnostics/logs?limit=0")
+ assert r.status_code == 200
+ body = r.json()
+ assert len(body["logs"]) == 1
+ assert body["logs"][0] == "Log line 1500"
+
+ # 5. Exact custom limit
+ r = client.get("/api/diagnostics/logs?limit=5")
+ assert r.status_code == 200
+ body = r.json()
+ assert len(body["logs"]) == 5
+ assert body["logs"] == [
+ "Log line 1496",
+ "Log line 1497",
+ "Log line 1498",
+ "Log line 1499",
+ "Log line 1500"
+ ]