From 4282d6c6e9ec68da07a48c65bc47194909bb28c4 Mon Sep 17 00:00:00 2001 From: carlosalbertorg Date: Tue, 22 Sep 2026 11:21:36 -0300 Subject: [PATCH 1/2] feat: Add a Logs viewer to the admin UI Nginx Proxy Manager had no way to inspect application or nginx logs from the web UI - admins had to shell into the container or read `docker logs`. This adds an admin-only Logs page that can tail: - the backend application log, now also mirrored to /data/logs/backend.log (in addition to stdout) and rotated by the logrotate timer that already runs every 2 days - the Let's Encrypt/certbot log, which certbot already writes to /data/logs/letsencrypt.log via its existing --logs-dir flag - per-host nginx access/error logs (proxy, redirection, 404 and stream hosts), with the file path always resolved server-side from a validated host_type enum + numeric host_id, never from client input Reads use a reverse chunked scan (64KB chunks, capped at 5MB scanned per request) instead of loading whole files into memory, and the frontend polls every 5s only while the tab is focused and "Live" is on, so this stays cheap on both CPU and memory. No new runtime dependencies were added on either side. Purely additive: two new admin-only endpoints (GET /api/logs/sources, GET /api/logs/tail), no existing behaviour changed. --- backend/internal/log-viewer.js | 229 ++++++++++++++++ backend/lib/access/logs-list.json | 7 + backend/logger.js | 122 ++++++++- backend/routes/logs.js | 106 ++++++++ backend/routes/main.js | 2 + .../schema/components/log-sources-object.json | 69 +++++ .../schema/components/log-tail-object.json | 30 +++ backend/schema/paths/logs/sources/get.json | 36 +++ backend/schema/paths/logs/tail/get.json | 100 +++++++ backend/schema/swagger.json | 14 + .../etc/logrotate.d/nginx-proxy-manager | 10 + frontend/.gitignore | 2 +- frontend/src/Router.tsx | 2 + frontend/src/api/backend/getLogSources.ts | 8 + frontend/src/api/backend/getLogTail.ts | 19 ++ frontend/src/api/backend/index.ts | 2 + frontend/src/api/backend/models.ts | 24 ++ frontend/src/components/SiteMenu.tsx | 7 + frontend/src/hooks/index.ts | 2 + frontend/src/hooks/useLogSources.ts | 15 ++ frontend/src/hooks/useLogTail.ts | 25 ++ frontend/src/locale/src/en.json | 45 ++++ frontend/src/locale/src/et.json | 45 ++++ frontend/src/pages/Logs/LogLines.tsx | 76 ++++++ frontend/src/pages/Logs/LogViewer.module.css | 15 ++ frontend/src/pages/Logs/LogViewer.tsx | 249 ++++++++++++++++++ frontend/src/pages/Logs/index.tsx | 13 + 27 files changed, 1262 insertions(+), 12 deletions(-) create mode 100644 backend/internal/log-viewer.js create mode 100644 backend/lib/access/logs-list.json create mode 100644 backend/routes/logs.js create mode 100644 backend/schema/components/log-sources-object.json create mode 100644 backend/schema/components/log-tail-object.json create mode 100644 backend/schema/paths/logs/sources/get.json create mode 100644 backend/schema/paths/logs/tail/get.json create mode 100644 frontend/src/api/backend/getLogSources.ts create mode 100644 frontend/src/api/backend/getLogTail.ts create mode 100644 frontend/src/hooks/useLogSources.ts create mode 100644 frontend/src/hooks/useLogTail.ts create mode 100644 frontend/src/pages/Logs/LogLines.tsx create mode 100644 frontend/src/pages/Logs/LogViewer.module.css create mode 100644 frontend/src/pages/Logs/LogViewer.tsx create mode 100644 frontend/src/pages/Logs/index.tsx diff --git a/backend/internal/log-viewer.js b/backend/internal/log-viewer.js new file mode 100644 index 000000000..abbaac510 --- /dev/null +++ b/backend/internal/log-viewer.js @@ -0,0 +1,229 @@ +import fs from "node:fs"; +import errs from "../lib/error.js"; +import internalDeadHost from "./dead-host.js"; +import internalProxyHost from "./proxy-host.js"; +import internalRedirectionHost from "./redirection-host.js"; +import internalStream from "./stream.js"; + +const SYSTEM_LOG_FILE = "/data/logs/backend.log"; +const LETSENCRYPT_LOG_FILE = "/data/logs/letsencrypt.log"; + +// Matches the access_log/error_log paths written by the nginx templates +// (see backend/templates/{proxy_host,redirection_host,dead_host,stream}.conf). +// This is a fixed, server-side lookup table - a host log path is always derived +// from a validated `host_type` enum + numeric `host_id`, never from a client-supplied +// path or filename, so there is no path-traversal surface here. +const HOST_FILE_PREFIX = { + proxy: "proxy-host", + redirection: "redirection-host", + dead: "dead-host", + stream: "stream", +}; + +const DEFAULT_LINES = 200; +const MAX_LINES = 1000; +const CHUNK_SIZE = 64 * 1024; +// Never scan further back than this, regardless of how many lines were requested, +// so a huge or pathological log file can't turn a single request into unbounded I/O. +const MAX_SCAN_BYTES = 5 * 1024 * 1024; + +/** + * Reads at most `maxLines` lines from the end of a file, without loading the + * whole file into memory. Reads backwards in fixed-size chunks until enough + * newlines have been seen, the start of the file is reached, or the hard + * MAX_SCAN_BYTES ceiling is hit. + * + * @param {String} filePath + * @param {Number} maxLines + * @returns {Promise<{lines: String[], size: Number, truncated: Boolean}>} + */ +const readLastLines = async (filePath, maxLines) => { + let handle; + try { + handle = await fs.promises.open(filePath, "r"); + const stat = await handle.stat(); + const { size } = stat; + + if (size === 0) { + return { lines: [], size: 0, truncated: false }; + } + + let position = size; + let scanned = 0; + let newlineCount = 0; + const chunks = []; + + while (position > 0 && newlineCount <= maxLines && scanned < MAX_SCAN_BYTES) { + const readSize = Math.min(CHUNK_SIZE, position); + position -= readSize; + const buffer = Buffer.alloc(readSize); + await handle.read(buffer, 0, readSize, position); + scanned += readSize; + for (let i = buffer.length - 1; i >= 0; i--) { + if (buffer[i] === 0x0a) newlineCount++; + } + chunks.unshift(buffer); + } + + const truncated = position > 0 && scanned >= MAX_SCAN_BYTES; + + // If we didn't start reading from byte 0, the first line in our buffer is only + // partial *unless* it happens that `position` landed exactly on a line boundary + // (the byte right before it is a newline) - check that one byte to avoid + // silently dropping a perfectly valid line. + let startsOnLineBoundary = position === 0; + if (position > 0) { + const boundaryByte = Buffer.alloc(1); + await handle.read(boundaryByte, 0, 1, position - 1); + startsOnLineBoundary = boundaryByte[0] === 0x0a; + } + + const text = Buffer.concat(chunks).toString("utf8"); + const allLines = text.split("\n"); + + if (!startsOnLineBoundary && allLines.length > 0) { + allLines.shift(); + } + // Drop the trailing empty element caused by a final trailing newline. + if (allLines.length > 0 && allLines[allLines.length - 1] === "") { + allLines.pop(); + } + + return { lines: allLines.slice(-maxLines), size, truncated }; + } finally { + if (handle) { + await handle.close(); + } + } +}; + +/** + * Resolves a validated {type, host_type, host_id, channel} selection to the + * fixed, absolute path of the log file on disk. Throws if the combination + * isn't a recognised source. + * + * `channel` ("access" | "error") picks which of the two log files nginx writes + * per host - it's deliberately not named "stream" to avoid confusion with the + * "stream" host_type (TCP/UDP stream hosts). + * + * @param {Object} data + * @returns {String} + */ +const resolveFilePath = (data) => { + switch (data.type) { + case "system": + return SYSTEM_LOG_FILE; + case "letsencrypt": + return LETSENCRYPT_LOG_FILE; + case "host": { + const prefix = HOST_FILE_PREFIX[data.host_type]; + if (!prefix || !data.host_id || !["access", "error"].includes(data.channel)) { + throw new errs.ValidationError("Invalid host log source"); + } + return `/data/logs/${prefix}-${data.host_id}_${data.channel}.log`; + } + default: + throw new errs.ItemNotFoundError(data.type); + } +}; + +/** + * @param {Array} rows + * @returns {Array} + */ +const toHostOptions = (rows) => + rows.map((row) => ({ + id: row.id, + label: Array.isArray(row.domain_names) ? row.domain_names.join(", ") : `Host #${row.id}`, + })); + +const internalLogViewer = { + /** + * Lists the log sources available for the log viewer: the system (backend) + * log, the Let's Encrypt (certbot) log, and one entry per host the caller + * can see, for each host type. + * + * @param {Access} access + * @returns {Promise} + */ + listSources: async (access) => { + await access.can("logs:list"); + + const [proxyHosts, redirectionHosts, deadHosts, streams] = await Promise.all([ + internalProxyHost.getAll(access), + internalRedirectionHost.getAll(access), + internalDeadHost.getAll(access), + internalStream.getAll(access), + ]); + + return { + system: { label: "System" }, + letsencrypt: { label: "Let's Encrypt" }, + hosts: { + proxy: toHostOptions(proxyHosts), + redirection: toHostOptions(redirectionHosts), + dead: toHostOptions(deadHosts), + stream: streams.map((row) => ({ + id: row.id, + label: `Port ${row.incoming_port} → ${row.forward_ip}:${row.forwarding_port}`, + })), + }, + }; + }, + + /** + * Returns the last N lines of the requested log source, optionally + * filtered by level and/or a plain-text search term. + * + * @param {Access} access + * @param {Object} data + * @param {String} data.type "system" | "letsencrypt" | "host" + * @param {String} [data.host_type] "proxy" | "redirection" | "dead" | "stream" + * @param {Number} [data.host_id] + * @param {String} [data.channel] "access" | "error" + * @param {Number} [data.lines] + * @param {String} [data.level] + * @param {String} [data.search] + * @returns {Promise} + */ + tail: async (access, data) => { + await access.can("logs:list"); + + const filePath = resolveFilePath(data); + const lines = Math.min(Math.max(data.lines || DEFAULT_LINES, 1), MAX_LINES); + + let result; + try { + result = await readLastLines(filePath, lines); + } catch (err) { + if (err.code === "ENOENT") { + return { lines: [], size: 0, truncated: false, exists: false }; + } + throw err; + } + + let outputLines = result.lines; + + // Only the system log is written in our own "LEVEL [scope]" format - level + // filtering on nginx/certbot lines would just match nothing and look like an + // empty log, so the filter is a no-op for any other source. + if (data.type === "system" && data.level) { + const needle = ` ${data.level.toUpperCase().padEnd(7)} `; + outputLines = outputLines.filter((line) => line.includes(needle)); + } + + if (data.search) { + const needle = data.search.toLowerCase(); + outputLines = outputLines.filter((line) => line.toLowerCase().includes(needle)); + } + + return { + lines: outputLines, + size: result.size, + truncated: result.truncated, + exists: true, + }; + }, +}; + +export default internalLogViewer; diff --git a/backend/lib/access/logs-list.json b/backend/lib/access/logs-list.json new file mode 100644 index 000000000..aeadc94ba --- /dev/null +++ b/backend/lib/access/logs-list.json @@ -0,0 +1,7 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + } + ] +} diff --git a/backend/logger.js b/backend/logger.js index 2b60dbff7..cd845d5fb 100644 --- a/backend/logger.js +++ b/backend/logger.js @@ -1,3 +1,5 @@ +import fs from "node:fs"; +import path from "node:path"; import signale from "signale"; import { isDebugMode } from "./lib/config.js"; @@ -5,17 +7,115 @@ const opts = { logLevel: "info", }; -const global = new signale.Signale({ scope: "Global ", ...opts }); -const migrate = new signale.Signale({ scope: "Migrate ", ...opts }); -const express = new signale.Signale({ scope: "Express ", ...opts }); -const access = new signale.Signale({ scope: "Access ", ...opts }); -const nginx = new signale.Signale({ scope: "Nginx ", ...opts }); -const ssl = new signale.Signale({ scope: "SSL ", ...opts }); -const certbot = new signale.Signale({ scope: "Certbot ", ...opts }); -const importer = new signale.Signale({ scope: "Importer ", ...opts }); -const setup = new signale.Signale({ scope: "Setup ", ...opts }); -const ipRanges = new signale.Signale({ scope: "IP Ranges", ...opts }); -const remoteVersion = new signale.Signale({ scope: "Remote Version", ...opts }); +// Methods that are actually used across the codebase (see grep of `.info(`, `.warn(`, etc). +// Only these are mirrored to the log file - decorative signale methods (star, note, watch, ...) +// are left console-only since they carry no diagnostic value worth persisting. +const PERSISTED_METHODS = ["info", "warn", "error", "debug", "success", "fatal", "complete"]; + +const LOG_FILE = "/data/logs/backend.log"; +// biome-ignore lint/suspicious/noControlCharactersInRegex: stripping ANSI colour codes before writing to disk +const ANSI_PATTERN = /\x1b\[[0-9;]*m/g; + +let fileStream = null; +let lastOpenAttempt = 0; +// If the file can't be opened (eg. running outside the standard Docker image, or in +// CI without /data), don't retry on every single log call - but do retry periodically +// so a transient issue (disk full, permissions fixed later) recovers without a restart. +const REOPEN_COOLDOWN_MS = 30 * 1000; + +/** + * Lazily opens the backend log file for appending. If the directory isn't writable, + * file logging is silently disabled and console logging continues unaffected. + * + * @returns {import('node:fs').WriteStream|null} + */ +const getFileStream = () => { + if (fileStream) { + return fileStream; + } + + const now = Date.now(); + if (now - lastOpenAttempt < REOPEN_COOLDOWN_MS) { + return null; + } + lastOpenAttempt = now; + + try { + fs.mkdirSync(path.dirname(LOG_FILE), { recursive: true }); + const stream = fs.createWriteStream(LOG_FILE, { flags: "a" }); + stream.on("error", () => { + fileStream = null; + }); + fileStream = stream; + } catch (_err) { + fileStream = null; + } + return fileStream; +}; + +/** + * Formats and appends a single log line to the backend log file. + * Never throws - a failure here must never take down the application. + * + * @param {String} level + * @param {String} scope + * @param {Array} args + */ +const writeToFile = (level, scope, args) => { + const stream = getFileStream(); + if (!stream) { + return; + } + + const message = args + .map((arg) => { + if (typeof arg === "string") return arg; + if (arg instanceof Error) return arg.stack || arg.message; + try { + return JSON.stringify(arg); + } catch (_err) { + return String(arg); + } + }) + .join(" ") + .replace(ANSI_PATTERN, ""); + + const line = `${new Date().toISOString()} ${level.toUpperCase().padEnd(7)} [${scope.trim()}] ${message}\n`; + stream.write(line); +}; + +/** + * Wraps a Signale instance so that every call to one of PERSISTED_METHODS is also + * appended to the backend log file, in addition to its normal console output. + * + * @param {Signale} instance + * @param {String} scope + * @returns {Signale} + */ +const withFileSink = (instance, scope) => { + for (const method of PERSISTED_METHODS) { + const original = instance[method].bind(instance); + instance[method] = (...args) => { + writeToFile(method, scope, args); + return original(...args); + }; + } + return instance; +}; + +const createLogger = (scope) => withFileSink(new signale.Signale({ scope, ...opts }), scope); + +const global = createLogger("Global "); +const migrate = createLogger("Migrate "); +const express = createLogger("Express "); +const access = createLogger("Access "); +const nginx = createLogger("Nginx "); +const ssl = createLogger("SSL "); +const certbot = createLogger("Certbot "); +const importer = createLogger("Importer "); +const setup = createLogger("Setup "); +const ipRanges = createLogger("IP Ranges"); +const remoteVersion = createLogger("Remote Version"); const debug = (logger, ...args) => { if (isDebugMode()) { diff --git a/backend/routes/logs.js b/backend/routes/logs.js new file mode 100644 index 000000000..9088507b3 --- /dev/null +++ b/backend/routes/logs.js @@ -0,0 +1,106 @@ +import express from "express"; +import internalLogViewer from "../internal/log-viewer.js"; +import jwtdecode from "../lib/express/jwt-decode.js"; +import validator from "../lib/validator/index.js"; +import { debug, express as logger } from "../logger.js"; + +const router = express.Router({ + caseSensitive: true, + strict: true, + mergeParams: true, +}); + +/** + * /api/logs/sources + */ +router + .route("/sources") + .options((_, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) + + /** + * GET /api/logs/sources + * + * Lists the log sources available for the log viewer + */ + .get(async (req, res, next) => { + try { + const data = await internalLogViewer.listSources(res.locals.access); + res.status(200).send(data); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +/** + * /api/logs/tail + */ +router + .route("/tail") + .options((_, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) + + /** + * GET /api/logs/tail + * + * Retrieve the last N lines of a log source + */ + .get(async (req, res, next) => { + try { + const data = await validator( + { + required: ["type"], + additionalProperties: false, + properties: { + type: { + type: "string", + enum: ["system", "letsencrypt", "host"], + }, + host_type: { + anyOf: [{ type: "null" }, { type: "string", enum: ["proxy", "redirection", "dead", "stream"] }], + }, + host_id: { + anyOf: [{ type: "null" }, { type: "integer", minimum: 1 }], + }, + channel: { + anyOf: [{ type: "null" }, { type: "string", enum: ["access", "error"] }], + }, + lines: { + anyOf: [{ type: "null" }, { type: "integer", minimum: 1, maximum: 1000 }], + }, + level: { + anyOf: [ + { type: "null" }, + { type: "string", enum: ["INFO", "WARN", "ERROR", "DEBUG", "SUCCESS", "FATAL", "COMPLETE"] }, + ], + }, + search: { + anyOf: [{ type: "null" }, { type: "string", minLength: 1, maxLength: 200 }], + }, + }, + }, + { + type: req.query.type, + host_type: typeof req.query.host_type === "string" ? req.query.host_type : null, + host_id: typeof req.query.host_id !== "undefined" ? req.query.host_id : null, + channel: typeof req.query.channel === "string" ? req.query.channel : null, + lines: typeof req.query.lines !== "undefined" ? req.query.lines : null, + level: typeof req.query.level === "string" ? req.query.level : null, + search: typeof req.query.search === "string" ? req.query.search : null, + }, + ); + + const result = await internalLogViewer.tail(res.locals.access, data); + res.status(200).send(result); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +export default router; diff --git a/backend/routes/main.js b/backend/routes/main.js index a308ea617..2d719bb48 100644 --- a/backend/routes/main.js +++ b/backend/routes/main.js @@ -6,6 +6,7 @@ import pjson from "../package.json" with { type: "json" }; import { isSetup } from "../setup.js"; import auditLogRoutes from "./audit-log.js"; import ciRoutes from "./ci.js"; +import logsRoutes from "./logs.js"; import accessListsRoutes from "./nginx/access_lists.js"; import certificatesHostsRoutes from "./nginx/certificates.js"; import deadHostsRoutes from "./nginx/dead_hosts.js"; @@ -50,6 +51,7 @@ router.use("/schema", schemaRoutes); router.use("/tokens", tokensRoutes); router.use("/users", usersRoutes); router.use("/audit-log", auditLogRoutes); +router.use("/logs", logsRoutes); router.use("/reports", reportsRoutes); router.use("/settings", settingsRoutes); router.use("/version", versionRoutes); diff --git a/backend/schema/components/log-sources-object.json b/backend/schema/components/log-sources-object.json new file mode 100644 index 000000000..e077f1a0f --- /dev/null +++ b/backend/schema/components/log-sources-object.json @@ -0,0 +1,69 @@ +{ + "type": "object", + "description": "Available log sources for the log viewer", + "required": ["system", "letsencrypt", "hosts"], + "additionalProperties": false, + "properties": { + "system": { + "type": "object", + "properties": { + "label": { + "type": "string", + "example": "System" + } + } + }, + "letsencrypt": { + "type": "object", + "properties": { + "label": { + "type": "string", + "example": "Let's Encrypt" + } + } + }, + "hosts": { + "type": "object", + "required": ["proxy", "redirection", "dead", "stream"], + "additionalProperties": false, + "properties": { + "proxy": { + "$ref": "#/$defs/host-option-list" + }, + "redirection": { + "$ref": "#/$defs/host-option-list" + }, + "dead": { + "$ref": "#/$defs/host-option-list" + }, + "stream": { + "$ref": "#/$defs/host-option-list" + } + }, + "example": { + "proxy": [{ "id": 1, "label": "example.com" }], + "redirection": [], + "dead": [], + "stream": [{ "id": 1, "label": "Port 5432 → 10.0.0.5:5432" }] + } + } + }, + "$defs": { + "host-option-list": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "label"], + "properties": { + "id": { + "$ref": "../common.json#/properties/id" + }, + "label": { + "type": "string", + "example": "example.com" + } + } + } + } + } +} diff --git a/backend/schema/components/log-tail-object.json b/backend/schema/components/log-tail-object.json new file mode 100644 index 000000000..a897f08fc --- /dev/null +++ b/backend/schema/components/log-tail-object.json @@ -0,0 +1,30 @@ +{ + "type": "object", + "description": "The last N lines of a log source", + "required": ["lines", "size", "truncated", "exists"], + "additionalProperties": false, + "properties": { + "lines": { + "type": "array", + "items": { + "type": "string" + }, + "example": ["2026-01-01T00:00:00.000Z INFO [Global ] Backend PID 1 listening on port 3000 ..."] + }, + "size": { + "type": "integer", + "description": "Size in bytes of the underlying log file", + "example": 4096 + }, + "truncated": { + "type": "boolean", + "description": "True when the file is larger than the maximum amount of data scanned per request", + "example": false + }, + "exists": { + "type": "boolean", + "description": "False when the underlying log file does not exist yet", + "example": true + } + } +} diff --git a/backend/schema/paths/logs/sources/get.json b/backend/schema/paths/logs/sources/get.json new file mode 100644 index 000000000..255698699 --- /dev/null +++ b/backend/schema/paths/logs/sources/get.json @@ -0,0 +1,36 @@ +{ + "operationId": "getLogSources", + "summary": "Get available log sources", + "tags": ["logs"], + "security": [ + { + "bearerAuth": ["admin"] + } + ], + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "examples": { + "default": { + "value": { + "system": { "label": "System" }, + "letsencrypt": { "label": "Let's Encrypt" }, + "hosts": { + "proxy": [{ "id": 1, "label": "example.com" }], + "redirection": [], + "dead": [], + "stream": [{ "id": 1, "label": "Port 5432 → 10.0.0.5:5432" }] + } + } + } + }, + "schema": { + "$ref": "../../../components/log-sources-object.json" + } + } + } + } + } +} diff --git a/backend/schema/paths/logs/tail/get.json b/backend/schema/paths/logs/tail/get.json new file mode 100644 index 000000000..2e99ede57 --- /dev/null +++ b/backend/schema/paths/logs/tail/get.json @@ -0,0 +1,100 @@ +{ + "operationId": "getLogTail", + "summary": "Get the last N lines of a log source", + "tags": ["logs"], + "security": [ + { + "bearerAuth": ["admin"] + } + ], + "parameters": [ + { + "in": "query", + "name": "type", + "required": true, + "description": "Which log source to read", + "schema": { + "type": "string", + "enum": ["system", "letsencrypt", "host"] + } + }, + { + "in": "query", + "name": "host_type", + "description": "Required when type=host", + "schema": { + "type": "string", + "enum": ["proxy", "redirection", "dead", "stream"] + } + }, + { + "in": "query", + "name": "host_id", + "description": "Required when type=host", + "schema": { + "type": "integer", + "minimum": 1 + } + }, + { + "in": "query", + "name": "channel", + "description": "Required when type=host - which log file to read (not to be confused with host_type=stream)", + "schema": { + "type": "string", + "enum": ["access", "error"] + } + }, + { + "in": "query", + "name": "lines", + "description": "Number of lines to return from the end of the file", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 200 + } + }, + { + "in": "query", + "name": "level", + "description": "Only applicable to type=system", + "schema": { + "type": "string", + "enum": ["INFO", "WARN", "ERROR", "DEBUG", "SUCCESS", "FATAL", "COMPLETE"] + } + }, + { + "in": "query", + "name": "search", + "description": "Case-insensitive plain-text search", + "schema": { + "type": "string", + "maxLength": 200 + } + } + ], + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "examples": { + "default": { + "value": { + "lines": ["2026-01-01T00:00:00.000Z INFO [Global ] Backend PID 1 listening on port 3000 ..."], + "size": 4096, + "truncated": false, + "exists": true + } + } + }, + "schema": { + "$ref": "../../../components/log-tail-object.json" + } + } + } + } + } +} diff --git a/backend/schema/swagger.json b/backend/schema/swagger.json index 4222f19dd..fd0dc33f5 100644 --- a/backend/schema/swagger.json +++ b/backend/schema/swagger.json @@ -24,6 +24,10 @@ "name": "audit-log", "description": "Endpoints related to Audit Logs" }, + { + "name": "logs", + "description": "Endpoints for viewing system, Let's Encrypt and per-host logs" + }, { "name": "access-lists", "description": "Endpoints related to Access Lists" @@ -81,6 +85,16 @@ "$ref": "./paths/audit-log/id/get.json" } }, + "/logs/sources": { + "get": { + "$ref": "./paths/logs/sources/get.json" + } + }, + "/logs/tail": { + "get": { + "$ref": "./paths/logs/tail/get.json" + } + }, "/nginx/access-lists": { "get": { "$ref": "./paths/nginx/access-lists/get.json" diff --git a/docker/rootfs/etc/logrotate.d/nginx-proxy-manager b/docker/rootfs/etc/logrotate.d/nginx-proxy-manager index de9772971..3e42324c7 100644 --- a/docker/rootfs/etc/logrotate.d/nginx-proxy-manager +++ b/docker/rootfs/etc/logrotate.d/nginx-proxy-manager @@ -25,3 +25,13 @@ kill -USR1 `cat /run/nginx/nginx.pid 2>/dev/null` 2>/dev/null || true endscript } + +/data/logs/backend.log { + su npm npm + size 10M + rotate 5 + missingok + notifempty + compress + copytruncate +} diff --git a/frontend/.gitignore b/frontend/.gitignore index a9b91bc1a..50c1dc047 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -1,7 +1,7 @@ src/locale/lang # Logs -logs +/logs/ *.log npm-debug.log* yarn-debug.log* diff --git a/frontend/src/Router.tsx b/frontend/src/Router.tsx index 6aa8f0894..e67ba00db 100644 --- a/frontend/src/Router.tsx +++ b/frontend/src/Router.tsx @@ -20,6 +20,7 @@ const Settings = lazy(() => import("src/pages/Settings")); const Certificates = lazy(() => import("src/pages/Certificates")); const Access = lazy(() => import("src/pages/Access")); const AuditLog = lazy(() => import("src/pages/AuditLog")); +const Logs = lazy(() => import("src/pages/Logs")); const Users = lazy(() => import("src/pages/Users")); const ProxyHosts = lazy(() => import("src/pages/Nginx/ProxyHosts")); const RedirectionHosts = lazy(() => import("src/pages/Nginx/RedirectionHosts")); @@ -64,6 +65,7 @@ function Router() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/backend/getLogSources.ts b/frontend/src/api/backend/getLogSources.ts new file mode 100644 index 000000000..4bec70002 --- /dev/null +++ b/frontend/src/api/backend/getLogSources.ts @@ -0,0 +1,8 @@ +import * as api from "./base"; +import type { LogSources } from "./models"; + +export async function getLogSources(): Promise { + return await api.get({ + url: "/logs/sources", + }); +} diff --git a/frontend/src/api/backend/getLogTail.ts b/frontend/src/api/backend/getLogTail.ts new file mode 100644 index 000000000..899282b32 --- /dev/null +++ b/frontend/src/api/backend/getLogTail.ts @@ -0,0 +1,19 @@ +import * as api from "./base"; +import type { LogChannel, LogHostType, LogSourceType, LogTail } from "./models"; + +export interface GetLogTailParams { + type: LogSourceType; + hostType?: LogHostType; + hostId?: number; + channel?: LogChannel; + lines?: number; + level?: string; + search?: string; +} + +export async function getLogTail(params: GetLogTailParams): Promise { + return await api.get({ + url: "/logs/tail", + params: { ...params }, + }); +} diff --git a/frontend/src/api/backend/index.ts b/frontend/src/api/backend/index.ts index 40cb4142f..99e682294 100644 --- a/frontend/src/api/backend/index.ts +++ b/frontend/src/api/backend/index.ts @@ -26,6 +26,8 @@ export * from "./getDeadHost"; export * from "./getDeadHosts"; export * from "./getHealth"; export * from "./getHostsReport"; +export * from "./getLogSources"; +export * from "./getLogTail"; export * from "./getProxyHost"; export * from "./getProxyHosts"; export * from "./getRedirectionHost"; diff --git a/frontend/src/api/backend/models.ts b/frontend/src/api/backend/models.ts index 2ae0b0834..3febec790 100644 --- a/frontend/src/api/backend/models.ts +++ b/frontend/src/api/backend/models.ts @@ -44,6 +44,30 @@ export interface AuditLog { user?: User; } +export type LogSourceType = "system" | "letsencrypt" | "host"; +export type LogHostType = "proxy" | "redirection" | "dead" | "stream"; +// Which of the two files nginx writes per host - named "channel", not "stream", +// to avoid confusion with the "stream" LogHostType (TCP/UDP stream hosts). +export type LogChannel = "access" | "error"; + +export interface LogHostOption { + id: number; + label: string; +} + +export interface LogSources { + system: { label: string }; + letsencrypt: { label: string }; + hosts: Record; +} + +export interface LogTail { + lines: string[]; + size: number; + truncated: boolean; + exists: boolean; +} + export interface AccessList { id?: number; createdOn?: string; diff --git a/frontend/src/components/SiteMenu.tsx b/frontend/src/components/SiteMenu.tsx index 565fa01bf..c7274751a 100644 --- a/frontend/src/components/SiteMenu.tsx +++ b/frontend/src/components/SiteMenu.tsx @@ -1,6 +1,7 @@ import { IconBook, IconDeviceDesktop, + IconFileText, IconHome, IconLock, IconSettings, @@ -95,6 +96,12 @@ const menuItems: MenuItem[] = [ label: "auditlogs", permissionSection: ADMIN, }, + { + to: "/logs", + icon: IconFileText, + label: "logs", + permissionSection: ADMIN, + }, { to: "/settings", icon: IconSettings, diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index 744190ade..03dd9881a 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -10,6 +10,8 @@ export * from "./useDeadHosts"; export * from "./useDnsProviders"; export * from "./useHealth"; export * from "./useHostReport"; +export * from "./useLogSources"; +export * from "./useLogTail"; export * from "./useProxyHost"; export * from "./useProxyHosts"; export * from "./useRedirectionHost"; diff --git a/frontend/src/hooks/useLogSources.ts b/frontend/src/hooks/useLogSources.ts new file mode 100644 index 000000000..3f77bbd1e --- /dev/null +++ b/frontend/src/hooks/useLogSources.ts @@ -0,0 +1,15 @@ +import { useQuery } from "@tanstack/react-query"; +import { getLogSources, type LogSources } from "src/api/backend"; + +const fetchLogSources = () => getLogSources(); + +const useLogSources = (options = {}) => { + return useQuery({ + queryKey: ["log-sources"], + queryFn: fetchLogSources, + staleTime: 30 * 1000, + ...options, + }); +}; + +export { fetchLogSources, useLogSources }; diff --git a/frontend/src/hooks/useLogTail.ts b/frontend/src/hooks/useLogTail.ts new file mode 100644 index 000000000..d817dd0c1 --- /dev/null +++ b/frontend/src/hooks/useLogTail.ts @@ -0,0 +1,25 @@ +import { useQuery } from "@tanstack/react-query"; +import { type GetLogTailParams, getLogTail, type LogTail } from "src/api/backend"; + +const POLL_INTERVAL_MS = 5000; + +interface UseLogTailOptions extends GetLogTailParams { + live?: boolean; +} + +// Polls for new log lines while `live` is true. React Query only runs the interval +// while the tab is focused (refetchIntervalInBackground defaults to false), so an +// idle/backgrounded browser tab never generates load. +const useLogTail = ({ live = true, ...params }: UseLogTailOptions) => { + const enabled = params.type === "host" ? Boolean(params.hostType && params.hostId && params.channel) : true; + + return useQuery({ + queryKey: ["log-tail", params], + queryFn: () => getLogTail(params), + enabled, + refetchInterval: live ? POLL_INTERVAL_MS : false, + placeholderData: (previousData) => previousData, + }); +}; + +export { useLogTail }; diff --git a/frontend/src/locale/src/en.json b/frontend/src/locale/src/en.json index 88fdd724e..2626673df 100644 --- a/frontend/src/locale/src/en.json +++ b/frontend/src/locale/src/en.json @@ -488,6 +488,51 @@ "login.title": { "defaultMessage": "Login to your account" }, + "logs": { + "defaultMessage": "Logs" + }, + "logs.channel.access": { + "defaultMessage": "Access" + }, + "logs.channel.error": { + "defaultMessage": "Error" + }, + "logs.download": { + "defaultMessage": "Download" + }, + "logs.empty": { + "defaultMessage": "No log entries yet." + }, + "logs.level": { + "defaultMessage": "Level" + }, + "logs.level.all": { + "defaultMessage": "All Levels" + }, + "logs.lines": { + "defaultMessage": "Lines" + }, + "logs.live": { + "defaultMessage": "Live" + }, + "logs.paused": { + "defaultMessage": "Paused" + }, + "logs.refresh": { + "defaultMessage": "Refresh" + }, + "logs.search-placeholder": { + "defaultMessage": "Search log…" + }, + "logs.source": { + "defaultMessage": "Source" + }, + "logs.source.system": { + "defaultMessage": "System" + }, + "logs.truncated-warning": { + "defaultMessage": "Only the most recent portion of this file is shown." + }, "nginx-config.label": { "defaultMessage": "Custom Nginx Configuration" }, diff --git a/frontend/src/locale/src/et.json b/frontend/src/locale/src/et.json index a5b5393b3..9fd0f74d1 100644 --- a/frontend/src/locale/src/et.json +++ b/frontend/src/locale/src/et.json @@ -488,6 +488,51 @@ "login.title": { "defaultMessage": "Logi kontole sisse" }, + "logs": { + "defaultMessage": "Logid" + }, + "logs.channel.access": { + "defaultMessage": "Juurdepääs" + }, + "logs.channel.error": { + "defaultMessage": "Viga" + }, + "logs.download": { + "defaultMessage": "Laadi alla" + }, + "logs.empty": { + "defaultMessage": "Logikirjeid pole veel." + }, + "logs.level": { + "defaultMessage": "Tase" + }, + "logs.level.all": { + "defaultMessage": "Kõik tasemed" + }, + "logs.lines": { + "defaultMessage": "Read" + }, + "logs.live": { + "defaultMessage": "Reaalajas" + }, + "logs.paused": { + "defaultMessage": "Peatatud" + }, + "logs.refresh": { + "defaultMessage": "Värskenda" + }, + "logs.search-placeholder": { + "defaultMessage": "Otsi logist…" + }, + "logs.source": { + "defaultMessage": "Allikas" + }, + "logs.source.system": { + "defaultMessage": "Süsteem" + }, + "logs.truncated-warning": { + "defaultMessage": "Kuvatakse ainult faili kõige uuem osa." + }, "nginx-config.label": { "defaultMessage": "Kohandatud Nginx seadistus" }, diff --git a/frontend/src/pages/Logs/LogLines.tsx b/frontend/src/pages/Logs/LogLines.tsx new file mode 100644 index 000000000..7e1d4763c --- /dev/null +++ b/frontend/src/pages/Logs/LogLines.tsx @@ -0,0 +1,76 @@ +import cn from "classnames"; +import { useEffect, useRef } from "react"; +import { T } from "src/locale"; +import styles from "./LogViewer.module.css"; + +const LEVEL_PATTERN = /\b(ERROR|FATAL|WARN|SUCCESS|COMPLETE|DEBUG|INFO)\b/; + +const levelClassName = (line: string): string | undefined => { + const match = line.match(LEVEL_PATTERN); + if (!match) { + return undefined; + } + switch (match[1]) { + case "ERROR": + case "FATAL": + return "text-danger"; + case "WARN": + return "text-warning"; + case "SUCCESS": + case "COMPLETE": + return "text-success"; + case "DEBUG": + return "text-secondary"; + default: + return undefined; + } +}; + +// How close to the bottom (in pixels) the user has to be for auto-scroll to keep +// following new lines. Scrolling further up than this to read history disables it, +// so newly polled lines never yank the viewport away from what's being read. +const AUTO_SCROLL_THRESHOLD_PX = 40; + +interface Props { + lines: string[]; +} + +export default function LogLines({ lines }: Props) { + const containerRef = useRef(null); + const stickToBottomRef = useRef(true); + + const handleScroll = () => { + const el = containerRef.current; + if (!el) { + return; + } + const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; + stickToBottomRef.current = distanceFromBottom < AUTO_SCROLL_THRESHOLD_PX; + }; + + // biome-ignore lint/correctness/useExhaustiveDependencies: lines is a re-scroll trigger, not read in the body + useEffect(() => { + const el = containerRef.current; + if (el && stickToBottomRef.current) { + el.scrollTop = el.scrollHeight; + } + }, [lines]); + + if (!lines.length) { + return ( +
+ +
+ ); + } + + return ( +
+			{lines.map((line, idx) => (
+				
+					{line}
+				
+			))}
+		
+ ); +} diff --git a/frontend/src/pages/Logs/LogViewer.module.css b/frontend/src/pages/Logs/LogViewer.module.css new file mode 100644 index 000000000..09cb1c88c --- /dev/null +++ b/frontend/src/pages/Logs/LogViewer.module.css @@ -0,0 +1,15 @@ +.logBox { + height: 65vh; + overflow-y: auto; + padding: 0.75rem 1rem; + margin: 0; + font-family: var(--tblr-font-monospace, ui-monospace, monospace); + font-size: 0.8125rem; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-all; +} + +.logLine { + display: block; +} diff --git a/frontend/src/pages/Logs/LogViewer.tsx b/frontend/src/pages/Logs/LogViewer.tsx new file mode 100644 index 000000000..728420c12 --- /dev/null +++ b/frontend/src/pages/Logs/LogViewer.tsx @@ -0,0 +1,249 @@ +import { useEffect, useMemo, useState } from "react"; +import Alert from "react-bootstrap/Alert"; +import type { LogChannel, LogHostType, LogSourceType } from "src/api/backend"; +import { Loading } from "src/components"; +import { useLogSources, useLogTail } from "src/hooks"; +import { T, intl } from "src/locale"; +import LogLines from "./LogLines"; + +const LINES_OPTIONS = [100, 200, 500, 1000]; +const LEVEL_OPTIONS = ["INFO", "WARN", "ERROR", "DEBUG", "SUCCESS", "FATAL", "COMPLETE"]; +const SEARCH_DEBOUNCE_MS = 300; + +const HOST_TYPES: { type: LogHostType; labelId: string }[] = [ + { type: "proxy", labelId: "proxy-hosts" }, + { type: "redirection", labelId: "redirection-hosts" }, + { type: "dead", labelId: "dead-hosts" }, + { type: "stream", labelId: "streams" }, +]; + +interface Selection { + type: LogSourceType; + hostType?: LogHostType; + hostId?: number; +} + +const encodeSelection = (selection: Selection): string => { + if (selection.type === "host") { + return `host:${selection.hostType}:${selection.hostId}`; + } + return selection.type; +}; + +const decodeSelection = (value: string): Selection => { + if (value === "system" || value === "letsencrypt") { + return { type: value }; + } + const [, hostType, hostId] = value.split(":"); + return { type: "host", hostType: hostType as LogHostType, hostId: Number(hostId) }; +}; + +const formatBytes = (bytes: number): string => { + if (bytes < 1024) { + return `${bytes} B`; + } + const units = ["KB", "MB", "GB"]; + let value = bytes / 1024; + let unitIndex = 0; + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex++; + } + return `${value.toFixed(1)} ${units[unitIndex]}`; +}; + +export default function LogViewer() { + const sourcesQuery = useLogSources(); + + const [selection, setSelection] = useState({ type: "system" }); + const [channel, setChannel] = useState("access"); + const [level, setLevel] = useState(""); + const [lines, setLines] = useState(LINES_OPTIONS[1]); + const [live, setLive] = useState(true); + const [searchInput, setSearchInput] = useState(""); + const [search, setSearch] = useState(""); + + useEffect(() => { + const handle = setTimeout(() => setSearch(searchInput.trim()), SEARCH_DEBOUNCE_MS); + return () => clearTimeout(handle); + }, [searchInput]); + + const tailQuery = useLogTail({ + type: selection.type, + hostType: selection.hostType, + hostId: selection.hostId, + channel: selection.type === "host" ? channel : undefined, + lines, + level: selection.type === "system" && level ? level : undefined, + search: search || undefined, + live, + }); + + const hostGroups = useMemo(() => { + if (!sourcesQuery.data) { + return []; + } + return HOST_TYPES.map(({ type, labelId }) => ({ + type, + labelId, + options: sourcesQuery.data.hosts[type] || [], + })).filter((group) => group.options.length > 0); + }, [sourcesQuery.data]); + + const handleDownload = () => { + const content = (tailQuery.data?.lines || []).join("\n"); + const blob = new Blob([content], { type: "text/plain" }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${encodeSelection(selection).replace(/:/g, "-")}.log`; + a.click(); + window.URL.revokeObjectURL(url); + }; + + return ( +
+
+
+
+
+

+ +

+
+ {typeof tailQuery.data?.size === "number" && ( +
+ {formatBytes(tailQuery.data.size)} +
+ )} +
+ +
+ {selection.type === "host" && ( +
+
+ + +
+
+ )} + {selection.type === "system" && ( +
+ +
+ )} +
+ setSearchInput(e.target.value)} + /> +
+
+ +
+
+ + + +
+
+
+ + {tailQuery.isError && ( +
+ {tailQuery.error?.message || "Unknown error"} +
+ )} + + {tailQuery.data?.truncated && ( +
+ + + +
+ )} + + {sourcesQuery.isLoading || (tailQuery.isLoading && !tailQuery.data) ? ( +
+ +
+ ) : ( + + )} +
+ ); +} diff --git a/frontend/src/pages/Logs/index.tsx b/frontend/src/pages/Logs/index.tsx new file mode 100644 index 000000000..eb72b8e11 --- /dev/null +++ b/frontend/src/pages/Logs/index.tsx @@ -0,0 +1,13 @@ +import { HasPermission } from "src/components"; +import { ADMIN, VIEW } from "src/modules/Permissions"; +import LogViewer from "./LogViewer"; + +const Logs = () => { + return ( + + + + ); +}; + +export default Logs; From 9445038b36eb611718c5edffcc911e5373225524 Mon Sep 17 00:00:00 2001 From: carlosalbertorg Date: Wed, 23 Sep 2026 07:00:19 -0300 Subject: [PATCH 2/2] fix: Use forwarding_host instead of the renamed forward_ip column The stream table's forward_ip column was renamed to forwarding_host back in migration 20210423103500_stream_domain.js, so the stream log source labels were always showing "undefined" for the forwarding target. Spotted by @jc21 in review. --- backend/internal/log-viewer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/internal/log-viewer.js b/backend/internal/log-viewer.js index abbaac510..2b1b8e967 100644 --- a/backend/internal/log-viewer.js +++ b/backend/internal/log-viewer.js @@ -165,7 +165,7 @@ const internalLogViewer = { dead: toHostOptions(deadHosts), stream: streams.map((row) => ({ id: row.id, - label: `Port ${row.incoming_port} → ${row.forward_ip}:${row.forwarding_port}`, + label: `Port ${row.incoming_port} → ${row.forwarding_host}:${row.forwarding_port}`, })), }, };