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.
This commit is contained in:
carlosalbertorg
2026-09-22 13:11:38 -03:00
parent 73f784ea95
commit 4282d6c6e9
27 changed files with 1262 additions and 12 deletions
+229
View File
@@ -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;
+7
View File
@@ -0,0 +1,7 @@
{
"anyOf": [
{
"$ref": "roles#/definitions/admin"
}
]
}
+111 -11
View File
@@ -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()) {
+106
View File
@@ -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;
+2
View File
@@ -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);
@@ -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"
}
}
}
}
}
}
@@ -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
}
}
}
@@ -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"
}
}
}
}
}
}
+100
View File
@@ -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"
}
}
}
}
}
}
+14
View File
@@ -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"
@@ -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
}
+1 -1
View File
@@ -1,7 +1,7 @@
src/locale/lang
# Logs
logs
/logs/
*.log
npm-debug.log*
yarn-debug.log*
+2
View File
@@ -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() {
<Route path="/certificates" element={<Certificates />} />
<Route path="/access" element={<Access />} />
<Route path="/audit-log" element={<AuditLog />} />
<Route path="/logs" element={<Logs />} />
<Route path="/settings" element={<Settings />} />
<Route path="/users" element={<Users />} />
<Route path="/nginx/proxy" element={<ProxyHosts />} />
@@ -0,0 +1,8 @@
import * as api from "./base";
import type { LogSources } from "./models";
export async function getLogSources(): Promise<LogSources> {
return await api.get({
url: "/logs/sources",
});
}
+19
View File
@@ -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<LogTail> {
return await api.get({
url: "/logs/tail",
params: { ...params },
});
}
+2
View File
@@ -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";
+24
View File
@@ -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<LogHostType, LogHostOption[]>;
}
export interface LogTail {
lines: string[];
size: number;
truncated: boolean;
exists: boolean;
}
export interface AccessList {
id?: number;
createdOn?: string;
+7
View File
@@ -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,
+2
View File
@@ -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";
+15
View File
@@ -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<LogSources, Error>({
queryKey: ["log-sources"],
queryFn: fetchLogSources,
staleTime: 30 * 1000,
...options,
});
};
export { fetchLogSources, useLogSources };
+25
View File
@@ -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<LogTail, Error>({
queryKey: ["log-tail", params],
queryFn: () => getLogTail(params),
enabled,
refetchInterval: live ? POLL_INTERVAL_MS : false,
placeholderData: (previousData) => previousData,
});
};
export { useLogTail };
+45
View File
@@ -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"
},
+45
View File
@@ -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"
},
+76
View File
@@ -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<HTMLPreElement>(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 (
<div className={cn(styles.logBox, "text-secondary")}>
<T id="logs.empty" />
</div>
);
}
return (
<pre ref={containerRef} onScroll={handleScroll} className={styles.logBox}>
{lines.map((line, idx) => (
<span key={idx} className={cn(styles.logLine, levelClassName(line))}>
{line}
</span>
))}
</pre>
);
}
@@ -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;
}
+249
View File
@@ -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<Selection>({ type: "system" });
const [channel, setChannel] = useState<LogChannel>("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 (
<div className="card mt-4">
<div className="card-status-top bg-purple" />
<div className="card-header">
<div className="row w-full g-2 align-items-center">
<div className="col-auto">
<h2 className="mt-1 mb-0">
<T id="logs" />
</h2>
</div>
{typeof tailQuery.data?.size === "number" && (
<div className="col-auto">
<span className="text-secondary small">{formatBytes(tailQuery.data.size)}</span>
</div>
)}
<div className="col-auto">
<select
className="form-select form-select-sm"
value={encodeSelection(selection)}
onChange={(e) => {
setSelection(decodeSelection(e.target.value));
setChannel("access");
}}
>
<option value="system">{intl.formatMessage({ id: "logs.source.system" })}</option>
<option value="letsencrypt">{intl.formatMessage({ id: "lets-encrypt" })}</option>
{hostGroups.map((group) => (
<optgroup key={group.type} label={intl.formatMessage({ id: group.labelId })}>
{group.options.map((option) => (
<option key={option.id} value={`host:${group.type}:${option.id}`}>
{option.label}
</option>
))}
</optgroup>
))}
</select>
</div>
{selection.type === "host" && (
<div className="col-auto">
<div className="btn-group" role="group">
<button
type="button"
className={`btn btn-sm ${channel === "access" ? "btn-primary" : "btn-outline-primary"}`}
onClick={() => setChannel("access")}
>
<T id="logs.channel.access" />
</button>
<button
type="button"
className={`btn btn-sm ${channel === "error" ? "btn-primary" : "btn-outline-primary"}`}
onClick={() => setChannel("error")}
>
<T id="logs.channel.error" />
</button>
</div>
</div>
)}
{selection.type === "system" && (
<div className="col-auto">
<select
className="form-select form-select-sm"
value={level}
onChange={(e) => setLevel(e.target.value)}
>
<option value="">{intl.formatMessage({ id: "logs.level.all" })}</option>
{LEVEL_OPTIONS.map((lvl) => (
<option key={lvl} value={lvl}>
{lvl}
</option>
))}
</select>
</div>
)}
<div className="col-auto">
<input
type="search"
className="form-control form-control-sm"
placeholder={intl.formatMessage({ id: "logs.search-placeholder" })}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
/>
</div>
<div className="col-auto">
<select
className="form-select form-select-sm"
value={lines}
onChange={(e) => setLines(Number(e.target.value))}
>
{LINES_OPTIONS.map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
</div>
<div className="col-auto ms-auto d-flex gap-2">
<button
type="button"
className={`btn btn-sm ${live ? "btn-success" : "btn-outline-secondary"}`}
onClick={() => setLive((v) => !v)}
>
<T id={live ? "logs.live" : "logs.paused"} />
</button>
<button
type="button"
className="btn btn-sm btn-outline-secondary"
onClick={() => tailQuery.refetch()}
>
<T id="logs.refresh" />
</button>
<button
type="button"
className="btn btn-sm btn-outline-secondary"
disabled={!tailQuery.data?.lines.length}
onClick={handleDownload}
>
<T id="logs.download" />
</button>
</div>
</div>
</div>
{tailQuery.isError && (
<div className="card-body">
<Alert variant="danger">{tailQuery.error?.message || "Unknown error"}</Alert>
</div>
)}
{tailQuery.data?.truncated && (
<div className="card-body pb-0">
<Alert variant="warning">
<T id="logs.truncated-warning" />
</Alert>
</div>
)}
{sourcesQuery.isLoading || (tailQuery.isLoading && !tailQuery.data) ? (
<div className="card-body">
<Loading noLogo />
</div>
) : (
<LogLines lines={tailQuery.data?.lines || []} />
)}
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { HasPermission } from "src/components";
import { ADMIN, VIEW } from "src/modules/Permissions";
import LogViewer from "./LogViewer";
const Logs = () => {
return (
<HasPermission section={ADMIN} permission={VIEW} pageLoading loadingNoLogo>
<LogViewer />
</HasPermission>
);
};
export default Logs;