adding ability to record logs for debug purposes

This commit is contained in:
orangecoding
2026-06-09 15:42:25 +02:00
parent 6c7d655277
commit 6bef907416
20 changed files with 2229 additions and 7 deletions

View File

@@ -5,6 +5,40 @@ labels: [bug]
assignees: [] assignees: []
body: 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 - type: textarea
id: description id: description
attributes: attributes:
@@ -49,8 +83,11 @@ body:
id: screenshots id: screenshots
attributes: attributes:
label: Screenshots / Logs label: Screenshots / Logs
description: Add screenshots or paste log output to help explain the problem. description: |
placeholder: "Drag and drop screenshots here, or paste logs." 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: validations:
required: false required: false
@@ -58,8 +95,10 @@ body:
id: environment id: environment
attributes: attributes:
label: Environment label: Environment
description: Provide details about your environment. description: |
placeholder: "OS: macOS 15, Browser: Chrome 124, App version: 1.2.3" 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: validations:
required: true required: true

View File

@@ -210,6 +210,50 @@ The data includes: names of active adapters/providers, OS, architecture, Node ve
**Thanks**🤘 **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-<version>.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
### Development Mode ### Development Mode

View File

@@ -10,6 +10,7 @@ import { runMigrations } from './lib/services/storage/migrations/migrate.js';
import { ensureDemoUserExists, ensureAdminUserExists } from './lib/services/storage/userStorage.js'; import { ensureDemoUserExists, ensureAdminUserExists } from './lib/services/storage/userStorage.js';
import { initTrackerCron } from './lib/services/crons/tracker-cron.js'; import { initTrackerCron } from './lib/services/crons/tracker-cron.js';
import logger from './lib/services/logger.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 { initActiveCheckerCron } from './lib/services/crons/listing-alive-cron.js';
import { initGeocodingCron } from './lib/services/crons/geocoding-cron.js'; import { initGeocodingCron } from './lib/services/crons/geocoding-cron.js';
import { getSettings } from './lib/services/storage/settingsStorage.js'; import { getSettings } from './lib/services/storage/settingsStorage.js';
@@ -42,6 +43,12 @@ await runMigrations();
const settings = await getSettings(); 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) // Ensure the sqlite directory exists before loading anything else (based on config.sqlitepath)
const { dir: sqliteDir } = await computeDbPath(); const { dir: sqliteDir } = await computeDbPath();
if (!fs.existsSync(sqliteDir)) { if (!fs.existsSync(sqliteDir)) {

View File

@@ -24,6 +24,7 @@ import userSettingsPlugin from './routes/userSettingsRoute.js';
import trackingPlugin from './routes/trackingRoute.js'; import trackingPlugin from './routes/trackingRoute.js';
import generalSettingsPlugin from './routes/generalSettingsRoute.js'; import generalSettingsPlugin from './routes/generalSettingsRoute.js';
import backupPlugin from './routes/backupRouter.js'; import backupPlugin from './routes/backupRouter.js';
import debugPlugin, { registerDebugPublicProbe } from './routes/debugRouter.js';
import userPlugin from './routes/userRoute.js'; import userPlugin from './routes/userRoute.js';
import notificationAdapterPlugin from './routes/notificationAdapterRouter.js'; import notificationAdapterPlugin from './routes/notificationAdapterRouter.js';
import providerPlugin from './routes/providerRouter.js'; import providerPlugin from './routes/providerRouter.js';
@@ -77,6 +78,16 @@ fastify.register(async (app) => {
app.register(userSettingsPlugin, { prefix: '/api/user/settings' }); app.register(userSettingsPlugin, { prefix: '/api/user/settings' });
app.register(trackingPlugin, { prefix: '/api/tracking' }); app.register(trackingPlugin, { prefix: '/api/tracking' });
app.register(generalSettingsPlugin, { prefix: '/api/admin/generalSettings' }); 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 // Admin-only routes
@@ -84,6 +95,7 @@ fastify.register(async (app) => {
app.addHook('preHandler', authHook); app.addHook('preHandler', authHook);
app.addHook('preHandler', adminHook); app.addHook('preHandler', adminHook);
app.register(backupPlugin, { prefix: '/api/admin/backup' }); app.register(backupPlugin, { prefix: '/api/admin/backup' });
app.register(debugPlugin, { prefix: '/api/admin/debug' });
app.register(userPlugin, { prefix: '/api/admin/users' }); app.register(userPlugin, { prefix: '/api/admin/users' });
}); });

View File

@@ -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);
});
}

View File

@@ -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<any>}
*/
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<string>}
*/
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<string>}
*/
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<Buffer>}
*/
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();
}

