mirror of
https://github.com/NginxProxyManager/nginx-proxy-manager.git
synced 2026-09-30 17:00:13 +01:00
Merge pull request #5878 from carlosalbertorg/feat/log-viewer
feat: Add a Logs viewer to the admin UI
This commit is contained in:
@@ -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;
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "roles#/definitions/admin"
|
||||
}
|
||||
]
|
||||
}
|
||||
+111
-11
@@ -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()) {
|
||||
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -1,7 +1,7 @@
|
||||
src/locale/lang
|
||||
|
||||
# Logs
|
||||
logs
|
||||
/logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user