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
@@ -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"