mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
adding ability to record logs for debug purposes
This commit is contained in:
47
.github/ISSUE_TEMPLATE/bug.yml
vendored
47
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -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
|
||||||
|
|
||||||
|
|||||||
44
README.md
44
README.md
@@ -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
|
||||||
|
|||||||
7
index.js
7
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 { 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)) {
|
||||||
|
|||||||
@@ -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' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
93
lib/api/routes/debugRouter.js
Normal file
93
lib/api/routes/debugRouter.js
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
263
lib/services/debug/debugBundleService.js
Normal file
263
lib/services/debug/debugBundleService.js
Normal 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();
|
||||||
|
}
|
||||||
346
lib/services/debug/debugLogStorage.js
Normal file
346
lib/services/debug/debugLogStorage.js
Normal 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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
32
lib/services/storage/migrations/sql/20.add-debug-logs.js
Normal file
32
lib/services/storage/migrations/sql/20.add-debug-logs.js
Normal 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
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
129
test/services/debug/debugBundleService.test.js
Normal file
129
test/services/debug/debugBundleService.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
278
test/services/debug/debugLogStorage.test.js
Normal file
278
test/services/debug/debugLogStorage.test.js
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
250
test/services/debug/debugRouter.test.js
Normal file
250
test/services/debug/debugRouter.test.js
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
89
test/services/logger.test.js
Normal file
89
test/services/logger.test.js
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
|||||||
58
ui/src/components/debug/DebugLoggingBanner.jsx
Normal file
58
ui/src/components/debug/DebugLoggingBanner.jsx
Normal 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';
|
||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
124
ui/src/services/debugLoggingClient.js
Normal file
124
ui/src/services/debugLoggingClient.js
Normal 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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user