Merge pull request #5878 from carlosalbertorg/feat/log-viewer

feat: Add a Logs viewer to the admin UI
This commit is contained in:
jc21
2026-09-23 20:23:26 +10:00
committed by GitHub
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.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;
+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"));
@@ -65,6 +66,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;