From 6bef90741676cbdd6278dc7b710ad060f23c4685 Mon Sep 17 00:00:00 2001 From: orangecoding Date: Tue, 9 Jun 2026 15:42:25 +0200 Subject: [PATCH] adding ability to record logs for debug purposes --- .github/ISSUE_TEMPLATE/bug.yml | 47 ++- README.md | 44 +++ index.js | 7 + lib/api/api.js | 12 + lib/api/routes/debugRouter.js | 93 +++++ lib/services/debug/debugBundleService.js | 263 +++++++++++++ lib/services/debug/debugLogStorage.js | 346 ++++++++++++++++++ lib/services/logger.js | 75 +++- .../migrations/sql/20.add-debug-logs.js | 32 ++ package.json | 2 +- .../services/debug/debugBundleService.test.js | 129 +++++++ test/services/debug/debugLogStorage.test.js | 278 ++++++++++++++ test/services/debug/debugRouter.test.js | 250 +++++++++++++ test/services/logger.test.js | 89 +++++ ui/src/App.jsx | 2 + .../components/debug/DebugLoggingBanner.jsx | 58 +++ ui/src/locales/de.json | 31 ++ ui/src/locales/en.json | 31 ++ ui/src/services/debugLoggingClient.js | 124 +++++++ .../views/generalSettings/GeneralSettings.jsx | 323 +++++++++++++++- 20 files changed, 2229 insertions(+), 7 deletions(-) create mode 100644 lib/api/routes/debugRouter.js create mode 100644 lib/services/debug/debugBundleService.js create mode 100644 lib/services/debug/debugLogStorage.js create mode 100644 lib/services/storage/migrations/sql/20.add-debug-logs.js create mode 100644 test/services/debug/debugBundleService.test.js create mode 100644 test/services/debug/debugLogStorage.test.js create mode 100644 test/services/debug/debugRouter.test.js create mode 100644 test/services/logger.test.js create mode 100644 ui/src/components/debug/DebugLoggingBanner.jsx create mode 100644 ui/src/services/debugLoggingClient.js diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 17b65d3..0f170f8 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -5,6 +5,40 @@ labels: [bug] assignees: [] body: + - type: markdown + attributes: + value: | + ## Please attach a debug bundle (available since Fredy 22.5.0+) + + Since **Fredy 22.5.0** you can export a debug bundle that contains a system + snapshot (`sys.txt`, Fredy version, Node.js version, OS, Docker detection, + CPU, memory, sanitized settings) and the full log buffer (`logs.txt`) that + Fredy recorded while you reproduced the issue. Attaching it dramatically + speeds up triage. + Oh and before you ask: I decided against simply putting all logs into the debug + due to privacy reasons :) + + **The bundle is only useful when the error is actually inside `logs.txt`.** + That means you have to record first, reproduce after: + + 1. Log in to Fredy as **admin** and open **Settings → Debug**. + 2. Click **"Enable debug logging" / "Debug-Logging aktivieren"**. A red banner + appears across the whole app while recording is on. + 3. **Now reproduce the bug.** Trigger the broken job, click the failing + button, wait for the failing scrape — whatever it was. + 4. Come back to **Settings → Debug** and confirm the progress bar moved + (i.e. log entries were actually written). If it stayed at 0%, nothing was + captured and the bundle won't help us. + 5. Click **"Download debug information" / "Debug Informationen herunterladen"** + and drop the resulting `FredyDebug-*.zip` into the "Screenshots / Logs" + field below. + 6. Optional but recommended: click **"Disable debug logging"** to stop the + recording, and **"Delete stored debug logs"** once you have the zip so the + database does not keep them around. + + On Fredy versions older than 22.5.0, paste the relevant log lines from your + console / Docker / systemd journal manually instead. + - type: textarea id: description attributes: @@ -49,8 +83,11 @@ body: id: screenshots attributes: label: Screenshots / Logs - description: Add screenshots or paste log output to help explain the problem. - placeholder: "Drag and drop screenshots here, or paste logs." + description: | + Drop the FredyDebug-*.zip here (see the instructions at the top, available + since Fredy 22.5.0) and/or any additional screenshots. If you cannot produce + the bundle, paste relevant log lines instead. + placeholder: "Drag and drop the FredyDebug-*.zip and any screenshots here." validations: required: false @@ -58,8 +95,10 @@ body: id: environment attributes: label: Environment - description: Provide details about your environment. - placeholder: "OS: macOS 15, Browser: Chrome 124, App version: 1.2.3" + description: | + Provide details about your environment. You can copy most of this from + sys.txt inside the debug bundle. + placeholder: "OS: macOS 15, Browser: Chrome 124, App version: 22.5.0, Docker: yes" validations: required: true diff --git a/README.md b/README.md index a027373..aede1af 100755 --- a/README.md +++ b/README.md @@ -210,6 +210,50 @@ The data includes: names of active adapters/providers, OS, architecture, Node ve **Thanks**🤘 +## 🐞 Debug Information + +Since Fredy **22.5.0** there is a built-in way to capture everything Fredy logs into the +database for a limited time and download it as a single zip file. This is the recommended +way to attach diagnostics to a bug report. I decided against simply putting all logs into +a debug bundle due to privacy reasons! + +**How it works** + +- Debug logging is **opt-in** and admin-only. As long as it is off, Fredy behaves exactly + as before (console output only, nothing in the DB). +- When you turn it on, every log line (`debug`, `info`, `warn`, `error`) is additionally + written into the `debug_logs` SQLite table. The console keeps logging at its usual level. +- The recorded data is hard-capped at **5 MiB** via a rolling buffer: once the cap is hit, + the oldest entries are dropped automatically so the newest ones always survive. +- The on/off flag is persisted, so debug logging stays on across restarts (and you'll see + the warning banner everywhere until you turn it off again). + +**Capturing a debug bundle** + +1. Open Fredy as an **admin** and go to **Settings → Debug**. +2. Click **"Enable debug logging" / "Debug-Logging aktivieren"**. A red banner appears on + every page while recording is on. +3. **Reproduce the bug**. +4. Come back to **Settings → Debug** and check the progress bar, if it stayed at 0 %, + nothing was captured. +5. Click **"Download debug information" / "Debug Informationen herunterladen"**. You get a + zip named `YYYY-MM-DD-FredyDebug-.zip` containing two files: + - `logs.txt` - every log line captured while recording was on, prefixed with timestamp + and level. + - `sys.txt` - runtime snapshot (Fredy version, Node.js version, OS, Docker detection, + CPU, memory, sanitized settings). Proxy credentials and session secrets are + **stripped** before export. +6. Attach the zip to the bug report. +7. Optional but recommended: click **"Disable debug logging"** to stop recording, and + **"Delete stored debug logs"** once you've sent the zip so the DB does not keep them + around. + +**What is _not_ included** + +- passwords/privacy relevant things +- Anything that Fredy itself does not pass through its `logger`. If a third-party library + writes directly to `process.stderr`, that output stays on the console only. + ## 🛠️ Development ### Development Mode diff --git a/index.js b/index.js index 86fc999..0d2b1d6 100755 --- a/index.js +++ b/index.js @@ -10,6 +10,7 @@ import { runMigrations } from './lib/services/storage/migrations/migrate.js'; import { ensureDemoUserExists, ensureAdminUserExists } from './lib/services/storage/userStorage.js'; import { initTrackerCron } from './lib/services/crons/tracker-cron.js'; import logger from './lib/services/logger.js'; +import { reloadEnabledFromSettings } from './lib/services/debug/debugLogStorage.js'; import { initActiveCheckerCron } from './lib/services/crons/listing-alive-cron.js'; import { initGeocodingCron } from './lib/services/crons/geocoding-cron.js'; import { getSettings } from './lib/services/storage/settingsStorage.js'; @@ -42,6 +43,12 @@ await runMigrations(); const settings = await getSettings(); +// Restore the persisted on/off flag for opt-in DB log capture so it survives a +// Fredy restart. reloadEnabledFromSettings() also (un)wires the logger sink based +// on the restored flag, so the logger hot path stays cost-free when nobody enabled +// the feature. +await reloadEnabledFromSettings(); + // Ensure the sqlite directory exists before loading anything else (based on config.sqlitepath) const { dir: sqliteDir } = await computeDbPath(); if (!fs.existsSync(sqliteDir)) { diff --git a/lib/api/api.js b/lib/api/api.js index 265e0a5..a7234a0 100644 --- a/lib/api/api.js +++ b/lib/api/api.js @@ -24,6 +24,7 @@ import userSettingsPlugin from './routes/userSettingsRoute.js'; import trackingPlugin from './routes/trackingRoute.js'; import generalSettingsPlugin from './routes/generalSettingsRoute.js'; import backupPlugin from './routes/backupRouter.js'; +import debugPlugin, { registerDebugPublicProbe } from './routes/debugRouter.js'; import userPlugin from './routes/userRoute.js'; import notificationAdapterPlugin from './routes/notificationAdapterRouter.js'; import providerPlugin from './routes/providerRouter.js'; @@ -77,6 +78,16 @@ fastify.register(async (app) => { app.register(userSettingsPlugin, { prefix: '/api/user/settings' }); app.register(trackingPlugin, { prefix: '/api/tracking' }); app.register(generalSettingsPlugin, { prefix: '/api/admin/generalSettings' }); + // The lightweight /api/debug/active probe used by the app-wide red banner. Lives + // here (under authHook, NOT adminHook) so non-admin users also see the warning + // banner when an admin has enabled the feature, without exposing the rest of the + // settings payload. + app.register( + async (sub) => { + registerDebugPublicProbe(sub); + }, + { prefix: '/api/debug' }, + ); }); // Admin-only routes @@ -84,6 +95,7 @@ fastify.register(async (app) => { app.addHook('preHandler', authHook); app.addHook('preHandler', adminHook); app.register(backupPlugin, { prefix: '/api/admin/backup' }); + app.register(debugPlugin, { prefix: '/api/admin/debug' }); app.register(userPlugin, { prefix: '/api/admin/users' }); }); diff --git a/lib/api/routes/debugRouter.js b/lib/api/routes/debugRouter.js new file mode 100644 index 0000000..939bcf9 --- /dev/null +++ b/lib/api/routes/debugRouter.js @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +import { + isEnabled, + enableDebugLogging, + disableDebugLogging, + getCurrentSize, + getMaxSize, + hasAnyLogs, + wasEverEnabled, + clearAllDebugLogs, +} from '../../services/debug/debugLogStorage.js'; +import { buildDebugBundleFileName, buildDebugBundleZip } from '../../services/debug/debugBundleService.js'; +import { getSettings } from '../../services/storage/settingsStorage.js'; + +/** + * Build the JSON status payload returned by /status and after each enable/disable. + * @returns {Promise<{enabled:boolean, size:number, max:number, hasLogs:boolean, everEnabled:boolean}>} + */ +async function buildStatus() { + return { + enabled: isEnabled(), + size: await getCurrentSize(), + max: getMaxSize(), + hasLogs: hasAnyLogs(), + everEnabled: await wasEverEnabled(), + }; +} + +/** + * Register the lightweight /active probe used by the app-wide red banner. Exposed + * to every authenticated user (not just admins) so non-admin users see the warning + * banner too. Returns only a single boolean so it cannot be repurposed to leak any + * other state. + * + * @param {import('fastify').FastifyInstance} fastify + */ +export async function registerDebugPublicProbe(fastify) { + fastify.get('/active', async () => ({ enabled: isEnabled() })); +} + +/** + * Admin-only debug logging endpoints. + * + * Routes (all relative to the registered prefix /api/admin/debug): + * GET /status → current feature status (used by the UI polling). + * POST /enable → turn debug logging on. Body: { clearPrevious?:boolean }. + * POST /disable → turn debug logging off (existing logs are kept on disk). + * GET /download → ZIP with logs.txt + sys.txt. 409 when the feature has + * never been enabled OR there are no logs to export. + * DELETE /logs → drop every stored debug log row (does NOT change the + * enabled flag — useful to free space while keeping + * recording on). + * + * @param {import('fastify').FastifyInstance} fastify + */ +export default async function debugPlugin(fastify) { + fastify.get('/status', async () => buildStatus()); + + fastify.post('/enable', async (request) => { + const clearPrevious = request.body?.clearPrevious === true; + await enableDebugLogging({ clearPrevious }); + return buildStatus(); + }); + + fastify.post('/disable', async () => { + await disableDebugLogging(); + return buildStatus(); + }); + + fastify.delete('/logs', async () => { + clearAllDebugLogs(); + return buildStatus(); + }); + + fastify.get('/download', async (request, reply) => { + const ever = await wasEverEnabled(); + if (!ever || !hasAnyLogs()) { + return reply.code(409).send({ + error: 'Debug logging has never produced any data on this Fredy installation.', + }); + } + const settings = await getSettings(); + const zipBuffer = await buildDebugBundleZip({ settings }); + const fileName = await buildDebugBundleFileName(); + reply.header('Content-Type', 'application/zip'); + reply.header('Content-Disposition', `attachment; filename="${fileName}"`); + return reply.send(zipBuffer); + }); +} diff --git a/lib/services/debug/debugBundleService.js b/lib/services/debug/debugBundleService.js new file mode 100644 index 0000000..4eb6ab2 --- /dev/null +++ b/lib/services/debug/debugBundleService.js @@ -0,0 +1,263 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +import fs from 'fs'; +import os from 'os'; +import { getAllDebugLogs } from './debugLogStorage.js'; +import { getPackageVersion } from '../../utils.js'; + +const LOGS_FILE_NAME = 'logs.txt'; +const SYSTEM_INFO_FILE_NAME = 'sys.txt'; +const DEBUG_FILE_PREFIX = 'FredyDebug-'; + +/** + * Lazily resolve AdmZip via dynamic import so tests can swap it via globalThis. + * Mirrors the pattern used by backupRestoreService.js for consistency. + * @returns {Promise} + */ +let _AdmZipSingleton = null; +async function getAdmZip() { + if (_AdmZipSingleton) return _AdmZipSingleton; + if (globalThis && globalThis.__TEST_ADM_ZIP__) { + _AdmZipSingleton = globalThis.__TEST_ADM_ZIP__; + return _AdmZipSingleton; + } + const mod = await import('adm-zip'); + _AdmZipSingleton = (mod && mod.default) || mod; + return _AdmZipSingleton; +} + +/** + * Format a Date as YYYY-MM-DD using local time. Used for the download filename. + * @param {Date} date + * @returns {string} + */ +function formatDateOnly(date) { + const yyyy = date.getFullYear(); + const mm = String(date.getMonth() + 1).padStart(2, '0'); + const dd = String(date.getDate()).padStart(2, '0'); + return `${yyyy}-${mm}-${dd}`; +} + +/** + * Build the debug bundle filename, e.g. "2026-06-08-FredyDebug-22.5.0.zip". + * @returns {Promise} + */ +export async function buildDebugBundleFileName() { + const version = await getPackageVersion(); + return `${formatDateOnly(new Date())}-${DEBUG_FILE_PREFIX}${version}.zip`; +} + +/** + * Format a stored debug_logs row into a single text line. The format mirrors the + * console layout from logger.js so support staff sees familiar output: + * [YYYY-MM-DD HH:MM:SS] LEVEL: message + * + * @param {{ts:number, level:string, message:string}} row + * @returns {string} + */ +function formatLogLine(row) { + const d = new Date(row.ts); + const yyyy = d.getFullYear(); + const mm = String(d.getMonth() + 1).padStart(2, '0'); + const dd = String(d.getDate()).padStart(2, '0'); + const hh = String(d.getHours()).padStart(2, '0'); + const mi = String(d.getMinutes()).padStart(2, '0'); + const ss = String(d.getSeconds()).padStart(2, '0'); + const level = String(row.level || 'info').toUpperCase(); + return `[${yyyy}-${mm}-${dd} ${hh}:${mi}:${ss}] ${level}: ${row.message}`; +} + +/** + * Render every stored debug log row into a single newline-delimited text blob. + * Returns an empty string when there are no rows. + * + * @returns {string} + */ +export function renderLogsTxt() { + const rows = getAllDebugLogs(); + if (!rows || rows.length === 0) return ''; + return rows.map(formatLogLine).join('\n') + '\n'; +} + +/** + * Best-effort Docker detection. Used as a context hint in sys.txt so issue triage + * knows whether the user runs the official container image. + * + * @returns {{inDocker:boolean, evidence:string[]}} + */ +function detectDocker() { + const evidence = []; + let inDocker = false; + + if (process.env.FREDY_IN_DOCKER === 'true' || process.env.FREDY_IN_DOCKER === '1') { + inDocker = true; + evidence.push('FREDY_IN_DOCKER env var is set'); + } + try { + if (fs.existsSync('/.dockerenv')) { + inDocker = true; + evidence.push('/.dockerenv exists'); + } + } catch { + // ignore + } + try { + if (fs.existsSync('/proc/1/cgroup')) { + const cgroup = fs.readFileSync('/proc/1/cgroup', 'utf-8'); + if (/docker|containerd|kubepods/i.test(cgroup)) { + inDocker = true; + evidence.push('/proc/1/cgroup mentions a container runtime'); + } + } + } catch { + // ignore + } + return { inDocker, evidence }; +} + +/** + * Strip credentials from URL-like strings so they can safely appear in sys.txt. + * Returns the input unchanged for non-URL values. + * @param {string} value + * @returns {string} + */ +function sanitizeUrlLike(value) { + if (typeof value !== 'string' || value.length === 0) return value; + try { + const u = new URL(value); + if (u.username || u.password) { + u.username = '***'; + u.password = '***'; + } + return u.toString(); + } catch { + return value; + } +} + +function formatBytes(bytes) { + if (!Number.isFinite(bytes)) return String(bytes); + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KiB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MiB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GiB`; +} + +function formatDuration(seconds) { + if (!Number.isFinite(seconds)) return String(seconds); + const s = Math.floor(seconds); + const days = Math.floor(s / 86400); + const hours = Math.floor((s % 86400) / 3600); + const minutes = Math.floor((s % 3600) / 60); + const secs = s % 60; + return `${days}d ${hours}h ${minutes}m ${secs}s`; +} + +/** + * Build a plaintext system / runtime report for inclusion in the debug zip. Settings + * are sanitized, proxy URL credentials and session secrets are stripped before + * serialization. + * + * @param {object} [options] + * @param {object} [options.settings] + * @returns {Promise} + */ +export async function buildSystemInfo({ settings = null } = {}) { + const fredyVersion = await getPackageVersion(); + const docker = detectDocker(); + const cpus = os.cpus() || []; + const procMem = process.memoryUsage(); + + const lines = []; + lines.push('# Fredy Debug Report'); + lines.push(`Generated at: ${new Date().toISOString()}`); + lines.push(''); + + lines.push('## Application'); + lines.push(`Fredy version: ${fredyVersion}`); + lines.push(`Node.js version: ${process.version}`); + lines.push(`Process uptime: ${formatDuration(process.uptime())}`); + lines.push(`PID: ${process.pid}`); + lines.push(`Env (NODE_ENV): ${process.env.NODE_ENV || 'development'}`); + lines.push(''); + + lines.push('## Operating System'); + lines.push(`Platform: ${process.platform}`); + lines.push(`Architecture: ${process.arch}`); + lines.push(`OS type: ${os.type()}`); + lines.push(`OS release: ${os.release()}`); + lines.push(`OS version: ${typeof os.version === 'function' ? os.version() : 'n/a'}`); + lines.push(`Hostname: ${os.hostname()}`); + lines.push(`System uptime: ${formatDuration(os.uptime())}`); + lines.push(''); + + lines.push('## Container'); + lines.push(`Running in Docker: ${docker.inDocker ? 'yes' : 'no'}`); + if (docker.evidence.length > 0) { + lines.push(`Evidence: ${docker.evidence.join('; ')}`); + } + if (process.env.FREDY_IMAGE_TAG) { + lines.push(`Image tag: ${process.env.FREDY_IMAGE_TAG}`); + } + lines.push(''); + + lines.push('## Hardware'); + lines.push(`CPU count: ${cpus.length}`); + lines.push(`CPU model: ${cpus[0]?.model || 'unknown'}`); + lines.push(`Total memory: ${formatBytes(os.totalmem())}`); + lines.push(`Free memory: ${formatBytes(os.freemem())}`); + lines.push(`Process RSS: ${formatBytes(procMem.rss)}`); + lines.push(`Process heapUsed: ${formatBytes(procMem.heapUsed)}`); + lines.push(`Process heapTotal: ${formatBytes(procMem.heapTotal)}`); + lines.push(''); + + if (settings && typeof settings === 'object') { + lines.push('## Settings (sanitized)'); + const safe = { ...settings }; + if (safe.proxyUrl) safe.proxyUrl = sanitizeUrlLike(safe.proxyUrl); + delete safe.session_secret; + delete safe.sessionSecret; + for (const [key, value] of Object.entries(safe)) { + let printed; + if (value == null) { + printed = 'null'; + } else if (typeof value === 'object') { + try { + printed = JSON.stringify(value); + } catch { + printed = String(value); + } + } else { + printed = String(value); + } + lines.push(`${key}: ${printed}`); + } + lines.push(''); + } + + return lines.join('\n'); +} + +/** + * Build the final debug bundle zip buffer (logs.txt + sys.txt). The caller is + * responsible for checking wasEverEnabled() before invoking this, we still produce + * a valid zip even when there are zero log rows (logs.txt will contain a placeholder) + * because the route layer handles the user-friendly 409 case. + * + * @param {object} [options] + * @param {object} [options.settings] Runtime settings to embed in sys.txt. + * @returns {Promise} + */ +export async function buildDebugBundleZip({ settings = null } = {}) { + const logsContent = renderLogsTxt() || 'No debug log entries are currently stored.\n'; + const sysContent = await buildSystemInfo({ settings }); + + const AdmZip = await getAdmZip(); + const zip = new AdmZip(); + zip.addFile(LOGS_FILE_NAME, Buffer.from(logsContent, 'utf-8')); + zip.addFile(SYSTEM_INFO_FILE_NAME, Buffer.from(sysContent, 'utf-8')); + return zip.toBuffer(); +} diff --git a/lib/services/debug/debugLogStorage.js b/lib/services/debug/debugLogStorage.js new file mode 100644 index 0000000..4d3cb8b --- /dev/null +++ b/lib/services/debug/debugLogStorage.js @@ -0,0 +1,346 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +import SqliteConnection from '../storage/SqliteConnection.js'; +import { upsertSettings, getSettings } from '../storage/settingsStorage.js'; +import logger from '../logger.js'; + +/** + * Hard cap on the total UTF-8 byte length of stored log MESSAGES (5 MiB). + * + * Note: this measures the payload bytes (message strings only); SQLite per-row + * overhead (id, ts, level, byte_size columns + page housekeeping) means the actual + * sqlite_master page count for debug_logs can be larger than this cap by a constant + * factor. The cap is intentionally about user-visible payload to keep the math + * predictable and to align with what ends up in logs.txt. + * + * The cap is enforced via a rolling buffer: when the live size exceeds it, the + * oldest rows are removed until the size falls below the limit again. + * @type {number} + */ +export const MAX_DEBUG_LOG_BYTES = 5 * 1024 * 1024; + +/** Settings key persisting the active on/off flag. */ +const SETTING_ENABLED = 'debug_logging_enabled'; + +/** + * Settings key persisting "this feature has been turned on at least once". Used to + * decide whether the download endpoint returns 409 (never enabled) or whether the + * "delete previous logs?" confirm dialog should be shown on (re)enable. + */ +const SETTING_EVER_ENABLED = 'debug_logging_ever_enabled'; + +/** + * Cached live byte size of all rows in debug_logs. Initialized lazily from the DB on + * the first call and kept in sync by append / clear / trim. Storing this in-memory + * avoids running SUM() on every single insert (logger writes can be very frequent). + * @type {number|null} + */ +let cachedSize = null; + +/** + * Cached value of debug_logging_enabled. Reflects DB state; flipped by enable() / + * disable() so the logger hot-path does not have to hit the settings cache for every + * log line. + * @type {boolean|null} + */ +let cachedEnabled = null; + +/** + * Compute the UTF-8 byte length of a string. Falls back to character count for + * environments where Buffer is not available (vitest covers Node, so it always is). + * @param {string} str + * @returns {number} + */ +function byteLengthOf(str) { + if (typeof str !== 'string') return 0; + if (typeof Buffer !== 'undefined' && typeof Buffer.byteLength === 'function') { + return Buffer.byteLength(str, 'utf-8'); + } + return str.length; +} + +/** + * Read the current total byte size from the DB and update the local cache. + * @returns {number} + */ +function refreshSizeFromDb() { + const rows = SqliteConnection.query('SELECT COALESCE(SUM(byte_size), 0) AS total FROM debug_logs'); + cachedSize = Number(rows?.[0]?.total ?? 0); + return cachedSize; +} + +/** + * Lazily ensure the cached enabled/size values are up to date. Called by every public + * method that needs to know either value, so external init is not required. + * @returns {Promise} + */ +async function ensureCachesInitialized() { + if (cachedEnabled == null) { + const settings = await getSettings(); + cachedEnabled = settings[SETTING_ENABLED] === true; + } + if (cachedSize == null) { + refreshSizeFromDb(); + } +} + +/** + * Cached prepared statements used by trimToFit(). Initialized on first use so we do + * not pay the prepare cost on every overflow event, and skipped entirely when the + * feature is never activated. + * @type {{select:any, del:any}|null} + */ +let trimStatements = null; + +/** + * Drop the oldest rows from debug_logs until the cached size falls below + * MAX_DEBUG_LOG_BYTES. Implements the rolling buffer behavior chosen for the feature. + * + * The deletion is performed in batches of up to 100 oldest rows wrapped in a single + * transaction. The size cache is updated only after the transaction commits, so a + * mid-batch failure (rolled back by SQLite) cannot leave cachedSize out of sync with + * the on-disk reality. A defensive resync via SUM() is performed on transaction + * failure to recover from any unexpected drift. + * + * @returns {void} + */ +function trimToFit() { + if (cachedSize == null || cachedSize <= MAX_DEBUG_LOG_BYTES) return; + + const db = SqliteConnection.getConnection(); + if (trimStatements == null) { + trimStatements = { + select: db.prepare('SELECT id, byte_size FROM debug_logs ORDER BY id ASC LIMIT 100'), + del: db.prepare('DELETE FROM debug_logs WHERE id = @id'), + }; + } + + while (cachedSize > MAX_DEBUG_LOG_BYTES) { + const oldest = trimStatements.select.all(); + if (oldest.length === 0) { + // Table is empty but the cache still claims we are over the cap. That can only + // happen if cachedSize drifted (e.g. external DB modification, zero-byte + // messages that never contributed to SUM(byte_size), or a previous trim that + // partially succeeded). Resync from the source of truth and bail out. + refreshSizeFromDb(); + break; + } + + // Pick exactly enough oldest rows to bring the cache back under the cap. We do + // NOT delete the entire 100-row batch unconditionally, that would over-trim in + // edge cases where just one or two rows are enough. + const needToFree = cachedSize - MAX_DEBUG_LOG_BYTES; + let freed = 0; + const idsToDelete = []; + for (const row of oldest) { + idsToDelete.push(row.id); + freed += Number(row.byte_size) || 0; + if (freed >= needToFree) break; + } + + try { + const tx = db.transaction((ids) => { + for (const id of ids) { + trimStatements.del.run({ id }); + } + }); + tx(idsToDelete); + // Only decrement after the transaction has committed; a mid-batch failure + // would roll the DELETEs back and leave cachedSize untouched. + cachedSize -= freed; + if (freed === 0) { + // We deleted rows but they all had byte_size <= 0, so cachedSize did not + // move. Without intervention the outer loop would spin again with the same + // condition. Resync from the DB and bail to prevent that. + refreshSizeFromDb(); + break; + } + } catch { + // SQLite rolled the batch back; resync cachedSize from the DB to recover from + // any unexpected drift, then bail out so we do not spin forever on a persistent + // failure (e.g. database is locked or read-only). + refreshSizeFromDb(); + break; + } + } + if (cachedSize < 0) cachedSize = 0; +} + +/** + * Whether debug logging is currently enabled. Synchronous and cheap so the logger + * hot-path can call it on every log line. + * + * @returns {boolean} True if logs should be persisted to the debug_logs table. + */ +export function isEnabled() { + return cachedEnabled === true; +} + +/** + * Append a single log entry to debug_logs (if enabled) and trim the rolling buffer if + * the new row pushes the live size above the cap. + * + * Safe to call even when logging is disabled, it becomes a no-op. Any storage error + * is swallowed so the logger never breaks the calling code; bookkeeping for cachedSize + * stays consistent because we update it only after a successful insert. + * + * @param {{ts:number, level:string, message:string}} entry + * @returns {void} + */ +export function appendLogEntry(entry) { + if (!isEnabled()) return; + if (!entry || typeof entry.message !== 'string') return; + + try { + const ts = Number.isFinite(entry.ts) ? entry.ts : Date.now(); + const level = String(entry.level || 'info'); + const message = entry.message; + const byte_size = byteLengthOf(message); + + SqliteConnection.execute( + 'INSERT INTO debug_logs (ts, level, message, byte_size) VALUES (@ts, @level, @message, @byte_size)', + { ts, level, message, byte_size }, + ); + + if (cachedSize == null) { + refreshSizeFromDb(); + } else { + cachedSize += byte_size; + } + trimToFit(); + } catch { + // Logging must never break the application. Swallow storage errors silently. + } +} + +/** + * Remove every row from debug_logs and reset the cached size to zero. Used by both + * the "clear previous logs" path on (re)enable and by explicit clear actions. + * + * @returns {void} + */ +export function clearAllDebugLogs() { + SqliteConnection.execute('DELETE FROM debug_logs'); + cachedSize = 0; +} + +/** + * Return the cached live byte size of the debug_logs table contents. + * @returns {Promise} + */ +export async function getCurrentSize() { + await ensureCachesInitialized(); + return cachedSize ?? 0; +} + +/** + * Return the configured maximum size for the debug_logs table. + * @returns {number} + */ +export function getMaxSize() { + return MAX_DEBUG_LOG_BYTES; +} + +/** + * Check whether the debug_logs table contains at least one row. + * @returns {boolean} + */ +export function hasAnyLogs() { + const row = SqliteConnection.query('SELECT 1 AS one FROM debug_logs LIMIT 1'); + return Array.isArray(row) && row.length > 0; +} + +/** + * Has debug logging ever been enabled in this installation? Used by the download + * endpoint to distinguish "no logs yet" (empty table) from "feature never used" + * (which returns 409 to surface a friendlier UI error). + * + * @returns {Promise} + */ +export async function wasEverEnabled() { + const settings = await getSettings(); + return settings[SETTING_EVER_ENABLED] === true; +} + +/** + * Turn debug logging on. Persists both the active flag and the "ever enabled" flag, + * optionally clearing previous logs when the caller passes clearPrevious=true (this + * is the path taken when the UI confirm dialog "Delete previous logs?" is accepted). + * + * @param {object} [options] + * @param {boolean} [options.clearPrevious=false] + * @returns {Promise} + */ +export async function enableDebugLogging({ clearPrevious = false } = {}) { + if (clearPrevious) { + clearAllDebugLogs(); + } + upsertSettings({ [SETTING_ENABLED]: true, [SETTING_EVER_ENABLED]: true }); + cachedEnabled = true; + if (cachedSize == null) { + refreshSizeFromDb(); + } + // Attach the logger sink only while recording is on so the logger hot path pays + // no per-call cost (Date.now + stringifyArgs) when nobody enabled the feature. + logger.setDebugLogSink(appendLogEntry); +} + +/** + * Turn debug logging off. Previous logs are kept on disk so the user can still + * download them; they are only cleared when the user re-enables and chooses "delete + * previous logs". + * + * @returns {Promise} + */ +export async function disableDebugLogging() { + upsertSettings({ [SETTING_ENABLED]: false }); + cachedEnabled = false; + // Detach the sink so the logger hot path returns immediately on its `if (sink)` + // check instead of paying the no-op cost on every log line. + logger.setDebugLogSink(null); +} + +/** + * Return all stored log entries ordered chronologically. Used by the bundle builder + * when assembling logs.txt. + * + * @returns {{id:number, ts:number, level:string, message:string}[]} + */ +export function getAllDebugLogs() { + return SqliteConnection.query('SELECT id, ts, level, message FROM debug_logs ORDER BY id ASC'); +} + +/** + * Reload the cached enabled flag from settings storage. Called from the logger at + * startup so the cache reflects the persisted state after a Fredy restart. + * + * @returns {Promise} The active enabled flag. + */ +export async function reloadEnabledFromSettings() { + const settings = await getSettings(); + cachedEnabled = settings[SETTING_ENABLED] === true; + // (Un)wire the sink to match the persisted state. Note: startup work that runs + // before index.js calls this (CloakBrowser binary check, runMigrations) still + // logs to stdout only, since the sink is not attached yet at that point. + if (cachedEnabled) { + logger.setDebugLogSink(appendLogEntry); + } else { + logger.setDebugLogSink(null); + } + return cachedEnabled; +} + +/** + * Test-only helper to drop in-memory caches between unit tests. Resets every piece + * of module-scoped mutable state so a test that swaps the underlying DB does not + * inherit stale prepared statements from a previous run. + * @returns {void} + */ +export function _resetForTests() { + cachedSize = null; + cachedEnabled = null; + trimStatements = null; +} diff --git a/lib/services/logger.js b/lib/services/logger.js index 8152e96..cc041d7 100644 --- a/lib/services/logger.js +++ b/lib/services/logger.js @@ -14,6 +14,20 @@ const COLORS = { const env = process.env.NODE_ENV || 'development'; const useColor = process.stdout.isTTY || process.stderr.isTTY; +/** + * Optional sink that forwards formatted log entries to the opt-in "Debug Logging" + * DB storage. Wired and unwired by debugLogStorage as the feature is toggled, so + * when nobody enabled the feature this stays null and the logger hot path skips + * the Date.now + stringifyArgs work entirely. + * + * We deliberately do NOT import debugLogStorage here, because that would create a + * cycle (debugLogStorage → SqliteConnection → utils → logger → debugLogStorage). + * Inversion of control via setDebugLogSink() keeps the dependency one-way. + * + * @type {((entry:{ts:number, level:string, message:string}) => void)|null} + */ +let debugLogSink = null; + function ts() { const d = new Date(); const yyyy = d.getFullYear(); @@ -31,10 +45,50 @@ function lvl(level) { return `${COLORS[level] || ''}${upper}${COLORS.reset}`; } +/** + * Build a colour-free plain text representation of variadic console args. Errors + * are unwrapped to their stack/message, objects are JSON-serialized. Used when + * forwarding to the DB sink so the stored text is portable across terminals. + * + * @param {any[]} args + * @returns {string} + */ +function stringifyArgs(args) { + return args + .map((a) => { + if (a == null) return String(a); + if (a instanceof Error) return a.stack || a.message; + if (typeof a === 'object') { + try { + return JSON.stringify(a); + } catch { + return String(a); + } + } + return String(a); + }) + .join(' '); +} + /* eslint-disable no-console */ function log(level, ...args) { + // Forward to the DB sink first (regardless of console suppression rules) so the + // recorded debug bundle truly contains every level, including debug entries that + // would otherwise be silenced in production. + if (debugLogSink) { + try { + debugLogSink({ + ts: Date.now(), + level, + message: `${stringifyArgs(args)}`, + }); + } catch { + // never break the caller because of logging + } + } + if (level === 'debug' && env !== 'development') { - return; // Skip debug logs in non-development environments + return; // Skip debug logs in non-development environments (console only) } const prefix = `[${ts()}] ${lvl(level)}:`; @@ -56,9 +110,28 @@ function log(level, ...args) { } } +/** + * Register a sink function that receives every log entry the logger sees, regardless + * of console suppression rules. debugLogStorage attaches its sink only while the + * feature is enabled and detaches it on disable, so the logger's hot path can use + * the null check as a cheap on/off gate and skip stringification when off. + * + * Pass null to remove the sink (used both by the storage module on disable and by + * tests to reset state between cases). + * + * @param {((entry:{ts:number, level:string, message:string}) => void)|null} sink + * @returns {void} + */ +function setDebugLogSink(sink) { + debugLogSink = typeof sink === 'function' ? sink : null; +} + +export { setDebugLogSink }; + export default { debug: (...a) => log('debug', ...a), info: (...a) => log('info', ...a), warn: (...a) => log('warn', ...a), error: (...a) => log('error', ...a), + setDebugLogSink, }; diff --git a/lib/services/storage/migrations/sql/20.add-debug-logs.js b/lib/services/storage/migrations/sql/20.add-debug-logs.js new file mode 100644 index 0000000..8c59498 --- /dev/null +++ b/lib/services/storage/migrations/sql/20.add-debug-logs.js @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +/** + * Migration: create the debug_logs table used by the opt-in "Debug Logging" feature. + * + * Each row is a single log line (timestamp + level + message) captured by the in-app + * logger while debug logging is enabled. We store the UTF-8 byte size of the message + * alongside the row so the debugLogStorage can maintain a rolling 5 MB cap without + * having to run length() / SUM() on every insert. + * + * The "debug_logging_enabled" and "debug_logging_ever_enabled" flags are persisted in + * the existing settings table (no schema change needed there) and are managed by + * debugLogStorage.js at runtime. + */ +export function up(db) { + // id is INTEGER PRIMARY KEY AUTOINCREMENT, which is an alias for SQLite's rowid and + // is implicitly indexed. No additional index needed; selecting / deleting by id and + // ordering by id ASC (rolling buffer) both use the existing rowid index. + db.exec(` + CREATE TABLE IF NOT EXISTS debug_logs + ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + level TEXT NOT NULL, + message TEXT NOT NULL, + byte_size INTEGER NOT NULL + ); + `); +} diff --git a/package.json b/package.json index 7b6512d..dc83c97 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fredy", - "version": "22.4.0", + "version": "22.5.0", "description": "[F]ind [R]eal [E]states [d]amn eas[y].", "scripts": { "prepare": "husky", diff --git a/test/services/debug/debugBundleService.test.js b/test/services/debug/debugBundleService.test.js new file mode 100644 index 0000000..0565c86 --- /dev/null +++ b/test/services/debug/debugBundleService.test.js @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import path from 'node:path'; + +describe('services/debug/debugBundleService.js', () => { + let svc; + let storedLogs; + let addedZipEntries; + + beforeEach(async () => { + storedLogs = []; + addedZipEntries = []; + + /** + * Minimal AdmZip stand-in that records the in-memory entry names + payloads so we + * can assert what made it into the bundle without spinning up real zip parsing. + */ + class MockAdmZip { + constructor() { + this.entries = []; + } + addFile(name, buf) { + this.entries.push({ entryName: name, data: buf }); + addedZipEntries.push({ entryName: name, content: buf.toString('utf-8') }); + } + toBuffer() { + return Buffer.from(JSON.stringify(this.entries.map((e) => e.entryName))); + } + } + globalThis.__TEST_ADM_ZIP__ = MockAdmZip; + + const ROOT = path.resolve('.'); + const storagePath = path.join(ROOT, 'lib', 'services', 'debug', 'debugLogStorage.js'); + const utilsPath = path.join(ROOT, 'lib', 'utils.js'); + + const storageMock = { + getAllDebugLogs: () => storedLogs, + }; + const utilsMock = { getPackageVersion: async () => '22.5.0' }; + + vi.resetModules(); + vi.doMock(storagePath, () => storageMock); + vi.doMock(utilsPath, () => utilsMock); + + svc = await import(path.join(ROOT, 'lib', 'services', 'debug', 'debugBundleService.js')); + }); + + afterEach(() => { + delete globalThis.__TEST_ADM_ZIP__; + }); + + describe('renderLogsTxt', () => { + it('returns an empty string when there are no rows', () => { + expect(svc.renderLogsTxt()).toBe(''); + }); + + it('formats each row as [date] LEVEL: message and keeps order', () => { + storedLogs.push({ id: 1, ts: 1717855200000, level: 'info', message: 'first line' }); + storedLogs.push({ id: 2, ts: 1717855201000, level: 'warn', message: 'second line' }); + + const out = svc.renderLogsTxt(); + + expect(out).toMatch(/\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\] INFO: first line/); + expect(out).toMatch(/\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\] WARN: second line/); + expect(out.indexOf('first line')).toBeLessThan(out.indexOf('second line')); + expect(out.endsWith('\n')).toBe(true); + }); + }); + + describe('buildSystemInfo', () => { + it('contains Fredy version, Node version and OS platform', async () => { + const sys = await svc.buildSystemInfo({ settings: null }); + expect(sys).toMatch(/Fredy version:\s+22\.5\.0/); + expect(sys).toContain(`Node.js version: ${process.version}`); + expect(sys).toContain(`Platform: ${process.platform}`); + }); + + it('redacts proxy URL credentials', async () => { + const sys = await svc.buildSystemInfo({ + settings: { proxyUrl: 'http://secret:hunter2@proxy.example:8080', port: 9998 }, + }); + expect(sys).not.toContain('hunter2'); + expect(sys).not.toContain('secret'); + expect(sys).toContain('proxy.example'); + expect(sys).toContain('port: 9998'); + }); + + it('strips session secrets from sanitized settings output', async () => { + const sys = await svc.buildSystemInfo({ + settings: { session_secret: 'top-secret', sessionSecret: 'other-secret', port: 9998 }, + }); + expect(sys).not.toContain('top-secret'); + expect(sys).not.toContain('other-secret'); + }); + }); + + describe('buildDebugBundleFileName', () => { + it('matches YYYY-MM-DD-FredyDebug-.zip', async () => { + const name = await svc.buildDebugBundleFileName(); + expect(name).toMatch(/^\d{4}-\d{2}-\d{2}-FredyDebug-22\.5\.0\.zip$/); + }); + }); + + describe('buildDebugBundleZip', () => { + it('always emits both logs.txt and sys.txt entries', async () => { + storedLogs.push({ id: 1, ts: 1717855200000, level: 'info', message: 'recorded line' }); + await svc.buildDebugBundleZip({ settings: { port: 9998 } }); + + const names = addedZipEntries.map((e) => e.entryName).sort(); + expect(names).toEqual(['logs.txt', 'sys.txt']); + + const logs = addedZipEntries.find((e) => e.entryName === 'logs.txt'); + const sys = addedZipEntries.find((e) => e.entryName === 'sys.txt'); + expect(logs.content).toContain('recorded line'); + expect(sys.content).toMatch(/Fredy version:\s+22\.5\.0/); + expect(sys.content).toContain('port: 9998'); + }); + + it('includes a placeholder message when no logs are stored', async () => { + await svc.buildDebugBundleZip({ settings: null }); + const logs = addedZipEntries.find((e) => e.entryName === 'logs.txt'); + expect(logs.content).toMatch(/no debug log entries/i); + }); + }); +}); diff --git a/test/services/debug/debugLogStorage.test.js b/test/services/debug/debugLogStorage.test.js new file mode 100644 index 0000000..b0d3011 --- /dev/null +++ b/test/services/debug/debugLogStorage.test.js @@ -0,0 +1,278 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import path from 'node:path'; +import Database from 'better-sqlite3'; + +/** + * Wire up an in-memory better-sqlite3 instance plus a stubbed settings module so the + * storage module under test can exercise real SQL while the rest of the dependency + * graph stays inert. + */ +async function bootstrap() { + const db = new Database(':memory:'); + db.exec(` + CREATE TABLE debug_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + level TEXT NOT NULL, + message TEXT NOT NULL, + byte_size INTEGER NOT NULL + ); + `); + + const settings = { debug_logging_enabled: false, debug_logging_ever_enabled: false }; + + const ROOT = path.resolve('.'); + const sqlitePath = path.join(ROOT, 'lib', 'services', 'storage', 'SqliteConnection.js'); + const settingsPath = path.join(ROOT, 'lib', 'services', 'storage', 'settingsStorage.js'); + + const sqliteMock = { + default: { + getConnection: () => db, + execute: (sql, params = {}) => db.prepare(sql).run(params), + query: (sql, params = {}) => db.prepare(sql).all(params), + }, + }; + + const settingsMock = { + getSettings: async () => ({ ...settings }), + upsertSettings: (entries) => { + const map = Array.isArray(entries) ? Object.fromEntries(entries) : entries; + for (const [k, v] of Object.entries(map)) { + settings[k] = v; + } + }, + }; + + vi.resetModules(); + vi.doMock(sqlitePath, () => sqliteMock); + vi.doMock(settingsPath, () => settingsMock); + + const storage = await import(path.join(ROOT, 'lib', 'services', 'debug', 'debugLogStorage.js')); + storage._resetForTests(); + return { storage, db, settings }; +} + +describe('services/debug/debugLogStorage.js', () => { + let ctx; + + beforeEach(async () => { + ctx = await bootstrap(); + }); + + afterEach(() => { + try { + ctx.db.close(); + } catch { + // ignore + } + }); + + it('isEnabled is false before enableDebugLogging is called', async () => { + expect(ctx.storage.isEnabled()).toBe(false); + }); + + it('enableDebugLogging flips the cached flag and persists ever-enabled', async () => { + await ctx.storage.enableDebugLogging(); + expect(ctx.storage.isEnabled()).toBe(true); + expect(ctx.settings.debug_logging_enabled).toBe(true); + expect(ctx.settings.debug_logging_ever_enabled).toBe(true); + }); + + it('reloadEnabledFromSettings picks up persisted state after restart', async () => { + ctx.settings.debug_logging_enabled = true; + const enabled = await ctx.storage.reloadEnabledFromSettings(); + expect(enabled).toBe(true); + expect(ctx.storage.isEnabled()).toBe(true); + }); + + it('appendLogEntry writes only while enabled', async () => { + ctx.storage.appendLogEntry({ ts: 1, level: 'info', message: 'before-enable' }); + expect(ctx.db.prepare('SELECT COUNT(*) AS c FROM debug_logs').get().c).toBe(0); + + await ctx.storage.enableDebugLogging(); + ctx.storage.appendLogEntry({ ts: 2, level: 'warn', message: 'after-enable' }); + expect(ctx.db.prepare('SELECT COUNT(*) AS c FROM debug_logs').get().c).toBe(1); + + const row = ctx.db.prepare('SELECT level, message, byte_size FROM debug_logs').get(); + expect(row.level).toBe('warn'); + expect(row.message).toBe('after-enable'); + expect(row.byte_size).toBe(Buffer.byteLength('after-enable', 'utf-8')); + }); + + it('disableDebugLogging stops writes but keeps existing rows', async () => { + await ctx.storage.enableDebugLogging(); + ctx.storage.appendLogEntry({ ts: 1, level: 'info', message: 'keep-me' }); + await ctx.storage.disableDebugLogging(); + ctx.storage.appendLogEntry({ ts: 2, level: 'info', message: 'never-written' }); + + const rows = ctx.db.prepare('SELECT message FROM debug_logs ORDER BY id').all(); + expect(rows).toHaveLength(1); + expect(rows[0].message).toBe('keep-me'); + }); + + it('enableDebugLogging clears previous logs only when asked', async () => { + await ctx.storage.enableDebugLogging(); + ctx.storage.appendLogEntry({ ts: 1, level: 'info', message: 'pre-existing' }); + await ctx.storage.disableDebugLogging(); + + await ctx.storage.enableDebugLogging({ clearPrevious: false }); + expect(ctx.db.prepare('SELECT COUNT(*) AS c FROM debug_logs').get().c).toBe(1); + + await ctx.storage.disableDebugLogging(); + await ctx.storage.enableDebugLogging({ clearPrevious: true }); + expect(ctx.db.prepare('SELECT COUNT(*) AS c FROM debug_logs').get().c).toBe(0); + }); + + it('hasAnyLogs / wasEverEnabled report correctly', async () => { + expect(ctx.storage.hasAnyLogs()).toBe(false); + expect(await ctx.storage.wasEverEnabled()).toBe(false); + + await ctx.storage.enableDebugLogging(); + ctx.storage.appendLogEntry({ ts: 1, level: 'info', message: 'hi' }); + expect(ctx.storage.hasAnyLogs()).toBe(true); + expect(await ctx.storage.wasEverEnabled()).toBe(true); + }); + + it('getCurrentSize reflects the on-disk byte total', async () => { + await ctx.storage.enableDebugLogging(); + ctx.storage.appendLogEntry({ ts: 1, level: 'info', message: 'hello' }); // 5 bytes + ctx.storage.appendLogEntry({ ts: 2, level: 'info', message: 'world!' }); // 6 bytes + expect(await ctx.storage.getCurrentSize()).toBe(11); + }); + + it('rolling buffer drops oldest rows once the cap is exceeded', async () => { + await ctx.storage.enableDebugLogging(); + const cap = ctx.storage.getMaxSize(); + + // Insert one row whose payload exceeds the entire cap. trimToFit must drop the + // oldest row(s) until the live size falls back under the cap. With a single + // oversized row, the only outcome is "table empty". + const giantText = 'X'.repeat(cap + 1024); + ctx.storage.appendLogEntry({ ts: 10, level: 'info', message: giantText }); + + const remaining = await ctx.storage.getCurrentSize(); + expect(remaining).toBeLessThanOrEqual(cap); + expect(remaining).toBe(0); + expect(ctx.db.prepare('SELECT COUNT(*) AS c FROM debug_logs').get().c).toBe(0); + }); + + it('rolling buffer keeps newer rows when only the oldest need to go', async () => { + await ctx.storage.enableDebugLogging(); + const cap = ctx.storage.getMaxSize(); + + // Push the size just over the cap with one big row, then a smaller "newer" row + // that should survive the trim because it is not at the head of the queue. + const bigText = 'A'.repeat(cap - 10); // ~5 MiB - 10 bytes + ctx.storage.appendLogEntry({ ts: 1, level: 'info', message: bigText }); + + // At this point we are just under the cap. Pushing one more row will tip us over. + ctx.storage.appendLogEntry({ ts: 2, level: 'warn', message: 'tip-over message which keeps us above cap' }); + + const remainingRows = ctx.db.prepare('SELECT message FROM debug_logs ORDER BY id ASC').all(); + // The oldest (big) row must be gone; the newer one survives. + expect(remainingRows).toHaveLength(1); + expect(remainingRows[0].message).toContain('tip-over'); + + const remainingSize = await ctx.storage.getCurrentSize(); + expect(remainingSize).toBeLessThanOrEqual(cap); + // And the cache must match what SQLite reports, verifies no drift after trim. + const dbSize = ctx.db.prepare('SELECT COALESCE(SUM(byte_size),0) AS s FROM debug_logs').get().s; + expect(remainingSize).toBe(dbSize); + }); + + it('cachedSize stays consistent across enable → append → disable → re-enable(clear) cycles', async () => { + await ctx.storage.enableDebugLogging(); + ctx.storage.appendLogEntry({ ts: 1, level: 'info', message: 'one' }); + ctx.storage.appendLogEntry({ ts: 2, level: 'info', message: 'two' }); + const sizeAfterFirst = await ctx.storage.getCurrentSize(); + + await ctx.storage.disableDebugLogging(); + expect(await ctx.storage.getCurrentSize()).toBe(sizeAfterFirst); + + await ctx.storage.enableDebugLogging({ clearPrevious: true }); + expect(await ctx.storage.getCurrentSize()).toBe(0); + + ctx.storage.appendLogEntry({ ts: 3, level: 'info', message: 'fresh' }); + const dbSize = ctx.db.prepare('SELECT COALESCE(SUM(byte_size),0) AS s FROM debug_logs').get().s; + expect(await ctx.storage.getCurrentSize()).toBe(dbSize); + }); + + it('clearAllDebugLogs empties the table and resets cached size', async () => { + await ctx.storage.enableDebugLogging(); + ctx.storage.appendLogEntry({ ts: 1, level: 'info', message: 'foo' }); + ctx.storage.appendLogEntry({ ts: 2, level: 'info', message: 'bar' }); + expect(await ctx.storage.getCurrentSize()).toBeGreaterThan(0); + + ctx.storage.clearAllDebugLogs(); + + expect(ctx.db.prepare('SELECT COUNT(*) AS c FROM debug_logs').get().c).toBe(0); + expect(await ctx.storage.getCurrentSize()).toBe(0); + }); + + it('getAllDebugLogs returns rows ordered chronologically', async () => { + await ctx.storage.enableDebugLogging(); + ctx.storage.appendLogEntry({ ts: 1, level: 'info', message: 'first' }); + ctx.storage.appendLogEntry({ ts: 2, level: 'warn', message: 'second' }); + ctx.storage.appendLogEntry({ ts: 3, level: 'error', message: 'third' }); + + const rows = ctx.storage.getAllDebugLogs(); + expect(rows.map((r) => r.message)).toEqual(['first', 'second', 'third']); + expect(rows.map((r) => r.level)).toEqual(['info', 'warn', 'error']); + }); + + describe('logger sink wiring', () => { + let logger; + let consoleSpies; + + beforeEach(async () => { + // Storage imports the same logger module; vi.resetModules() ensured both share + // the same fresh instance for this test. Spies silence console output so the + // vitest report stays clean while we exercise real logger.info() calls. + logger = (await import(path.resolve('lib/services/logger.js'))).default; + consoleSpies = { + debug: vi.spyOn(console, 'debug').mockImplementation(() => {}), + info: vi.spyOn(console, 'info').mockImplementation(() => {}), + warn: vi.spyOn(console, 'warn').mockImplementation(() => {}), + error: vi.spyOn(console, 'error').mockImplementation(() => {}), + }; + }); + + afterEach(() => { + // Detach sink between tests to prevent cross-test pollution from the shared + // logger module instance. + logger.setDebugLogSink(null); + for (const spy of Object.values(consoleSpies)) spy.mockRestore(); + }); + + it('routes logger calls into debug_logs once enabled', async () => { + await ctx.storage.enableDebugLogging(); + logger.info('captured-via-logger'); + const rows = ctx.db.prepare('SELECT level, message FROM debug_logs').all(); + expect(rows).toHaveLength(1); + expect(rows[0].level).toBe('info'); + expect(rows[0].message).toContain('captured-via-logger'); + }); + + it('detaches the sink on disable so logger calls no longer hit the DB', async () => { + await ctx.storage.enableDebugLogging(); + await ctx.storage.disableDebugLogging(); + logger.info('not-captured'); + expect(ctx.db.prepare('SELECT COUNT(*) AS c FROM debug_logs').get().c).toBe(0); + }); + + it('restores the sink on reloadEnabledFromSettings when persisted state is on', async () => { + ctx.settings.debug_logging_enabled = true; + await ctx.storage.reloadEnabledFromSettings(); + logger.warn('captured-after-restart'); + const rows = ctx.db.prepare('SELECT level, message FROM debug_logs').all(); + expect(rows).toHaveLength(1); + expect(rows[0].level).toBe('warn'); + expect(rows[0].message).toContain('captured-after-restart'); + }); + }); +}); diff --git a/test/services/debug/debugRouter.test.js b/test/services/debug/debugRouter.test.js new file mode 100644 index 0000000..36984c9 --- /dev/null +++ b/test/services/debug/debugRouter.test.js @@ -0,0 +1,250 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import path from 'node:path'; +import Fastify from 'fastify'; + +describe('api/routes/debugRouter.js', () => { + let app; + let state; + + beforeEach(async () => { + state = { + enabled: false, + hasLogs: false, + everEnabled: false, + size: 0, + max: 5 * 1024 * 1024, + }; + + const ROOT = path.resolve('.'); + const storagePath = path.join(ROOT, 'lib', 'services', 'debug', 'debugLogStorage.js'); + const bundlePath = path.join(ROOT, 'lib', 'services', 'debug', 'debugBundleService.js'); + const settingsStoragePath = path.join(ROOT, 'lib', 'services', 'storage', 'settingsStorage.js'); + + const storageMock = { + isEnabled: () => state.enabled, + enableDebugLogging: async ({ clearPrevious = false } = {}) => { + state.enabled = true; + state.everEnabled = true; + if (clearPrevious) { + state.hasLogs = false; + state.size = 0; + } + }, + disableDebugLogging: async () => { + state.enabled = false; + }, + getCurrentSize: async () => state.size, + getMaxSize: () => state.max, + hasAnyLogs: () => state.hasLogs, + wasEverEnabled: async () => state.everEnabled, + clearAllDebugLogs: () => { + state.hasLogs = false; + state.size = 0; + }, + }; + + const bundleMock = { + buildDebugBundleFileName: async () => '2026-06-08-FredyDebug-22.5.0.zip', + buildDebugBundleZip: async () => Buffer.from('FAKEZIP'), + }; + + const settingsMock = { + getSettings: async () => ({ port: 9998 }), + }; + + vi.resetModules(); + vi.doMock(storagePath, () => storageMock); + vi.doMock(bundlePath, () => bundleMock); + vi.doMock(settingsStoragePath, () => settingsMock); + + const mod = await import(path.join(ROOT, 'lib', 'api', 'routes', 'debugRouter.js')); + const plugin = mod.default; + app = Fastify({ logger: false }); + await app.register(plugin, { prefix: '/api/admin/debug' }); + await app.register( + async (sub) => { + mod.registerDebugPublicProbe(sub); + }, + { prefix: '/api/debug' }, + ); + await app.ready(); + }); + + afterEach(async () => { + if (app) await app.close(); + }); + + it('GET /status returns the current snapshot', async () => { + const res = await app.inject({ method: 'GET', url: '/api/admin/debug/status' }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ + enabled: false, + size: 0, + max: state.max, + hasLogs: false, + everEnabled: false, + }); + }); + + it('POST /enable flips the feature on and returns updated status', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/admin/debug/enable', + payload: {}, + }); + expect(res.statusCode).toBe(200); + const json = res.json(); + expect(json.enabled).toBe(true); + expect(json.everEnabled).toBe(true); + }); + + it('POST /enable with clearPrevious=true wipes existing logs first', async () => { + state.hasLogs = true; + state.size = 1234; + state.everEnabled = true; + + const res = await app.inject({ + method: 'POST', + url: '/api/admin/debug/enable', + payload: { clearPrevious: true }, + }); + expect(res.statusCode).toBe(200); + const json = res.json(); + expect(json.enabled).toBe(true); + expect(json.hasLogs).toBe(false); + expect(json.size).toBe(0); + }); + + it('POST /disable turns the feature off without losing existing logs', async () => { + state.enabled = true; + state.hasLogs = true; + state.everEnabled = true; + state.size = 99; + + const res = await app.inject({ method: 'POST', url: '/api/admin/debug/disable' }); + expect(res.statusCode).toBe(200); + const json = res.json(); + expect(json.enabled).toBe(false); + expect(json.hasLogs).toBe(true); + expect(json.size).toBe(99); + }); + + it('GET /download returns 409 when the feature was never enabled', async () => { + const res = await app.inject({ method: 'GET', url: '/api/admin/debug/download' }); + expect(res.statusCode).toBe(409); + expect(res.json().error).toMatch(/never produced any data/i); + }); + + it('GET /download returns 409 when ever-enabled but no logs are stored', async () => { + state.everEnabled = true; + state.hasLogs = false; + const res = await app.inject({ method: 'GET', url: '/api/admin/debug/download' }); + expect(res.statusCode).toBe(409); + }); + + it('GET /download streams a zip with the expected headers when logs exist', async () => { + state.everEnabled = true; + state.hasLogs = true; + + const res = await app.inject({ method: 'GET', url: '/api/admin/debug/download' }); + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toBe('application/zip'); + expect(res.headers['content-disposition']).toContain('FredyDebug'); + expect(res.rawPayload.toString('utf-8')).toBe('FAKEZIP'); + }); + + it('DELETE /logs wipes stored logs without touching the enabled flag', async () => { + state.enabled = true; + state.hasLogs = true; + state.everEnabled = true; + state.size = 1234; + + const res = await app.inject({ method: 'DELETE', url: '/api/admin/debug/logs' }); + expect(res.statusCode).toBe(200); + const json = res.json(); + expect(json.enabled).toBe(true); + expect(json.hasLogs).toBe(false); + expect(json.size).toBe(0); + // everEnabled must stay true so the download button does not change semantics. + expect(json.everEnabled).toBe(true); + }); + + it('GET /api/debug/active returns only the enabled boolean (no other settings)', async () => { + state.enabled = false; + let res = await app.inject({ method: 'GET', url: '/api/debug/active' }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ enabled: false }); + + state.enabled = true; + res = await app.inject({ method: 'GET', url: '/api/debug/active' }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ enabled: true }); + }); +}); + +describe('api/routes/debugRouter.js - admin-only enforcement', () => { + let app; + + beforeEach(async () => { + const ROOT = path.resolve('.'); + const storagePath = path.join(ROOT, 'lib', 'services', 'debug', 'debugLogStorage.js'); + const bundlePath = path.join(ROOT, 'lib', 'services', 'debug', 'debugBundleService.js'); + const settingsStoragePath = path.join(ROOT, 'lib', 'services', 'storage', 'settingsStorage.js'); + + vi.resetModules(); + vi.doMock(storagePath, () => ({ + isEnabled: () => false, + enableDebugLogging: async () => {}, + disableDebugLogging: async () => {}, + getCurrentSize: async () => 0, + getMaxSize: () => 5 * 1024 * 1024, + hasAnyLogs: () => false, + wasEverEnabled: async () => false, + clearAllDebugLogs: () => {}, + })); + vi.doMock(bundlePath, () => ({ + buildDebugBundleFileName: async () => 'x.zip', + buildDebugBundleZip: async () => Buffer.from(''), + })); + vi.doMock(settingsStoragePath, () => ({ + getSettings: async () => ({}), + })); + + const plugin = (await import(path.join(ROOT, 'lib', 'api', 'routes', 'debugRouter.js'))).default; + app = Fastify({ logger: false }); + await app.register( + async (sub) => { + // Same wiring shape as lib/api/api.js: apply adminHook before the plugin. + sub.addHook('preHandler', async (request, reply) => { + reply.code(401).send(); + }); + sub.register(plugin, { prefix: '/api/admin/debug' }); + }, + { prefix: '/' }, + ); + await app.ready(); + }); + + afterEach(async () => { + if (app) await app.close(); + }); + + it('rejects non-admin callers with 401 on every endpoint', async () => { + for (const route of [ + ['GET', '/api/admin/debug/status'], + ['POST', '/api/admin/debug/enable'], + ['POST', '/api/admin/debug/disable'], + ['GET', '/api/admin/debug/download'], + ['DELETE', '/api/admin/debug/logs'], + ]) { + const [method, url] = route; + const res = await app.inject({ method, url, payload: method === 'POST' ? {} : undefined }); + expect(res.statusCode, `${method} ${url}`).toBe(401); + } + }); +}); diff --git a/test/services/logger.test.js b/test/services/logger.test.js new file mode 100644 index 0000000..36b9179 --- /dev/null +++ b/test/services/logger.test.js @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import path from 'node:path'; + +describe('services/logger.js - debug log sink', () => { + let logger; + let setDebugLogSink; + let consoleSpies; + + beforeEach(async () => { + vi.resetModules(); + const mod = await import(path.resolve('lib/services/logger.js')); + logger = mod.default; + setDebugLogSink = mod.setDebugLogSink; + + // Silence console output so test runner stdout stays readable while still + // letting us inspect what the logger emitted if a test wants to. + consoleSpies = { + debug: vi.spyOn(console, 'debug').mockImplementation(() => {}), + info: vi.spyOn(console, 'info').mockImplementation(() => {}), + warn: vi.spyOn(console, 'warn').mockImplementation(() => {}), + error: vi.spyOn(console, 'error').mockImplementation(() => {}), + }; + }); + + afterEach(() => { + setDebugLogSink(null); + for (const spy of Object.values(consoleSpies)) spy.mockRestore(); + }); + + it('is a no-op for the sink when none is registered', () => { + // Just make sure nothing throws. + expect(() => logger.info('hello')).not.toThrow(); + expect(() => logger.error(new Error('boom'))).not.toThrow(); + }); + + it('forwards every log level (including debug) to the registered sink', () => { + const captured = []; + setDebugLogSink((entry) => captured.push(entry)); + + logger.debug('debug-line'); + logger.info('info-line'); + logger.warn('warn-line'); + logger.error('error-line'); + + expect(captured).toHaveLength(4); + expect(captured.map((c) => c.level)).toEqual(['debug', 'info', 'warn', 'error']); + expect(captured[0].message).toContain('debug-line'); + expect(captured[1].message).toContain('info-line'); + expect(captured[2].message).toContain('warn-line'); + expect(captured[3].message).toContain('error-line'); + for (const c of captured) { + expect(typeof c.ts).toBe('number'); + } + }); + + it('serializes Error stacks for the sink instead of "[object Object]"', () => { + const captured = []; + setDebugLogSink((entry) => captured.push(entry)); + + logger.error(new Error('boom')); + + expect(captured).toHaveLength(1); + expect(captured[0].message).toContain('Error: boom'); + }); + + it('stops forwarding once the sink is unregistered', () => { + const captured = []; + setDebugLogSink((entry) => captured.push(entry)); + logger.info('one'); + setDebugLogSink(null); + logger.info('two'); + + expect(captured).toHaveLength(1); + expect(captured[0].message).toContain('one'); + }); + + it('does not break the caller when the sink throws', () => { + setDebugLogSink(() => { + throw new Error('sink exploded'); + }); + expect(() => logger.info('still works')).not.toThrow(); + expect(consoleSpies.info).toHaveBeenCalled(); + }); +}); diff --git a/ui/src/App.jsx b/ui/src/App.jsx index 982a162..8eb97ea 100644 --- a/ui/src/App.jsx +++ b/ui/src/App.jsx @@ -30,6 +30,7 @@ import Dashboard from './views/dashboard/Dashboard.jsx'; import ListingDetail from './views/listings/ListingDetail.jsx'; import NewsModal from './components/news/NewsModal.jsx'; import { I18nProvider, availableLanguages } from './services/i18n/i18n.jsx'; +import DebugLoggingBanner from './components/debug/DebugLoggingBanner.jsx'; const semiLocaleModules = import.meta.glob('/node_modules/@douyinfe/semi-ui-19/lib/es/locale/source/*.js', { eager: true, @@ -96,6 +97,7 @@ export default function FredyApp() { {versionUpdate?.newVersion && } + {settings.demoMode && ( <> { + let cancelled = false; + const tick = async () => { + try { + const res = await fetchDebugActive(); + if (!cancelled) setActive(Boolean(res?.enabled)); + } catch { + // Best-effort probe: an unauthenticated 401 (e.g. session expired) simply + // hides the banner until the next successful poll. + } + }; + tick(); + const id = window.setInterval(tick, POLL_INTERVAL_MS); + return () => { + cancelled = true; + window.clearInterval(id); + }; + }, []); + + if (!active) return null; + return ( + <> + +
+ + ); +} + +DebugLoggingBanner.displayName = 'DebugLoggingBanner'; diff --git a/ui/src/locales/de.json b/ui/src/locales/de.json index ac6295b..56cd955 100644 --- a/ui/src/locales/de.json +++ b/ui/src/locales/de.json @@ -295,6 +295,7 @@ "settings.tabExecution": "Ausführung", "settings.tabUserSettings": "Benutzereinstellungen", "settings.tabBackup": "Backup & Wiederherstellung", + "settings.tabDebug": "Debug", "settings.save": "Speichern", "settings.port": "Port", "settings.portHelp": "Der Port, auf dem Fredy läuft.", @@ -365,6 +366,36 @@ "settings.toastSqlitePathEmpty": "Der SQLite-Datenbankpfad darf nicht leer sein.", "settings.toastSavedReloading": "Einstellungen erfolgreich gespeichert. Der Browser wird in 3 Sekunden neu geladen.", "settings.toastSaveError": "Fehler beim Speichern der Einstellungen.", + "settings.debugSectionName": "Debug-Logging", + "settings.debugInfoTitle": "Was wird aufgezeichnet?", + "settings.debugInfoDescription": "Wenn aktiviert, schreibt Fredy jede Log-Zeile (debug, info, warn und error) in seine Datenbank. Maximal 5 MB werden gespeichert; sobald die Grenze erreicht ist, werden die ältesten Einträge automatisch gelöscht. Die Konsolen-Ausgabe bleibt unverändert.", + "settings.debugEnableButton": "Debug-Logging aktivieren", + "settings.debugDisableButton": "Debug-Logging deaktivieren", + "settings.debugDownloadButton": "Debug Informationen herunterladen", + "settings.debugUsedLabel": "Belegt:", + "settings.debugUsedValue": "{{used}} von {{max}} ({{percent}}%)", + "settings.debugStatusActive": "Debug-Logging ist aktiv!", + "settings.debugStatusInactive": "Debug-Logging ist inaktiv.", + "settings.debugConfirmReenableTitle": "Vorherige Logs löschen?", + "settings.debugConfirmReenableMessage": "Es sind noch Debug-Logs aus einer vorherigen Sitzung gespeichert. Möchtest du sie löschen, bevor das Debug-Logging erneut aktiviert wird?", + "settings.debugConfirmKeep": "Behalten & fortfahren", + "settings.debugConfirmDelete": "Löschen & aktivieren", + "settings.debugToastEnabled": "Debug-Logging aktiviert.", + "settings.debugToastDisabled": "Debug-Logging deaktiviert.", + "settings.debugToastEnableError": "Debug-Logging konnte nicht aktiviert werden.", + "settings.debugToastDisableError": "Debug-Logging konnte nicht deaktiviert werden.", + "settings.debugToastDownloadError": "Debug-Paket konnte nicht heruntergeladen werden.", + "settings.debugToastNoLogs": "Es sind noch keine Debug-Logs vorhanden. Aktiviere das Debug-Logging und reproduziere das Problem.", + "settings.debugClearButton": "Gespeicherte Debug-Logs löschen", + "settings.debugClearConfirmTitle": "Alle gespeicherten Debug-Logs löschen?", + "settings.debugClearConfirmMessage": "Damit werden alle gespeicherten Debug-Log-Einträge dauerhaft aus der Datenbank entfernt. Die Aufzeichnung selbst bleibt {{recordingState}}. Diese Aktion kann nicht rückgängig gemacht werden.", + "settings.debugClearConfirmRecordingOn": "Aktiv", + "settings.debugClearConfirmRecordingOff": "Inaktiv", + "settings.debugClearConfirmDelete": "Ja, Logs löschen", + "settings.debugClearConfirmCancel": "Abbrechen", + "settings.debugToastCleared": "Gespeicherte Debug-Logs wurden gelöscht.", + "settings.debugToastClearError": "Gespeicherte Debug-Logs konnten nicht gelöscht werden.", + "app.debugLoggingBanner": "Debug-Logging ist aktiv! Alles was Fredy loggt, wird in der Datenbank gespeichert. Deaktiviere es unter Einstellungen → Debug, sobald du fertig bist.", "watchlist.sectionName": "Benachrichtigung für Watchlist", "watchlist.sectionHelp": "Du kannst bei Änderungen an Inseraten auf deiner Watchlist benachrichtigt werden.", diff --git a/ui/src/locales/en.json b/ui/src/locales/en.json index 6ecd987..9595fdf 100644 --- a/ui/src/locales/en.json +++ b/ui/src/locales/en.json @@ -295,6 +295,7 @@ "settings.tabExecution": "Execution", "settings.tabUserSettings": "User Settings", "settings.tabBackup": "Backup & Restore", + "settings.tabDebug": "Debug", "settings.save": "Save", "settings.port": "Port", "settings.portHelp": "The port on which Fredy is running.", @@ -365,6 +366,36 @@ "settings.toastSqlitePathEmpty": "SQLite db path cannot be empty.", "settings.toastSavedReloading": "Settings stored successfully. We will reload your browser in 3 seconds.", "settings.toastSaveError": "Error while trying to store settings.", + "settings.debugSectionName": "Debug Logging", + "settings.debugInfoTitle": "What gets recorded?", + "settings.debugInfoDescription": "When enabled, Fredy records every log line (debug, info, warn and error) into its database. A maximum of 5 MB is kept; once the cap is reached, the oldest entries are dropped automatically. Console output is unaffected.", + "settings.debugEnableButton": "Enable debug logging", + "settings.debugDisableButton": "Disable debug logging", + "settings.debugDownloadButton": "Download debug information", + "settings.debugUsedLabel": "Used:", + "settings.debugUsedValue": "{{used}} of {{max}} ({{percent}}%)", + "settings.debugStatusActive": "Debug logging is currently active.", + "settings.debugStatusInactive": "Debug logging is currently inactive.", + "settings.debugConfirmReenableTitle": "Delete previous logs?", + "settings.debugConfirmReenableMessage": "Debug logs from a previous session are still stored. Do you want to delete them before enabling debug logging again?", + "settings.debugConfirmKeep": "Keep & continue", + "settings.debugConfirmDelete": "Delete & enable", + "settings.debugToastEnabled": "Debug logging enabled.", + "settings.debugToastDisabled": "Debug logging disabled.", + "settings.debugToastEnableError": "Could not enable debug logging.", + "settings.debugToastDisableError": "Could not disable debug logging.", + "settings.debugToastDownloadError": "Could not download the debug bundle.", + "settings.debugToastNoLogs": "No debug logs available yet. Enable debug logging first and reproduce the issue.", + "settings.debugClearButton": "Delete stored debug logs", + "settings.debugClearConfirmTitle": "Delete all stored debug logs?", + "settings.debugClearConfirmMessage": "This permanently removes every stored debug log entry from the database. Recording itself will stay {{recordingState}}. This action cannot be undone.", + "settings.debugClearConfirmRecordingOn": "ON", + "settings.debugClearConfirmRecordingOff": "OFF", + "settings.debugClearConfirmDelete": "Yes, delete logs", + "settings.debugClearConfirmCancel": "Cancel", + "settings.debugToastCleared": "Stored debug logs were deleted.", + "settings.debugToastClearError": "Could not delete the stored debug logs.", + "app.debugLoggingBanner": "Debug logging is active! Everything Fredy logs is being stored in its database. Disable it in Settings → Debug once you're done.", "watchlist.sectionName": "Notification for Watch List", "watchlist.sectionHelp": "You can get notified for changes on listings from your watch list.", diff --git a/ui/src/services/debugLoggingClient.js b/ui/src/services/debugLoggingClient.js new file mode 100644 index 0000000..04ac7ff --- /dev/null +++ b/ui/src/services/debugLoggingClient.js @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +/** + * Tiny client wrapping the /api/admin/debug endpoints. + * + * The server returns the same status payload from every mutation endpoint so the UI + * does not need to re-fetch after enable/disable, it can apply the response payload + * directly. + */ + +function extractFileNameFromDisposition(disposition) { + const dispo = disposition || ''; + // RFC 6266 says the UTF-8 encoded `filename*=` form takes precedence over the + // legacy `filename=` form when both are present. Match each form independently + // and prefer the UTF-8 one so we cannot accidentally pick the wrong encoding. + const utf8Match = dispo.match(/filename\*=UTF-8''([^;]+)/); + if (utf8Match) { + try { + return decodeURIComponent(utf8Match[1]); + } catch { + // malformed percent-encoding; fall through to the legacy form + } + } + const legacyMatch = dispo.match(/filename="?([^";]+)"?/); + if (legacyMatch) return legacyMatch[1]; + return 'FredyDebug.zip'; +} + +/** + * Fetch the current feature status. Requires admin auth. + * @returns {Promise<{enabled:boolean, size:number, max:number, hasLogs:boolean, everEnabled:boolean}>} + */ +export async function fetchDebugStatus() { + const resp = await fetch('/api/admin/debug/status', { credentials: 'include' }); + if (!resp.ok) throw new Error('Failed to load debug logging status'); + return resp.json(); +} + +/** + * Lightweight "is debug logging active right now?" probe usable by any authenticated + * user. Used by the app-wide red banner so non-admin users also see the warning. The + * payload is intentionally a single boolean, no other settings are exposed. + * + * @returns {Promise<{enabled:boolean}>} + */ +export async function fetchDebugActive() { + const resp = await fetch('/api/debug/active', { credentials: 'include' }); + if (!resp.ok) throw new Error('Failed to load debug active flag'); + return resp.json(); +} + +/** + * Enable the feature. When clearPrevious is true, existing log rows are dropped + * before the new collection starts. + * @param {{clearPrevious?:boolean}} [options] + * @returns {Promise} + */ +export async function enableDebugLogging({ clearPrevious = false } = {}) { + const resp = await fetch('/api/admin/debug/enable', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ clearPrevious }), + }); + if (!resp.ok) throw new Error('Failed to enable debug logging'); + return resp.json(); +} + +/** + * Disable the feature. Existing logs remain on disk so they can still be downloaded. + * @returns {Promise} + */ +export async function disableDebugLogging() { + const resp = await fetch('/api/admin/debug/disable', { + method: 'POST', + credentials: 'include', + }); + if (!resp.ok) throw new Error('Failed to disable debug logging'); + return resp.json(); +} + +/** + * Drop every stored debug log row. Does NOT change the enabled flag: if recording + * was on, it stays on and the table simply starts filling again. Returns the new + * status payload. + * @returns {Promise} + */ +export async function clearDebugLogs() { + const resp = await fetch('/api/admin/debug/logs', { + method: 'DELETE', + credentials: 'include', + }); + if (!resp.ok) throw new Error('Failed to clear debug logs'); + return resp.json(); +} + +/** + * Trigger the debug bundle download. Throws when there is nothing to export (server + * returns 409 in that case) or any other non-2xx response. + * @returns {Promise} + */ +export async function downloadDebugBundle() { + const resp = await fetch('/api/admin/debug/download', { credentials: 'include' }); + if (resp.status === 409) { + const data = await resp.json().catch(() => ({})); + const err = new Error(data?.error || 'No debug logs available yet'); + err.code = 'NO_LOGS'; + throw err; + } + if (!resp.ok) throw new Error('Failed to download debug bundle'); + const blob = await resp.blob(); + const fileName = extractFileNameFromDisposition(resp.headers.get('Content-Disposition')); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = fileName; + document.body.appendChild(a); + a.click(); + a.remove(); + window.URL.revokeObjectURL(url); +} diff --git a/ui/src/views/generalSettings/GeneralSettings.jsx b/ui/src/views/generalSettings/GeneralSettings.jsx index 87d80bd..bdea455 100644 --- a/ui/src/views/generalSettings/GeneralSettings.jsx +++ b/ui/src/views/generalSettings/GeneralSettings.jsx @@ -22,6 +22,7 @@ import { Radio, RadioGroup, Typography, + Progress, } from '@douyinfe/semi-ui-19'; import { InputNumber } from '@douyinfe/semi-ui-19'; import { xhrPost, xhrGet } from '../../services/xhr'; @@ -32,7 +33,14 @@ import { precheckRestore as clientPrecheckRestore, restore as clientRestore, } from '../../services/backupRestoreClient'; -import { IconSave, IconRefresh, IconSignal, IconHome, IconFolder } from '@douyinfe/semi-icons'; +import { + fetchDebugStatus, + enableDebugLogging as apiEnableDebugLogging, + disableDebugLogging as apiDisableDebugLogging, + downloadDebugBundle, + clearDebugLogs as apiClearDebugLogs, +} from '../../services/debugLoggingClient'; +import { IconSave, IconRefresh, IconSignal, IconHome, IconFolder, IconAlertTriangle } from '@douyinfe/semi-icons'; import { debounce } from '../../utils'; import Headline from '../../components/headline/Headline.jsx'; import './GeneralSettings.less'; @@ -55,6 +63,32 @@ function formatFromTBackend(time) { return date.getTime(); } +/** + * Human-readable byte formatter used by the Debug tab's usage label. + * @param {number} bytes + * @returns {string} + */ +function formatBytes(bytes) { + if (!Number.isFinite(bytes)) return String(bytes); + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`; + return `${(bytes / (1024 * 1024)).toFixed(2)} MiB`; +} + +/** + * Compute the integer percentage that `used` represents of `total`, clamped to [0, 100]. + * @param {number} used + * @param {number} total + * @returns {number} + */ +function percentOf(used, total) { + if (!total || total <= 0) return 0; + const pct = Math.round((used / total) * 100); + if (pct < 0) return 0; + if (pct > 100) return 100; + return pct; +} + const GeneralSettings = function GeneralSettings() { const actions = useActions(); const t = useTranslation(); @@ -79,6 +113,20 @@ const GeneralSettings = function GeneralSettings() { const [restoreBusy, setRestoreBusy] = React.useState(false); const [selectedRestoreFile, setSelectedRestoreFile] = React.useState(null); + // Debug-logging tab state. status is fetched on mount + polled every 3s while the + // feature is active so the progress bar reflects the live byte budget. + // debugStatusSeq monotonically increases with every applied status update so we can + // discard stale polling responses that arrive after a manual enable/disable. + const [debugStatus, setDebugStatus] = React.useState(null); + const [debugBusy, setDebugBusy] = React.useState(false); + const [debugConfirmVisible, setDebugConfirmVisible] = React.useState(false); + const [debugClearConfirmVisible, setDebugClearConfirmVisible] = React.useState(false); + const debugStatusSeqRef = React.useRef(0); + const applyDebugStatus = React.useCallback((fresh) => { + debugStatusSeqRef.current += 1; + setDebugStatus(fresh); + }, []); + // User settings state const homeAddress = useSelector((state) => state.userSettings.settings.home_address); const providerDetails = useSelector((state) => state.userSettings.settings.provider_details); @@ -127,6 +175,45 @@ const GeneralSettings = function GeneralSettings() { setListingDeleteSkipPrompt(listingDeletionPreference?.skipPrompt ?? false); }, [listingDeletionPreference]); + // Initial debug-status load. Subsequent updates flow through applyDebugStatus() + // (called by polling + after every enable/disable action), so this effect only + // needs to fire once on mount. + useEffect(() => { + let cancelled = false; + fetchDebugStatus() + .then((s) => { + if (!cancelled) applyDebugStatus(s); + }) + .catch((e) => { + // Non-fatal: tab is still usable, polling will retry. + console.error('Failed to load debug status', e); + }); + return () => { + cancelled = true; + }; + }, [applyDebugStatus]); + + // Live polling while the feature is active so the progress bar reflects new entries + // as they are written. We intentionally do NOT poll while inactive — the size stays + // constant and there's no Banner to update. Stale poll responses (where a manual + // enable/disable bumped the sequence in the meantime) are discarded so the UI does + // not flicker back to the previous state for ~3s. + useEffect(() => { + if (!debugStatus?.enabled) return undefined; + const id = window.setInterval(async () => { + const seqAtStart = debugStatusSeqRef.current; + try { + const fresh = await fetchDebugStatus(); + if (debugStatusSeqRef.current === seqAtStart) { + applyDebugStatus(fresh); + } + } catch { + // ignore transient errors; next tick will retry + } + }, 3000); + return () => window.clearInterval(id); + }, [debugStatus?.enabled, applyDebugStatus]); + const nullOrEmpty = (val) => val == null || val.length === 0; const handleStore = async () => { @@ -231,6 +318,89 @@ const GeneralSettings = function GeneralSettings() { } }, []); + // ── Debug-logging actions ──────────────────────────────────────────────────── + // performEnableDebug() centralizes the actual enable call so both branches of the + // confirm dialog ("delete" vs. "keep") plus the no-confirm fast-path can share it. + const performEnableDebug = React.useCallback( + async ({ clearPrevious }) => { + setDebugBusy(true); + try { + const fresh = await apiEnableDebugLogging({ clearPrevious }); + applyDebugStatus(fresh); + // Keep the global generalSettings store in sync so the app-wide red banner + // (which reads settings.debug_logging_enabled) updates immediately. + await actions.generalSettings.getGeneralSettings(); + Toast.success(t('settings.debugToastEnabled')); + } catch (e) { + console.error(e); + Toast.error(t('settings.debugToastEnableError')); + } finally { + setDebugBusy(false); + setDebugConfirmVisible(false); + } + }, + [actions.generalSettings, applyDebugStatus, t], + ); + + const handleToggleDebugLogging = React.useCallback(async () => { + // Guard against the initial-load race: if status hasn't arrived yet, ignore the + // click. The button is also disabled when debugStatus == null, this is belt & + // braces for the case where the click somehow reached the handler anyway. + if (debugStatus == null) return; + if (debugStatus.enabled) { + setDebugBusy(true); + try { + const fresh = await apiDisableDebugLogging(); + applyDebugStatus(fresh); + await actions.generalSettings.getGeneralSettings(); + Toast.success(t('settings.debugToastDisabled')); + } catch (e) { + console.error(e); + Toast.error(t('settings.debugToastDisableError')); + } finally { + setDebugBusy(false); + } + return; + } + // Enabling: if logs from a previous session are still around, ask first. + if (debugStatus.hasLogs) { + setDebugConfirmVisible(true); + return; + } + await performEnableDebug({ clearPrevious: false }); + }, [debugStatus, performEnableDebug, actions.generalSettings, applyDebugStatus, t]); + + const handleDownloadDebugBundle = React.useCallback(async () => { + try { + await downloadDebugBundle(); + } catch (e) { + console.error(e); + if (e?.code === 'NO_LOGS') { + Toast.error(t('settings.debugToastNoLogs')); + } else { + Toast.error(t('settings.debugToastDownloadError')); + } + } + }, [t]); + + // Deleting stored logs is a separate action from disabling the feature: the user can + // free up the rolling buffer mid-recording without turning off collection. The + // confirmation dialog makes the destructive nature explicit. + const performClearDebugLogs = React.useCallback(async () => { + setDebugBusy(true); + try { + const fresh = await apiClearDebugLogs(); + applyDebugStatus(fresh); + Toast.success(t('settings.debugToastCleared')); + } catch (e) { + console.error(e); + Toast.error(t('settings.debugToastClearError')); + } finally { + setDebugBusy(false); + setDebugClearConfirmVisible(false); + } + }, [applyDebugStatus, t]); + const handleSaveUserSettings = async () => { try { const responseJson = await actions.userSettings.setHomeAddress(address); @@ -572,6 +742,98 @@ const GeneralSettings = function GeneralSettings() { + + {currentUser?.isAdmin && ( + + + {t('settings.tabDebug')} + + } + itemKey="debug" + > +
+ + {t('settings.debugInfoTitle')}
} + description={t('settings.debugInfoDescription')} + /> + + {debugStatus?.enabled ? ( + +
{t('settings.debugStatusActive')}
+
+ + {t('settings.debugUsedLabel')} + + + {t('settings.debugUsedValue', { + used: formatBytes(debugStatus.size), + max: formatBytes(debugStatus.max), + percent: percentOf(debugStatus.size, debugStatus.max), + })} + + +
+ + } + /> + ) : ( +
+ {t('settings.debugStatusInactive')} +
+ )} + +
+ + + {debugStatus?.hasLogs && ( + + )} +
+ + +
+ )} )} @@ -621,6 +883,65 @@ const GeneralSettings = function GeneralSettings() { )} + + {debugConfirmVisible && ( + { + // Defensive reset in case a network blip left debugBusy stuck while the + // user dismissed the dialog via the X / backdrop. + setDebugBusy(false); + setDebugConfirmVisible(false); + }} + footer={ +
+ + +
+ } + > +
{t('settings.debugConfirmReenableMessage')}
+
+ )} + + {debugClearConfirmVisible && ( + { + setDebugBusy(false); + setDebugClearConfirmVisible(false); + }} + footer={ +
+ + +
+ } + > +
+ {t('settings.debugClearConfirmMessage', { + recordingState: debugStatus?.enabled + ? t('settings.debugClearConfirmRecordingOn') + : t('settings.debugClearConfirmRecordingOff'), + })} +
+
+ )} ); };