View File

@@ -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<void>}
*/
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<number>}
*/
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<boolean>}
*/
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<void>}
*/
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<void>}
*/
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<boolean>} 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;
}

View File

@@ -14,6 +14,20 @@ const COLORS = {
const env = process.env.NODE_ENV || 'development'; const env = process.env.NODE_ENV || 'development';
const useColor = process.stdout.isTTY || process.stderr.isTTY; 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() { function ts() {
const d = new Date(); const d = new Date();
const yyyy = d.getFullYear(); const yyyy = d.getFullYear();
@@ -31,10 +45,50 @@ function lvl(level) {
return `${COLORS[level] || ''}${upper}${COLORS.reset}`; 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 */ /* eslint-disable no-console */
function log(level, ...args) { 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') { 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)}:`; 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 { export default {
debug: (...a) => log('debug', ...a), debug: (...a) => log('debug', ...a),
info: (...a) => log('info', ...a), info: (...a) => log('info', ...a),
warn: (...a) => log('warn', ...a), warn: (...a) => log('warn', ...a),
error: (...a) => log('error', ...a), error: (...a) => log('error', ...a),
setDebugLogSink,
}; };

View File

@@ -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
);
`);
}

View File

@@ -1,6 +1,6 @@
{ {
"name": "fredy", "name": "fredy",
"version": "22.4.0", "version": "22.5.0",
"description": "[F]ind [R]eal [E]states [d]amn eas[y].", "description": "[F]ind [R]eal [E]states [d]amn eas[y].",
"scripts": { "scripts": {
"prepare": "husky", "prepare": "husky",

View File

@@ -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-<version>.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);
});
});
});

View File

@@ -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');
});
});
});

View File

@@ -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);
}
});
});

View File

@@ -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();
});
});

View File

@@ -30,6 +30,7 @@ import Dashboard from './views/dashboard/Dashboard.jsx';
import ListingDetail from './views/listings/ListingDetail.jsx'; import ListingDetail from './views/listings/ListingDetail.jsx';
import NewsModal from './components/news/NewsModal.jsx'; import NewsModal from './components/news/NewsModal.jsx';
import { I18nProvider, availableLanguages } from './services/i18n/i18n.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', { const semiLocaleModules = import.meta.glob('/node_modules/@douyinfe/semi-ui-19/lib/es/locale/source/*.js', {
eager: true, eager: true,
@@ -96,6 +97,7 @@ export default function FredyApp() {
<Layout className="app__main"> <Layout className="app__main">
<Content className="app__content"> <Content className="app__content">
{versionUpdate?.newVersion && <VersionBanner />} {versionUpdate?.newVersion && <VersionBanner />}
<DebugLoggingBanner />
{settings.demoMode && ( {settings.demoMode && (
<> <>
<Banner <Banner

View File

@@ -0,0 +1,58 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { useEffect, useState } from 'react';
import { Banner } from '@douyinfe/semi-ui-19';
import { useTranslation } from '../../services/i18n/i18n.jsx';
import { fetchDebugActive } from '../../services/debugLoggingClient.js';
const POLL_INTERVAL_MS = 15000;
/**
* Persistent, non-dismissable red banner shown on every page while the admin opt-in
* "Debug Logging" feature is active. Polls the lightweight `/api/debug/active` probe
* so every authenticated user (not just admins) sees the warning, without exposing
* the rest of the settings payload.
*
* Polling interval is intentionally generous (15s) because the value only changes
* when an admin toggles the feature, which happens at human speeds. The Debug tab
* itself uses its own 3s polling for the live progress bar inside Settings.
*
* @returns {JSX.Element|null}
*/
export default function DebugLoggingBanner() {
const t = useTranslation();
const [active, setActive] = useState(false);
useEffect(() => {
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 (
<>
<Banner fullMode={true} type="danger" bordered closeIcon={null} description={t('app.debugLoggingBanner')} />
<br />
</>
);
}
DebugLoggingBanner.displayName = 'DebugLoggingBanner';

View File

@@ -295,6 +295,7 @@
"settings.tabExecution": "Ausführung", "settings.tabExecution": "Ausführung",
"settings.tabUserSettings": "Benutzereinstellungen", "settings.tabUserSettings": "Benutzereinstellungen",
"settings.tabBackup": "Backup & Wiederherstellung", "settings.tabBackup": "Backup & Wiederherstellung",
"settings.tabDebug": "Debug",
"settings.save": "Speichern", "settings.save": "Speichern",
"settings.port": "Port", "settings.port": "Port",
"settings.portHelp": "Der Port, auf dem Fredy läuft.", "settings.portHelp": "Der Port, auf dem Fredy läuft.",
@@ -365,6 +366,36 @@
"settings.toastSqlitePathEmpty": "Der SQLite-Datenbankpfad darf nicht leer sein.", "settings.toastSqlitePathEmpty": "Der SQLite-Datenbankpfad darf nicht leer sein.",
"settings.toastSavedReloading": "Einstellungen erfolgreich gespeichert. Der Browser wird in 3 Sekunden neu geladen.", "settings.toastSavedReloading": "Einstellungen erfolgreich gespeichert. Der Browser wird in 3 Sekunden neu geladen.",
"settings.toastSaveError": "Fehler beim Speichern der Einstellungen.", "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.sectionName": "Benachrichtigung für Watchlist",
"watchlist.sectionHelp": "Du kannst bei Änderungen an Inseraten auf deiner Watchlist benachrichtigt werden.", "watchlist.sectionHelp": "Du kannst bei Änderungen an Inseraten auf deiner Watchlist benachrichtigt werden.",

View File

@@ -295,6 +295,7 @@
"settings.tabExecution": "Execution", "settings.tabExecution": "Execution",
"settings.tabUserSettings": "User Settings", "settings.tabUserSettings": "User Settings",
"settings.tabBackup": "Backup & Restore", "settings.tabBackup": "Backup & Restore",
"settings.tabDebug": "Debug",
"settings.save": "Save", "settings.save": "Save",
"settings.port": "Port", "settings.port": "Port",
"settings.portHelp": "The port on which Fredy is running.", "settings.portHelp": "The port on which Fredy is running.",
@@ -365,6 +366,36 @@
"settings.toastSqlitePathEmpty": "SQLite db path cannot be empty.", "settings.toastSqlitePathEmpty": "SQLite db path cannot be empty.",
"settings.toastSavedReloading": "Settings stored successfully. We will reload your browser in 3 seconds.", "settings.toastSavedReloading": "Settings stored successfully. We will reload your browser in 3 seconds.",
"settings.toastSaveError": "Error while trying to store settings.", "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.sectionName": "Notification for Watch List",
"watchlist.sectionHelp": "You can get notified for changes on listings from your watch list.", "watchlist.sectionHelp": "You can get notified for changes on listings from your watch list.",

View File

@@ -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<object>}
*/
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<object>}
*/
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<object>}
*/
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<void>}
*/
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);
}

View File

@@ -22,6 +22,7 @@ import {
Radio, Radio,
RadioGroup, RadioGroup,
Typography, Typography,
Progress,
} from '@douyinfe/semi-ui-19'; } from '@douyinfe/semi-ui-19';
import { InputNumber } from '@douyinfe/semi-ui-19'; import { InputNumber } from '@douyinfe/semi-ui-19';
import { xhrPost, xhrGet } from '../../services/xhr'; import { xhrPost, xhrGet } from '../../services/xhr';
@@ -32,7 +33,14 @@ import {
precheckRestore as clientPrecheckRestore, precheckRestore as clientPrecheckRestore,
restore as clientRestore, restore as clientRestore,
} from '../../services/backupRestoreClient'; } 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 { debounce } from '../../utils';
import Headline from '../../components/headline/Headline.jsx'; import Headline from '../../components/headline/Headline.jsx';
import './GeneralSettings.less'; import './GeneralSettings.less';
@@ -55,6 +63,32 @@ function formatFromTBackend(time) {
return date.getTime(); 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 GeneralSettings = function GeneralSettings() {
const actions = useActions(); const actions = useActions();
const t = useTranslation(); const t = useTranslation();
@@ -79,6 +113,20 @@ const GeneralSettings = function GeneralSettings() {
const [restoreBusy, setRestoreBusy] = React.useState(false); const [restoreBusy, setRestoreBusy] = React.useState(false);
const [selectedRestoreFile, setSelectedRestoreFile] = React.useState(null); 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 // User settings state
const homeAddress = useSelector((state) => state.userSettings.settings.home_address); const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
const providerDetails = useSelector((state) => state.userSettings.settings.provider_details); const providerDetails = useSelector((state) => state.userSettings.settings.provider_details);
@@ -127,6 +175,45 @@ const GeneralSettings = function GeneralSettings() {
setListingDeleteSkipPrompt(listingDeletionPreference?.skipPrompt ?? false); setListingDeleteSkipPrompt(listingDeletionPreference?.skipPrompt ?? false);
}, [listingDeletionPreference]); }, [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 nullOrEmpty = (val) => val == null || val.length === 0;
const handleStore = async () => { 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 () => { const handleSaveUserSettings = async () => {
try { try {
const responseJson = await actions.userSettings.setHomeAddress(address); const responseJson = await actions.userSettings.setHomeAddress(address);
@@ -572,6 +742,98 @@ const GeneralSettings = function GeneralSettings() {
</SegmentPart> </SegmentPart>
</div> </div>
</TabPane> </TabPane>
{currentUser?.isAdmin && (
<TabPane
tab={
<span>
<IconAlertTriangle
size="small"
style={{
marginRight: 6,
color: debugStatus?.enabled ? 'var(--semi-color-danger)' : undefined,
}}
/>
{t('settings.tabDebug')}
</span>
}
itemKey="debug"
>
<div className="generalSettings__tab-content">
<SegmentPart name={t('settings.debugSectionName')}>
<Banner
type="info"
fullMode={false}
closeIcon={null}
style={{ marginBottom: 12 }}
title={<div style={{ fontWeight: 600, fontSize: '14px' }}>{t('settings.debugInfoTitle')}</div>}
description={t('settings.debugInfoDescription')}
/>
{debugStatus?.enabled ? (
<Banner
type="danger"
fullMode={false}
closeIcon={null}
style={{ marginBottom: 12 }}
description={
<div>
<div style={{ fontWeight: 600 }}>{t('settings.debugStatusActive')}</div>
<div style={{ marginTop: 8 }}>
<Text type="secondary" style={{ marginRight: 8 }}>
{t('settings.debugUsedLabel')}
</Text>
<Text>
{t('settings.debugUsedValue', {
used: formatBytes(debugStatus.size),
max: formatBytes(debugStatus.max),
percent: percentOf(debugStatus.size, debugStatus.max),
})}
</Text>
<Progress
percent={percentOf(debugStatus.size, debugStatus.max)}
stroke="var(--semi-color-danger)"
aria-label="debug log storage"
style={{ marginTop: 6 }}
/>
</div>
</div>
}
/>
) : (
<div style={{ marginBottom: 12 }}>
<Text type="secondary">{t('settings.debugStatusInactive')}</Text>
</div>
)}
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<Button
theme="solid"
type={debugStatus?.enabled ? 'danger' : 'primary'}
loading={debugBusy}
disabled={debugStatus == null}
onClick={handleToggleDebugLogging}
>
{debugStatus?.enabled ? t('settings.debugDisableButton') : t('settings.debugEnableButton')}
</Button>
<Button
theme="light"
icon={<IconSave />}
disabled={debugStatus == null || !debugStatus?.everEnabled || !debugStatus?.hasLogs}
onClick={handleDownloadDebugBundle}
>
{t('settings.debugDownloadButton')}
</Button>
{debugStatus?.hasLogs && (
<Button theme="solid" type="warning" onClick={() => setDebugClearConfirmVisible(true)}>
{t('settings.debugClearButton')}
</Button>
)}
</div>
</SegmentPart>
</div>
</TabPane>
)}
</Tabs> </Tabs>
</> </>
)} )}
@@ -621,6 +883,65 @@ const GeneralSettings = function GeneralSettings() {
</div> </div>
</Modal> </Modal>
)} )}
{debugConfirmVisible && (
<Modal
title={t('settings.debugConfirmReenableTitle')}
visible={debugConfirmVisible}
onCancel={() => {
// 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={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Button onClick={() => performEnableDebug({ clearPrevious: false })} loading={debugBusy}>
{t('settings.debugConfirmKeep')}
</Button>
<Button
type="danger"
theme="solid"
onClick={() => performEnableDebug({ clearPrevious: true })}
loading={debugBusy}
>
{t('settings.debugConfirmDelete')}
</Button>
</div>
}
>
<div>{t('settings.debugConfirmReenableMessage')}</div>
</Modal>
)}
{debugClearConfirmVisible && (
<Modal
title={t('settings.debugClearConfirmTitle')}
visible={debugClearConfirmVisible}
onCancel={() => {
setDebugBusy(false);
setDebugClearConfirmVisible(false);
}}
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Button onClick={() => setDebugClearConfirmVisible(false)} disabled={debugBusy}>
{t('settings.debugClearConfirmCancel')}
</Button>
<Button type="warning" theme="solid" onClick={performClearDebugLogs} loading={debugBusy}>
{t('settings.debugClearConfirmDelete')}
</Button>
</div>
}
>
<div>
{t('settings.debugClearConfirmMessage', {
recordingState: debugStatus?.enabled
? t('settings.debugClearConfirmRecordingOn')
: t('settings.debugClearConfirmRecordingOff'),
})}
</div>
</Modal>
)}
</div> </div>
); );
}; };