diff --git a/backend/internal/log-viewer.js b/backend/internal/log-viewer.js new file mode 100644 index 000000000..2b1b8e967 --- /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.forwarding_host}:${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 d0ee7b18b..dc7556509 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")); @@ -65,6 +66,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;