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

@@ -26,6 +26,11 @@ test("API: до загрузки индекса сервер жив, но не
assert.equal(health.body.ready, false);
assert.equal(health.body.client, "127.0.0.1");
assert.equal(typeof health.body.apiVersion, "number");
// Public health says alive, ready and versions; the rest is on the dashboard.
assert.equal(health.body.traffic, undefined);
assert.equal(health.body.telegram, undefined);
// Without DASHBOARD_PASSWORD the dashboard is off.
assert.equal((await get("/status/")).status, 404);
const ready = await json("/api/ready");
assert.equal(ready.status, 503);
assert.equal(ready.body.code, "not_ready");

View File

@@ -21,13 +21,14 @@ let api;
const lines = [];
before(async () => {
mock.method(console, "log", (line) => lines.push(line));
api = await startApi({ PSU_REQUEST_LOG: "on", PSU_API_RATE_LIMIT: "6" });
api = await startApi({ PSU_REQUEST_LOG: "on", PSU_API_RATE_LIMIT: "6", DASHBOARD_PASSWORD: "secret" });
});
after(() => {
mock.restoreAll();
api?.stop();
});
const get = (path, headers) => fetch(api.base + path, { headers });
const status = async () => (await fetch(api.base + "/status/data.json", { headers: { Authorization: "Basic " + Buffer.from("admin:secret").toString("base64") } })).json();
test("Метрики: строка журнала на запрос и сводка в /api/health", async () => {
const groups = await get("/api/groups?" + new URLSearchParams({ q: "23-БА" }));
@@ -51,9 +52,11 @@ test("Метрики: строка журнала на запрос и свод
assert.equal(typeof notFound.ms, "number");
assert.ok(!logged.some((l) => l.path.startsWith("/api/health")));
const { traffic } = await (await get("/api/health")).json();
const { traffic } = await status();
const hour = traffic.lastHour;
assert.equal(hour.requests, 4);
assert.equal(traffic.series.length, 60);
assert.equal(traffic.series.at(-1).requests, 4);
assert.equal(hour.notModified, 1);
assert.equal(hour.notModifiedShare, 0.25);
assert.equal(hour.clientErrors, 1);
@@ -71,7 +74,7 @@ test("Метрики: лимит запросов и сбои университ
assert.equal((await get(`/api/original/${broken.id}`)).status, 502);
// The limit of 6 is reached: 4 earlier requests, the catalog and the original above.
assert.equal((await get("/api/nope")).status, 429);
const { traffic } = await (await get("/api/health")).json();
const { traffic } = await status();
const hour = traffic.lastHour;
assert.equal(hour.limited, 1);
assert.equal(hour.sourceErrors, 1);

54
test/status.test.js Normal file
View File

@@ -0,0 +1,54 @@
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import { startApi } from "./support/nextcloud.js";
// The dashboard at /status: the browser's sign-in window, a lockout after ten wrong passwords,
// the page under the strict CSP, and its data.
let api;
before(async () => {
api = await startApi({ DASHBOARD_PASSWORD: "пароль-123" });
});
after(() => api?.stop());
const basic = (user, password) => ({ Authorization: "Basic " + Buffer.from(`${user}:${password}`).toString("base64") });
const get = (path, headers = {}) => fetch(api.base + path, { headers });
test("Дашборд: без пароля — окно входа браузера", async () => {
const response = await get("/status/");
assert.equal(response.status, 401);
assert.match(response.headers.get("www-authenticate"), /^Basic realm=/);
assert.equal(response.headers.get("cache-control"), "no-store");
assert.equal(response.headers.get("x-robots-tag"), "noindex");
assert.equal((await get("/status/data.json")).status, 401);
});
test("Дашборд: с паролем — страница, её файлы и данные", async () => {
const auth = basic("любой", "пароль-123");
const page = await get("/status/", auth);
assert.equal(page.status, 200);
const html = await page.text();
assert.match(html, /<script type="module" src="status\.js"><\/script>/);
// Nothing inline: the strict CSP allows only the server's own files.
assert.doesNotMatch(html, /<script>|style=/);
assert.match(page.headers.get("content-security-policy"), /script-src 'self'/);
assert.equal((await get("/status/status.js", auth)).status, 200);
assert.equal((await get("/status/status.css", auth)).status, 200);
await get("/api/groups?q=23");
const data = await (await get("/status/data.json", auth)).json();
assert.equal(typeof data.server.apiVersion, "number");
assert.ok(Array.isArray(data.server.waiting));
assert.ok(data.catalog.files > 0);
assert.equal(data.directory.documents, 2);
assert.equal(data.traffic.series.length, 60);
assert.ok(Array.isArray(data.events));
assert.equal(typeof data.diagnostics.problems, "number");
assert.equal(data.diagnostics.failed.length, 2);
assert.equal(data.telegram.enabled, false);
});
test("Дашборд: десять неверных паролей — блокировка на 10 минут", async () => {
for (let i = 0; i < 10; i++) assert.equal((await get("/status/", basic("admin", "не тот"))).status, 401);
const locked = await get("/status/", basic("admin", "пароль-123"));
assert.equal(locked.status, 429);
assert.ok(Number(locked.headers.get("retry-after")) > 0);
});

View File

@@ -7,7 +7,8 @@ import { startApi, fixture } from "./support/nextcloud.js";
// rebuilt only for a changed document, and messages to the owner in Telegram (a stand-in here).
let api, telegram, watch;
const messages = [];
const health = async () => (await fetch(api.base + "/api/health")).json();
// What the dashboard shows (/status, behind its password).
const health = async () => (await fetch(api.base + "/status/data.json", { headers: { Authorization: "Basic " + Buffer.from("admin:secret").toString("base64") } })).json();
const lastMessage = () => messages.at(-1)?.text || "";
before(async () => {
@@ -29,6 +30,7 @@ before(async () => {
PSU_ALERT_SOURCE_DOWN_MS: "0",
// Every check reaches the stand-in, however soon after the last one.
PSU_MIN_REFRESH_MS: "0",
DASHBOARD_PASSWORD: "secret",
});
watch = await import("../server/watch.js");
});
@@ -109,5 +111,13 @@ test("Наблюдение: университет не отвечает и сн
await watch.watchOnce();
assert.equal(messages.length, 5);
assert.match(lastMessage(), /снова отвечает/);
assert.equal((await health()).watch.lastError, null);
const { watch: after, events } = await health();
assert.equal(after.lastError, null);
// The dashboard's feed, newest first: everything above, republished-as-is included.
assert.deepEqual(
events.map((e) => e.kind),
["source_up", "source_down", "republished", "republished", "new"],
);
assert.equal(events[2].text, "без новых подозрительных ячеек");
assert.match(events[3].text, /новых подозрительных ячеек: 1/);
});