feat: implement dashboard with authentication and event logging

- Added dashboard functionality at /status with Basic Auth using DASHBOARD_PASSWORD.
- Created server-side event logging to track catalog and university status.
- Implemented traffic metrics tracking and display on the dashboard.
- Enhanced API tests to cover new dashboard features and authentication.
- Introduced CSS and JS for dashboard styling and functionality.
- Added tests for dashboard access control and data retrieval.
This commit is contained in:
2026-10-21 00:04:33 +03:00
parent de72d003b8
commit 3ae7b38fca
15 changed files with 1216 additions and 74 deletions

View File

@@ -14,6 +14,8 @@ import { securityHeaders, requestTimeout } from "./protect.js";
import { startWatch, watchOnce, watchStatus } from "./watch.js";
import { telegram } from "./alerts.js";
import { requestLog, trafficStatus } from "./metrics.js";
import { dashboardAuth } from "./auth.js";
import { listEvents } from "./events.js";
import {
getIndex,
indexStatus,
@@ -75,8 +77,9 @@ app.use("/api", (_req, res, next) => {
// 9 — a request limit on /api (429 too_many_requests), a 30 s time limit (504 timeout), security headers;
// 10 — parserVersion in /api/catalog;
// 11 — the catalog is checked by ETag every 5 minutes; `watch`, `telegram` and `catalog.lastCrawl` in /api/health;
// 12 — a JSON log line per request and `traffic` in /api/health.
const API_VERSION = 12;
// 12 — a JSON log line per request;
// 13 — the dashboard at /status (password), /api/health only says alive, ready and versions.
const API_VERSION = 13;
/**
* What the server still waits for before it answers from memory: the catalog, the week table
@@ -85,25 +88,55 @@ const API_VERSION = 12;
const waitingFor = (sources, directory) =>
[!sources.catalog && "catalog", !sources.calendar && "calendar", !directory.builtAt && "index"].filter(Boolean);
// Liveness plus what the server currently reads from the university, without network calls.
// Public: alive, ready, versions. Everything else about the server is behind the dashboard's
// password (/status), as it names documents, clients and the owner's set-up.
app.get("/api/health", async (req, res) => {
const sources = await sourceStatus().catch((e) => ({ error: e.message }));
const sources = await sourceStatus().catch(() => ({}));
const waiting = waitingFor(sources, indexStatus());
res.json({
ok: true,
// The caller's address as the server sees it: behind a proxy it must be the client's own,
// or every visitor shares one request limit.
client: req.ip,
ready: waitingFor(sources, indexStatus()).length === 0,
ready: waiting.length === 0,
waiting,
apiVersion: API_VERSION,
parserVersion: PARSER_VERSION,
...sources,
directory: indexStatus(),
// The server's own checks of the catalog and its messages to the owner.
watch: watchStatus,
// What the API was asked in the last hour and since the start (server/metrics.js).
traffic: trafficStatus(),
telegram: { enabled: telegram.enabled, sent: telegram.sent, lastSentAt: telegram.lastSentAt, lastError: telegram.lastError },
year: sources.catalog?.year || null,
});
});
const startedAt = new Date().toISOString();
// The dashboard (server/status/) and its data, behind DASHBOARD_PASSWORD. The data sits under
// /status too, so the browser sends the password it was given for the page.
const statusPage = fileURLToPath(new URL("./status/", import.meta.url));
app.use("/status", dashboardAuth());
app.get("/status/data.json", async (_req, res, next) => {
try {
const sources = await sourceStatus();
const directory = indexStatus();
const idx = readyIndex();
res.json({
server: {
apiVersion: API_VERSION,
parserVersion: PARSER_VERSION,
startedAt,
node: process.version,
ready: waitingFor(sources, directory).length === 0,
waiting: waitingFor(sources, directory),
},
...sources,
directory,
watch: watchStatus,
telegram: { enabled: telegram.enabled, sent: telegram.sent, lastSentAt: telegram.lastSentAt, lastError: telegram.lastError },
traffic: trafficStatus(),
events: await listEvents(50),
diagnostics: idx && directory.builtAt ? catalogDiagnostics(idx) : null,
});
} catch (e) {
next(e);
}
});
app.use("/status", express.static(statusPage, { maxAge: 0 }));
// Readiness for Docker and a proxy: 503 until the catalog, the week table and the index are
// loaded, so traffic does not reach a server whose first requests would wait half a minute.
app.get("/api/ready", async (_req, res) => {