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

@@ -43,17 +43,25 @@ Vite работает на http://127.0.0.1:5173 и проксирует `/api`
[Каталог университета](https://nextcloud.psu.by/index.php/s/no5zKbMrns6j5jQ) читается через публичный DAV. Сервер обращается только к этой общей папке. Другие ссылки не принимаются.
**Учебный год выбирается сам.** Сервер читает корень каталога, находит папки вида `2026-2027` и с 1 сентября (Europe/Minsk) берёт папку нового года. Если её ещё нет, используется самая новая из прошлых, а `/api/health` предупреждает об этом. Таблица белых и зелёных недель и расписание звонков ищутся по смыслу имени («бел» + «зел» + «недел», «звонк»), сначала в папке года, затем в корне. Можно выложить несколько таблиц недель: они объединяются, при совпадении дат побеждает более новый файл. В список расписаний эти справочные файлы не попадают.
**Учебный год выбирается сам.** Сервер читает корень каталога, находит папки вида `2026-2027` и с 1 сентября (Europe/Minsk) берёт папку нового года. Если её ещё нет, используется самая новая из прошлых, а дашборд `/status` предупреждает об этом. Таблица белых и зелёных недель и расписание звонков ищутся по смыслу имени («бел» + «зел» + «недел», «звонк»), сначала в папке года, затем в корне. Можно выложить несколько таблиц недель: они объединяются, при совпадении дат побеждает более новый файл. В список расписаний эти справочные файлы не попадают.
**Семестры.** Таблица делится на семестры там, где нумерация недель начинается заново или между неделями больше трёх недель. Расписание связывается со своим семестром по тексту Word («осенний семестр 2026/2027»), расписание сессии — по своим датам, документ без указания семестра — по семестру, в котором сегодняшняя дата. Если семестр не найден (например, весенней таблицы ещё нет), даты не рассчитываются и показываются все строки Word.
**Обновления у студента.** При запуске и при возвращении в приложение, если прошло больше часа, клиент сверяет сохранённое расписание с каталогом. Тот же файл с новой датой тихо перечитывается. Так же один раз перечитывается расписание, сохранённое более старым парсером, чем на сервере: `/api/catalog` сообщает `parserVersion`, и PWA сравнивает его с версией сохранённой копии. Поэтому исправления разбора доходят до студентов без перепубликации документа. У сервера без `parserVersion` перечитывания нет. Если для группы появился более новый документ (следующий семестр или год), показывается предложение его открыть; отказ запоминается. Если документ исчез из каталога, приложение предлагает найти группу заново.
**Проверка каталога по ETag.** Сервер сам проверяет каталог университета раз в 5 минут, а не только когда приходит запрос. У каждой папки Nextcloud есть ETag, и он меняется, когда меняется что‑то внутри. Сервер хранит листинги прошлого обхода (`.data/catalog-tree.json`) и заново запрашивает только папки с новым ETag. Проверка неизменного каталога — 2 запроса вместо ~100: корень и папка года. Перезалитый документ стоит ещё пары запросов по пути к нему. Раз в 6 часов на всякий случай идёт полный обход. Индекс пересобирается, только если изменился какой‑то документ, поэтому ETag расписаний и кэши клиентов между проверками не сбрасываются. В `/api/health` поле `catalog.lastCrawl` показывает последний обход: число запросов, полный ли он, сколько папок взято из кэша. Поле `watch` показывает время и ошибку последней проверки.
**Проверка каталога по ETag.** Сервер сам проверяет каталог университета раз в 5 минут, а не только когда приходит запрос. У каждой папки Nextcloud есть ETag, и он меняется, когда меняется что‑то внутри. Сервер хранит листинги прошлого обхода (`.data/catalog-tree.json`) и заново запрашивает только папки с новым ETag. Проверка неизменного каталога — 2 запроса вместо ~100: корень и папка года. Перезалитый документ стоит ещё пары запросов по пути к нему. Раз в 6 часов на всякий случай идёт полный обход. Индекс пересобирается, только если изменился какой‑то документ, поэтому ETag расписаний и кэши клиентов между проверками не сбрасываются. На дашборде (поле `catalog.lastCrawl`) видно последний обход: число запросов, полный ли он, сколько папок взято из кэша. Поле `watch` там же — время и ошибка последней проверки.
**Дашборд `/status`.** Страница состояния сервера за паролем `DASHBOARD_PASSWORD`. Вход через обычное окно браузера, имя пользователя любое. Без пароля дашборд выключен и отвечает `404`. После десяти неверных паролей с одного адреса вход блокируется на 10 минут. Страница не кэшируется и закрыта от поисковиков (`noindex`). На ней:
- **плитки состояния** — сервер, каталог (год, число документов, последняя проверка и её стоимость в запросах), университет (отвечает ли, с какого времени не отвечает), разбор (подозрительные ячейки, нечитаемые документы), Telegram;
- **запросы за последний час** — ключевые числа, график по минутам (с ответом, `304`, ошибки), адреса API со средним временем и самые активные клиенты;
- **лента событий** — новые, перезалитые и нечитаемые документы, смена учебного года, сбои университета; последние 100 событий хранятся в `.data/events.json`;
- **подозрительные ячейки** — по документам, с исходным текстом ячейки и ссылкой на оригинал, с фильтрами по виду и поиском по группе, документу или тексту.
Страница — обычные HTML, CSS и JS из `server/status/` без сборки, под той же строгой CSP. Она обновляется раз в 30 секунд, пока вкладка открыта. Данные берёт из `/status/data.json` за тем же паролем. Публичный `/api/health` после появления дашборда отвечает только «жив, готов, версии, учебный год»: остальное называет документы, клиентов и настройки владельца.
**Журнал запросов и метрики.** На каждый запрос к `/api` в журнал (`docker logs`) пишется одна строка JSON: время, метод, путь с параметрами, статус, время ответа в мс, код ошибки, размер ответа и адрес клиента без последней части (`203.0.113.0`, у IPv6 — /48). Точные адреса не сохраняются. Проверки здоровья (`/api/health`, `/api/ready`) не пишутся и не считаются. Журнал выключается переменной `PSU_REQUEST_LOG=off`.
Поле `traffic` в `/api/health` — сводка за последний час (`lastHour`) и с запуска (`total`):
Поле `traffic` на дашборде (`/status/data.json`) — сводка за последний час (`lastHour`) и с запуска (`total`):
- сколько было запросов и какая доля ушла пустым `304`;
- ошибки клиента и сервера;
- срабатывания лимита (`limited`) и таймаута (`timeouts`);
@@ -87,7 +95,7 @@ PSU_PUBLIC_URL=https://psu.rumblur.by
5. Перезапустите контейнер: `docker compose up -d`.
Состояние оповещений — `telegram` в `/api/health`: включены ли, сколько отправлено и последняя ошибка. Токен в журналы и в `/api/health` не попадает.
Состояние оповещений — плитка Telegram на дашборде и поле `telegram` в `/status/data.json`: включены ли, сколько отправлено и последняя ошибка. Токен в журналы и в `/api/health` не попадает.
Каталог перечитывается раз в 5 минут (по ETag), папка года — раз в 15 минут, таблица недель и звонки — раз в 6 часов, распознанный документ — при изменении даты файла или версии парсера. Устаревшая копия отвечает сразу, а перечитывание идёт в фоне. Ждать приходится только при первом запуске, в начале нового учебного года и при «Обновить». «Обновить» (`?refresh=1`) принудительно перечитывает всё, но обращается к университету не чаще раза в 2 минуты (таблицу недель — раз в 10 минут), иначе отвечает только что прочитанной копией. После сбоя источника фоновые попытки тоже ждут 2 минуты. Если источник недоступен, используются сохранённые копии с явной пометкой. Фоновая синхронизация при закрытом приложении не реализована.
@@ -103,7 +111,7 @@ PSU_PUBLIC_URL=https://psu.rumblur.by
- **Заголовки безопасности.** Все ответы несут строгую `Content-Security-Policy`: только свои скрипты, стили, шрифты и запросы, а единственный inline-скрипт (тема до первой отрисовки) разрешён по хэшу, который сервер считает из собранного `dist/index.html`. Кроме неё: `X-Content-Type-Options`, `X-Frame-Options: DENY`, `Referrer-Policy`, `Permissions-Policy`, `Cross-Origin-Opener-Policy`. За HTTPS-прокси, который передаёт `X-Forwarded-Proto`, добавляется `Strict-Transport-Security`.
- **Адрес клиента за прокси.** Лимит считается по настоящему адресу клиента. Сервер верит `X-Forwarded-For` только от прокси из локальной сети (`127.0.0.1`, `10.*`, `172.16–31.*`, `192.168.*`). Поле `client` в `/api/health` показывает, кем сервер тебя видит: если там адрес прокси, а не твой, все посетители делят один лимит, и прокси нужно настроить на передачу `X-Forwarded-For`.
`GET /api/ready` отвечает `200`, когда каталог, таблица недель и индекс всех документов загружены, а до этого — `503` с кодом `not_ready` и списком того, что ещё грузится (`waiting`). На него смотрят проверка здоровья Docker и прокси. `GET /api/health` всегда отвечает `200`, пока процесс жив (поле `ready` — то же, что `/api/ready`), и без обращения к сети показывает выбранный учебный год, возраст каталога, найденные файлы недель и звонков, семестры и предупреждения: папка нового года не найдена, сегодняшняя дата вне таблицы недель, источник не обновляется.
`GET /api/ready` отвечает `200`, когда каталог, таблица недель и индекс всех документов загружены, а до этого — `503` с кодом `not_ready` и списком того, что ещё грузится (`waiting`). На него смотрят проверка здоровья Docker и прокси. `GET /api/health` всегда отвечает `200`, пока процесс жив: поле `ready` — то же, что `/api/ready`, плюс версии и учебный год. Возраст каталога, найденные файлы недель и звонков, семестры и предупреждения (папка нового года не найдена, сегодняшняя дата вне таблицы недель, источник не обновляется) — на дашборде `/status`.
```sh
npm run sync
@@ -136,7 +144,7 @@ npm run sync
PDF, Excel, изображения и сканы пока не импортируются. Они не выдаются за распознанное расписание.
**Подозрительные ячейки.** После разбора каждый документ каталога проверяется (`server/diagnostics.js`), и `GET /api/diagnostics` показывает ячейки, которые прочитаны неуверенно, с исходным текстом. Фильтры: `?group=23-АРХ`, `?doc=<id>`, `?code=weeks_partly`. Число таких ячеек есть и в `/api/health` (`directory.problems`). Коды:
**Подозрительные ячейки.** После разбора каждый документ каталога проверяется (`server/diagnostics.js`), и `GET /api/diagnostics` показывает ячейки, которые прочитаны неуверенно, с исходным текстом. Фильтры: `?group=23-АРХ`, `?doc=<id>`, `?code=weeks_partly`. Их число и список с фильтрами — на дашборде `/status`. Коды:
- `weeks_partly` — пометка недель прочитана не целиком (`1-4н.;6-12н.`);
- `weeks_unreadable` — пометка недель не прочитана (`2,6-12-16н`);
- `weeks_like_date` — «недели» похожи на дату с опечаткой (`19,11, 03.12`);

View File

@@ -25,6 +25,9 @@ services:
# и id чата. Значения удобно держать в файле .env рядом с этим файлом.
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
# Пароль дашборда /status (вход через окно браузера, имя любое);
# без него дашборд выключен.
DASHBOARD_PASSWORD: ${DASHBOARD_PASSWORD:-}
# Публичный адрес для ссылок в сообщениях, например https://psu.rumblur.by
PSU_PUBLIC_URL: ${PSU_PUBLIC_URL:-}
# Путь к LibreOffice для импорта старых .doc уже есть в образе.

View File

@@ -1,10 +1,12 @@
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { DATA } from "./paths.js";
import { diagnose } from "./diagnostics.js";
import { recordEvents } from "./events.js";
// Messages to the server's owner in Telegram, so a new document format, a typo the parser cannot
// read or a university that stopped answering is known the same day, not when a student notices.
// Off unless TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID are set.
// The same changes go to the dashboard's feed (events.js). Telegram is off unless
// TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID are set.
const TOKEN = process.env.TELEGRAM_BOT_TOKEN || "";
const CHAT = process.env.TELEGRAM_CHAT_ID || "";
@@ -95,13 +97,14 @@ function snapshot(idx, catalog) {
}
/**
* After every check of the catalog: a message about documents that appeared, documents that do
* not read, republished documents with suspicious cells they did not have, and a new academic
* year. The first run only says that messages are on. A message that does not go out is tried
* again on the next check.
* After every check of the catalog: events for the dashboard (every new, republished or unreadable
* document, a new academic year) and a Telegram message about the ones worth attention — new
* documents, documents that do not read, republished documents with suspicious cells they did not
* have. The first run only takes stock (and says in Telegram that messages are on). A message
* that does not go out is tried again on the next check, and its events wait with it.
*/
export async function checkCatalog(idx, catalog) {
if (!telegram.enabled || !idx) return;
if (!idx) return;
const was = await load();
const docs = snapshot(idx, catalog);
const stored = (d) => ({ ...d, problems: d.problems.map(signature) });
@@ -109,67 +112,94 @@ export async function checkCatalog(idx, catalog) {
docs: Object.fromEntries(Object.entries(docs).map(([id, d]) => [id, stored(d)])),
year: catalog.year,
yearWarning: was?.yearWarning || null,
announced: Boolean(was?.announced),
};
if (!was) {
const problems = Object.values(docs).reduce((n, d) => n + d.problems.length, 0);
const failed = Object.values(docs).filter((d) => d.failed).length;
const sent = await send(
`Оповещения расписания ПГУ включены.\nКаталог ${catalog.year}: ${idx.docs.length} документов` +
(failed ? `, не читаются: ${failed}` : "") +
`, подозрительных ячеек: ${problems}.` +
(PUBLIC ? `\n${PUBLIC}/api/diagnostics` : ""),
);
if (sent) await save(next);
return;
}
const sections = [];
for (const [id, now] of Object.entries(docs)) {
const before = was.docs?.[id];
if (before && before.modified === now.modified && (before.failed || null) === (now.failed || null)) continue;
const title = `${now.name}${now.faculty ? ` (${now.faculty})` : ""}`;
if (now.failed) {
sections.push(`Не читается: ${title}\n${now.failed}`);
continue;
const events = [];
if (was) {
for (const [id, now] of Object.entries(docs)) {
const before = was.docs?.[id];
if (before && before.modified === now.modified && (before.failed || null) === (now.failed || null)) continue;
const title = `${now.name}${now.faculty ? ` (${now.faculty})` : ""}`;
if (now.failed) {
sections.push(`Не читается: ${title}\n${now.failed}`);
events.push({ kind: "failed", title: now.name, text: now.failed, doc: id });
continue;
}
const known = new Set(before?.problems || []);
const added = now.problems.filter((p) => !known.has(signature(p)));
const lines = added.slice(0, MAX_PROBLEMS).map(problemLine);
if (added.length > MAX_PROBLEMS) lines.push(`• и ещё ${added.length - MAX_PROBLEMS}`);
const found = added.length ? `новых подозрительных ячеек: ${added.length}` : "без новых подозрительных ячеек";
if (!before) {
sections.push(
`Новый документ: ${title}\nГруппы: ${now.groups.join(", ") || "не найдены"}` +
(added.length ? `\nПодозрительные ячейки:\n${lines.join("\n")}${link(id)}` : ""),
);
events.push({ kind: "new", title: now.name, text: `Группы: ${now.groups.join(", ") || "не найдены"}; ${found}`, doc: id });
} else {
if (added.length) sections.push(`Перезалит с новыми подозрительными ячейками: ${title}\n${lines.join("\n")}${link(id)}`);
events.push({ kind: "republished", title: now.name, text: found, doc: id });
}
}
if (was.year && catalog.year && was.year !== catalog.year) {
sections.unshift(`Каталог перешёл на учебный год ${catalog.year}.`);
events.unshift({ kind: "year", title: `Учебный год ${catalog.year}` });
}
const known = new Set(before?.problems || []);
const added = now.problems.filter((p) => !known.has(signature(p)));
const lines = added.slice(0, MAX_PROBLEMS).map(problemLine);
if (added.length > MAX_PROBLEMS) lines.push(`• и ещё ${added.length - MAX_PROBLEMS}`);
if (!before)
sections.push(
`Новый документ: ${title}\nГруппы: ${now.groups.join(", ") || "не найдены"}` +
(added.length ? `\nПодозрительные ячейки:\n${lines.join("\n")}` : "") +
(added.length ? link(id) : ""),
);
else if (added.length) sections.push(`Перезалит с новыми подозрительными ячейками: ${title}\n${lines.join("\n")}${link(id)}`);
}
if (was.year && catalog.year && was.year !== catalog.year) sections.unshift(`Каталог перешёл на учебный год ${catalog.year}.`);
// The new year's folder is late: the catalog still shows last year's documents.
if (catalog.currentYear === false && catalog.expectedYear && was.yearWarning !== catalog.expectedYear) {
sections.unshift(`Папка учебного года ${catalog.expectedYear} ещё не опубликована: используется ${catalog.year}.`);
if (catalog.currentYear === false && catalog.expectedYear && next.yearWarning !== catalog.expectedYear) {
const text = `Папка учебного года ${catalog.expectedYear} ещё не опубликована: используется ${catalog.year}.`;
sections.unshift(text);
events.unshift({ kind: "year_late", title: text });
next.yearWarning = catalog.expectedYear;
}
if (!sections.length) return save(next);
const shown = sections.slice(0, MAX_DOCS);
if (sections.length > MAX_DOCS) shown.push(`И ещё изменений: ${sections.length - MAX_DOCS}.`);
if (await send(`Расписание ПГУ\n\n${shown.join("\n\n")}`)) await save(next);
if (telegram.enabled && !next.announced) {
const problems = Object.values(docs).reduce((n, d) => n + d.problems.length, 0);
const failed = Object.values(docs).filter((d) => d.failed).length;
sections.unshift(
`Оповещения расписания ПГУ включены.\nКаталог ${catalog.year}: ${idx.docs.length} документов` +
(failed ? `, не читаются: ${failed}` : "") +
`, подозрительных ячеек: ${problems}.` +
(PUBLIC ? `\n${PUBLIC}/status` : ""),
);
next.announced = true;
}
if (telegram.enabled && sections.length) {
const shown = sections.slice(0, MAX_DOCS);
if (sections.length > MAX_DOCS) shown.push(`И ещё изменений: ${sections.length - MAX_DOCS}.`);
const text = shown.length === 1 && !was ? shown[0] : `Расписание ПГУ\n\n${shown.join("\n\n")}`;
if (!(await send(text))) return;
}
await recordEvents(events);
await save(next);
}
/**
* The university's catalog failing for longer than an hour, and its return. Kept in memory: after
* a restart a long outage is reported again, which is better than missing it.
* The university's catalog failing for longer than an hour, and its return: an event and a
* Telegram message. Kept in memory: after a restart a long outage is reported again, which is
* better than missing it.
*/
let downRecorded = false;
export async function checkSource(failure) {
if (!telegram.enabled) return;
if (failure) {
const since = Date.parse(failure.since);
if (!sourceNotified && Number.isFinite(since) && Date.now() - since >= DOWN_AFTER) {
const at = new Date(since).toLocaleString("ru-RU", { timeZone: "Europe/Minsk", dateStyle: "short", timeStyle: "short" });
if (!Number.isFinite(since) || Date.now() - since < DOWN_AFTER) return;
const at = new Date(since).toLocaleString("ru-RU", { timeZone: "Europe/Minsk", dateStyle: "short", timeStyle: "short" });
if (!downRecorded) {
downRecorded = true;
await recordEvents([{ kind: "source_down", title: `Каталог университета не отвечает с ${at}`, text: failure.error }]);
}
if (telegram.enabled && !sourceNotified)
sourceNotified = await send(
`Каталог университета не отвечает с ${at}.\n${failure.error}\nПриложения показывают сохранённые копии расписаний.`,
);
}
} else if (sourceNotified && (await send("Каталог университета снова отвечает."))) sourceNotified = false;
return;
}
if (downRecorded) {
downRecorded = false;
await recordEvents([{ kind: "source_up", title: "Каталог университета снова отвечает" }]);
}
if (sourceNotified && (await send("Каталог университета снова отвечает."))) sourceNotified = false;
}

45
server/auth.js Normal file
View File

@@ -0,0 +1,45 @@
import { createHash, timingSafeEqual } from "node:crypto";
// The dashboard's password (DASHBOARD_PASSWORD), asked by the browser's own sign-in window
// (HTTP Basic). Without it set, the dashboard is off. Ten wrong passwords from one address lock
// it out for ten minutes.
const PASSWORD = process.env.DASHBOARD_PASSWORD || "";
const MAX_FAILURES = 10;
const LOCKOUT_MS = 10 * 60 * 1000;
export const dashboardEnabled = Boolean(PASSWORD);
/** Compares in constant time: the digests have equal length whatever was typed. */
const same = (a, b) => timingSafeEqual(createHash("sha256").update(a).digest(), createHash("sha256").update(b).digest());
export function dashboardAuth() {
const failures = new Map();
return (req, res, next) => {
// Status pages are nobody's search result and nobody's cache.
res.set({ "Cache-Control": "no-store", "X-Robots-Tag": "noindex" });
if (!PASSWORD)
return res.status(404).type("text/plain; charset=utf-8").send("Дашборд выключен: задайте DASHBOARD_PASSWORD.");
const now = Date.now();
for (const [ip, f] of failures) if (now - f.start >= LOCKOUT_MS) failures.delete(ip);
const tries = failures.get(req.ip);
if (tries && tries.count >= MAX_FAILURES) {
res.set("Retry-After", String(Math.ceil((tries.start + LOCKOUT_MS - now) / 1000)));
return res.status(429).type("text/plain; charset=utf-8").send("Слишком много неверных паролей. Попробуйте через 10 минут.");
}
const [scheme, value = ""] = (req.get("Authorization") || "").split(" ");
if (scheme === "Basic") {
const decoded = Buffer.from(value, "base64").toString("utf8");
// Any user name; only the password counts.
if (same(decoded.slice(decoded.indexOf(":") + 1), PASSWORD)) {
failures.delete(req.ip);
return next();
}
const f = tries || { count: 0, start: now };
f.count++;
failures.set(req.ip, f);
}
res.set("WWW-Authenticate", 'Basic realm="PSU schedule status", charset="UTF-8"');
res.status(401).type("text/plain; charset=utf-8").send("Нужен пароль дашборда.");
};
}

View File

@@ -31,7 +31,8 @@ export function diagnose(schedule) {
code,
message: message[code],
...(lesson && {
groups: lesson.groups,
// Word repeats a group in some headers ("24-ИЯ (РА)и-1_…и-2"): once is enough.
groups: [...new Set(lesson.groups)],
day: lesson.day,
start: lesson.start || null,
raw: lesson.raw,

39
server/events.js Normal file
View File

@@ -0,0 +1,39 @@
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { DATA } from "./paths.js";
// What happened to the catalog and the university, newest first, for the dashboard: the same
// events the Telegram messages report, plus republished documents without new problems.
// The last hundred are kept on disk, so a restart does not empty the feed.
const FILE = "events.json";
const MAX = 100;
let events;
async function load() {
if (events) return events;
try {
events = JSON.parse(await readFile(DATA + FILE, "utf8"));
if (!Array.isArray(events)) events = [];
} catch {
events = [];
}
return events;
}
/**
* Adds events: `{kind, title, text?, doc?}`. Kinds: new, republished, failed, year, year_late,
* source_down, source_up.
*/
export async function recordEvents(list) {
if (!list.length) return;
await load();
const t = new Date().toISOString();
events.unshift(...list.map((e) => ({ t, ...e })));
events.length = Math.min(events.length, MAX);
await mkdir(DATA, { recursive: true });
await writeFile(DATA + FILE, JSON.stringify(events));
}
export async function listEvents(limit = 50) {
return (await load()).slice(0, limit);
}

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) => {

View File

@@ -185,8 +185,23 @@ export function trafficStatus(now = Date.now()) {
for (const [client, n] of b.clients) hour.clients.set(client, (hour.clients.get(client) || 0) + n);
}
const busiest = [...hour.clients.entries()].sort((a, z) => z[1] - a[1]).slice(0, 3);
// Minute by minute, oldest first, empty minutes included: the dashboard's chart.
const current = Math.floor(now / 60000);
const series = Array.from({ length: WINDOW_MINUTES }, (_, i) => {
const minute = current - WINDOW_MINUTES + 1 + i;
const b = minutes.get(minute);
return {
t: new Date(minute * 60000).toISOString(),
requests: b?.requests || 0,
notModified: b?.notModified || 0,
errors: (b?.clientErrors || 0) + (b?.serverErrors || 0),
serverErrors: b?.serverErrors || 0,
slow: b?.slow || 0,
};
});
return {
since: started,
series,
lastHour: {
...summary(hour),
clients: hour.clients.size,

87
server/status/index.html Normal file
View File

@@ -0,0 +1,87 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<title>Статус · Расписание ПГУ</title>
<link rel="icon" href="/icon.svg" />
<link rel="stylesheet" href="status.css" />
<script type="module" src="status.js"></script>
</head>
<body>
<header class="top">
<div>
<h1>Расписание ПГУ</h1>
<p class="sub" id="sub">Статус сервера</p>
</div>
<div class="refresh">
<span id="updated" class="muted" aria-live="polite"></span>
<button id="reload" type="button" class="tonal">Обновить</button>
</div>
</header>
<main>
<p id="error" class="banner bad" hidden></p>
<ul id="warnings" class="warnings" hidden></ul>
<section class="tiles" id="tiles" aria-label="Состояние"></section>
<section class="card" aria-labelledby="traffic-title">
<div class="card-head">
<h2 id="traffic-title">Запросы за час</h2>
<span class="muted" id="traffic-since"></span>
</div>
<div class="kpis" id="kpis"></div>
<figure class="chart">
<svg id="chart" viewBox="0 0 600 140" preserveAspectRatio="none" role="img" aria-label="Запросы по минутам за последний час"></svg>
<figcaption class="legend">
<span><i class="swatch fresh"></i>С ответом</span>
<span><i class="swatch cached"></i>Без изменений (304)</span>
<span><i class="swatch failed"></i>Ошибки</span>
<span class="axis"><span>−60 мин</span><span>−30 мин</span><span>сейчас</span></span>
</figcaption>
</figure>
<div class="split">
<div>
<h3>Адреса</h3>
<table id="endpoints">
<thead>
<tr><th scope="col">Адрес</th><th scope="col" class="num">Запросов</th><th scope="col" class="num">Среднее</th><th scope="col" class="num">Ошибок</th></tr>
</thead>
<tbody></tbody>
</table>
</div>
<div>
<h3>Самые активные клиенты</h3>
<table id="clients">
<thead>
<tr><th scope="col">Адрес</th><th scope="col" class="num">Запросов</th></tr>
</thead>
<tbody></tbody>
</table>
<p class="muted small">Адреса без последней части: точные не хранятся.</p>
</div>
</div>
</section>
<div class="columns">
<section class="card" aria-labelledby="events-title">
<div class="card-head"><h2 id="events-title">События</h2></div>
<ol id="events" class="events"></ol>
</section>
<section class="card" aria-labelledby="diag-title">
<div class="card-head">
<h2 id="diag-title">Подозрительные ячейки</h2>
<span class="muted" id="diag-count"></span>
</div>
<div class="filters">
<input id="q" type="search" placeholder="Группа, документ или текст ячейки" aria-label="Поиск по ячейкам" />
<div id="codes" class="chips" role="group" aria-label="Виды"></div>
</div>
<div id="failed"></div>
<div id="problems"></div>
</section>
</div>
</main>
</body>
</html>

474
server/status/status.css Normal file
View File

@@ -0,0 +1,474 @@
/* The dashboard in the PWA's Material 3 colours and type (src/styles.css), light and dark. */
@font-face {
font-family: "Roboto Flex";
src: url("/fonts/RobotoFlex-Variable.ttf") format("truetype");
font-weight: 100 1000;
font-display: swap;
}
:root {
color-scheme: light dark;
--primary: #6750a4;
--on-primary: #fff;
--primary-container: #eaddff;
--on-primary-container: #21005d;
--secondary-container: #e8def8;
--on-secondary-container: #1d192b;
--surface: #fef7ff;
--on-surface: #1d1b20;
--on-surface-variant: #49454f;
--container-lowest: #fff;
--card: #fff;
--container-low: #f7f2fa;
--container: #f3edf7;
--container-high: #ece6f0;
--outline-variant: #cac4d0;
--error: #b3261e;
--error-container: #f9dedc;
--on-error-container: #410e0b;
--ok: #2e6b30;
--ok-container: #c8efb8;
--warn: #8a5100;
--warn-container: #ffddb8;
--font: "Roboto Flex", Roboto, system-ui, sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--primary: #d0bcff;
--on-primary: #381e72;
--primary-container: #4f378b;
--on-primary-container: #eaddff;
--secondary-container: #4a4458;
--on-secondary-container: #e8def8;
--surface: #141218;
--on-surface: #e6e0e9;
--on-surface-variant: #cac4d0;
--container-lowest: #0f0d13;
/* Cards lift off the dark page as in the PWA: a container tone, not the darkest one. */
--card: #211f26;
--container-low: #2b2930;
--container: #211f26;
--container-high: #2b2930;
--outline-variant: #49454f;
--error: #f2b8b5;
--error-container: #8c1d18;
--on-error-container: #f9dedc;
--ok: #9fd79a;
--ok-container: #1f4d24;
--warn: #ffb95c;
--warn-container: #693c00;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--surface);
color: var(--on-surface);
font: 400 15px/1.45 var(--font);
-webkit-font-smoothing: antialiased;
}
h1,
h2,
h3 {
margin: 0;
font-weight: 600;
letter-spacing: 0;
}
h1 {
font-size: 1.6rem;
}
h2 {
font-size: 1.15rem;
}
h3 {
font-size: 0.95rem;
margin-bottom: 8px;
color: var(--on-surface-variant);
}
.muted {
color: var(--on-surface-variant);
}
.small {
font-size: 0.8rem;
}
.top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
max-width: 1280px;
margin: 0 auto;
padding: 24px 16px 8px;
}
.sub {
margin: 2px 0 0;
color: var(--on-surface-variant);
}
.refresh {
display: flex;
align-items: center;
gap: 12px;
}
button.tonal {
border: 0;
border-radius: 9999px;
padding: 10px 20px;
background: var(--secondary-container);
color: var(--on-secondary-container);
font: 600 0.875rem var(--font);
cursor: pointer;
}
button.tonal:hover {
filter: brightness(0.96);
}
button:focus-visible,
input:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
main {
max-width: 1280px;
margin: 0 auto;
padding: 8px 16px 48px;
display: grid;
gap: 16px;
}
.banner {
margin: 0;
padding: 12px 16px;
border-radius: 16px;
}
.banner.bad {
background: var(--error-container);
color: var(--on-error-container);
}
.warnings {
margin: 0;
padding: 12px 16px 12px 36px;
border-radius: 16px;
background: var(--warn-container);
}
.tiles {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
gap: 12px;
}
.tile {
background: var(--card);
border-radius: 20px;
padding: 16px 18px;
display: grid;
gap: 4px;
align-content: start;
}
.tile .label {
font-size: 0.8rem;
color: var(--on-surface-variant);
display: flex;
align-items: center;
gap: 8px;
}
.tile .value {
font-size: 1.25rem;
font-weight: 600;
}
.tile .detail {
font-size: 0.85rem;
color: var(--on-surface-variant);
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
background: var(--outline-variant);
}
.dot.ok {
background: var(--ok);
}
.dot.warn {
background: var(--warn);
}
.dot.bad {
background: var(--error);
}
.card {
background: var(--card);
border-radius: 28px;
padding: 20px;
min-width: 0;
}
.card-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.kpis {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 8px;
margin-bottom: 16px;
}
.kpi {
background: var(--container-low);
border-radius: 16px;
padding: 10px 14px;
}
.kpi b {
display: block;
font-size: 1.35rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.kpi span {
font-size: 0.8rem;
color: var(--on-surface-variant);
}
.kpi.warn {
background: var(--warn-container);
}
.kpi.bad {
background: var(--error-container);
color: var(--on-error-container);
}
.chart {
margin: 0 0 16px;
}
.chart svg {
width: 100%;
height: 140px;
display: block;
background: var(--container-low);
border-radius: 12px;
}
.bar-fresh {
fill: var(--primary);
}
.bar-cached {
fill: var(--primary-container);
}
.bar-failed {
fill: var(--error);
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
margin-top: 8px;
font-size: 0.8rem;
color: var(--on-surface-variant);
align-items: center;
}
.legend .axis {
margin-left: auto;
display: flex;
gap: 24px;
}
.swatch {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 3px;
margin-right: 6px;
vertical-align: -1px;
}
.swatch.fresh {
background: var(--primary);
}
.swatch.cached {
background: var(--primary-container);
}
.swatch.failed {
background: var(--error);
}
.split {
display: grid;
grid-template-columns: 3fr 2fr;
gap: 24px;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
th {
text-align: left;
font-weight: 500;
color: var(--on-surface-variant);
padding: 6px 8px;
border-bottom: 1px solid var(--outline-variant);
}
td {
padding: 7px 8px;
border-bottom: 1px solid var(--container-high);
overflow-wrap: anywhere;
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.columns {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 3fr);
gap: 16px;
align-items: start;
}
.events {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 2px;
max-height: 720px;
overflow: auto;
}
.events li {
display: grid;
grid-template-columns: auto 1fr;
gap: 2px 12px;
padding: 10px 4px;
border-bottom: 1px solid var(--container-high);
}
.events .dot {
margin-top: 6px;
grid-row: span 3;
}
.events .when {
font-size: 0.8rem;
color: var(--on-surface-variant);
}
.events .title {
font-weight: 500;
overflow-wrap: anywhere;
}
.events .text {
font-size: 0.85rem;
color: var(--on-surface-variant);
overflow-wrap: anywhere;
}
.filters {
display: grid;
gap: 10px;
margin-bottom: 12px;
}
input[type="search"] {
width: 100%;
border: 1px solid var(--outline-variant);
border-radius: 12px;
padding: 10px 14px;
background: var(--container-low);
color: var(--on-surface);
font: 400 0.95rem var(--font);
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.chip {
border: 1px solid var(--outline-variant);
border-radius: 8px;
padding: 6px 12px;
background: transparent;
color: var(--on-surface);
font: 500 0.8rem var(--font);
cursor: pointer;
}
.chip[aria-pressed="true"] {
background: var(--secondary-container);
color: var(--on-secondary-container);
border-color: transparent;
}
.chip .n {
margin-left: 6px;
color: var(--on-surface-variant);
font-variant-numeric: tabular-nums;
}
.doc {
border-top: 1px solid var(--container-high);
padding: 14px 0 6px;
}
.doc-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 6px;
}
.doc-head b {
overflow-wrap: anywhere;
}
.doc-head a {
color: var(--primary);
font-size: 0.85rem;
white-space: nowrap;
}
.problem {
padding: 10px 0;
}
.problem + .problem {
border-top: 1px dashed var(--container-high);
}
.problem .where {
font-size: 0.8rem;
color: var(--on-surface-variant);
}
.problem q {
display: block;
margin-top: 4px;
padding: 6px 10px;
border-radius: 8px;
background: var(--container-low);
font-size: 0.85rem;
quotes: "«" "»";
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.failed-doc {
padding: 10px 14px;
border-radius: 12px;
background: var(--error-container);
color: var(--on-error-container);
margin-bottom: 8px;
}
.empty {
padding: 24px 0;
text-align: center;
color: var(--on-surface-variant);
}
@media (max-width: 900px) {
.columns,
.split {
grid-template-columns: 1fr;
}
}
@media (max-width: 560px) {
.legend .axis {
display: none;
}
}

335
server/status/status.js Normal file
View File

@@ -0,0 +1,335 @@
// The dashboard: reads /status/data.json every 30 seconds while the tab is visible and draws it.
// Every value goes in through textContent: the cells come from Word files, not from us.
const REFRESH_MS = 30_000;
const TZ = "Europe/Minsk";
const SVG = "http://www.w3.org/2000/svg";
const CODES = {
weeks_partly: "Недели не целиком",
weeks_unreadable: "Недели не прочитаны",
weeks_like_date: "Опечатка в дате",
weeks_out_of_range: "Неделя вне семестра",
date_in_subject: "Дата в названии",
time_in_subject: "Время в названии",
dates_unread: "Даты не прочитаны",
date_weekday: "Не тот день недели",
date_out_of_year: "Дата вне года",
subject_missing: "Нет названия",
time_invalid: "Странное время",
group_empty: "Группа без пар",
};
const EVENTS = {
new: ["Новый документ", "ok"],
republished: ["Перезалит", ""],
failed: ["Не читается", "bad"],
year: ["Новый учебный год", "ok"],
year_late: ["Папка года задерживается", "warn"],
source_down: ["Университет не отвечает", "bad"],
source_up: ["Университет снова отвечает", "ok"],
};
const DAYS = ["", "пн", "вт", "ср", "чт", "пт", "сб", "вс"];
const $ = (id) => document.getElementById(id);
/** An element with a class and text or children. */
function el(tag, className, ...children) {
const node = document.createElement(tag);
if (className) node.className = className;
for (const child of children)
if (child != null && child !== false) node.append(child instanceof Node ? child : String(child));
return node;
}
const plural = (n, one, few, many) => {
const t = n % 100;
return t >= 11 && t <= 14 ? many : n % 10 === 1 ? one : n % 10 >= 2 && n % 10 <= 4 ? few : many;
};
const time = (iso) =>
iso ? new Date(iso).toLocaleTimeString("ru-RU", { timeZone: TZ, hour: "2-digit", minute: "2-digit" }) : "—";
const dateTime = (iso) =>
iso ? new Date(iso).toLocaleString("ru-RU", { timeZone: TZ, day: "numeric", month: "long", hour: "2-digit", minute: "2-digit" }) : "—";
function ago(iso) {
if (!iso) return "—";
const minutes = Math.round((Date.now() - Date.parse(iso)) / 60000);
if (minutes < 1) return "только что";
if (minutes < 60) return `${minutes} мин назад`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} ч ${minutes % 60} мин назад`;
return dateTime(iso);
}
const ms = (v) => (v == null ? "—" : typeof v === "number" ? `≤ ${v} мс` : `${v} мс`);
// Tiles
function tile(label, state, value, detail) {
return el(
"article",
"tile",
el("span", "label", el("i", `dot ${state}`), label),
el("span", "value", value),
detail ? el("span", "detail", detail) : null,
);
}
function tiles(d) {
const uptime = Math.round((Date.now() - Date.parse(d.server.startedAt)) / 60000);
const up = uptime < 60 ? `${uptime} мин` : `${Math.floor(uptime / 60)} ч ${uptime % 60} мин`;
const crawl = d.catalog?.lastCrawl;
const failing = d.catalog?.failingSince;
const dir = d.directory || {};
const problems = d.diagnostics?.problems ?? dir.problems ?? 0;
const tg = d.telegram;
$("tiles").replaceChildren(
tile(
"Сервер",
d.server.ready ? "ok" : "warn",
d.server.ready ? "Готов" : "Загружается",
d.server.ready
? `API ${d.server.apiVersion} · парсер ${d.server.parserVersion} · работает ${up}`
: `Ждёт: ${d.server.waiting.join(", ")}`,
),
tile(
"Каталог",
d.catalog?.stale ? "warn" : "ok",
d.catalog ? `${d.catalog.files} ${plural(d.catalog.files, "документ", "документа", "документов")}` : "Нет",
d.catalog
? `${d.catalog.year} · проверен ${ago(d.watch?.lastAt || d.catalog.updatedAt)}` +
(crawl ? ` · ${crawl.requests} ${plural(crawl.requests, "запрос", "запроса", "запросов")}${crawl.full ? ", полный обход" : ""}` : "")
: null,
),
tile(
"Университет",
failing ? "bad" : "ok",
failing ? "Не отвечает" : "Отвечает",
failing ? `с ${dateTime(failing)}` : d.watch?.lastError || `таблица недель: ${d.calendar?.weeksFiles?.length ? "есть" : "нет"}`,
),
tile(
"Разбор",
dir.failed ? "bad" : problems ? "warn" : "ok",
`${problems} ${plural(problems, "ячейка", "ячейки", "ячеек")} под вопросом`,
`прочитано ${dir.documents ?? 0} из ${dir.total ?? 0}` + (dir.failed ? ` · не читаются: ${dir.failed}` : ""),
),
tile(
"Telegram",
!tg.enabled ? "" : tg.lastError ? "bad" : "ok",
!tg.enabled ? "Выключен" : tg.lastError ? "Ошибка" : `Отправлено: ${tg.sent}`,
!tg.enabled ? "задайте TELEGRAM_BOT_TOKEN и TELEGRAM_CHAT_ID" : tg.lastError || (tg.lastSentAt ? `последнее ${ago(tg.lastSentAt)}` : "с запуска ничего"),
),
);
const warnings = d.warnings || [];
$("warnings").hidden = !warnings.length;
$("warnings").replaceChildren(...warnings.map((w) => el("li", "", w)));
}
// Traffic
function kpi(label, value, state = "") {
return el("div", `kpi ${state}`, el("b", "", value), el("span", "", label));
}
function traffic(t) {
const h = t.lastHour;
$("traffic-since").textContent = `с запуска ${t.total.requests} · считается с ${dateTime(t.since)}`;
$("kpis").replaceChildren(
kpi("запросов", h.requests),
kpi("без изменений (304)", h.notModifiedShare == null ? "—" : `${Math.round(h.notModifiedShare * 100)}%`),
kpi("медиана", ms(h.p50Ms)),
kpi("95-й процентиль", ms(h.p95Ms), h.slow ? "warn" : ""),
kpi("ошибок клиента", h.clientErrors),
kpi("ошибок сервера", h.serverErrors, h.serverErrors ? "bad" : ""),
kpi("лимит и таймаут", h.limited + h.timeouts, h.limited + h.timeouts ? "warn" : ""),
kpi("сбоев университета", h.sourceFailures, h.sourceFailures ? "warn" : ""),
kpi("клиентов", h.clients),
);
chart(t.series);
const rows = Object.entries(h.endpoints).map(([path, e]) =>
el("tr", "", el("td", "", path), el("td", "num", e.requests), el("td", "num", `${e.avgMs} мс`), el("td", "num", e.errors)),
);
$("endpoints").tBodies[0].replaceChildren(...(rows.length ? rows : [el("tr", "", el("td", "muted", "Запросов не было"))]));
const clients = h.busiestClients.map((c) => el("tr", "", el("td", "", c.client), el("td", "num", c.requests)));
$("clients").tBodies[0].replaceChildren(...(clients.length ? clients : [el("tr", "", el("td", "muted", "—"))]));
}
/** Bars per minute: answered requests, 304s under them and errors on top. */
function chart(series) {
const svg = $("chart");
const width = 600,
height = 140,
pad = 6;
const max = Math.max(1, ...series.map((m) => m.requests));
const step = width / series.length;
const scale = (n) => (n / max) * (height - pad * 2);
const nodes = [];
series.forEach((m, i) => {
const x = i * step + 1;
const w = Math.max(1, step - 2);
let y = height - pad;
const part = (n, cls) => {
if (!n) return;
const h = Math.max(1.5, scale(n));
y -= h;
const r = document.createElementNS(SVG, "rect");
r.setAttribute("x", x.toFixed(1));
r.setAttribute("y", y.toFixed(1));
r.setAttribute("width", w.toFixed(1));
r.setAttribute("height", h.toFixed(1));
r.setAttribute("rx", "1.5");
r.setAttribute("class", cls);
nodes.push(r);
};
part(m.notModified, "bar-cached");
part(Math.max(0, m.requests - m.notModified - m.errors), "bar-fresh");
part(m.errors, "bar-failed");
// A hover target over the whole minute, with the numbers as its tooltip.
const hit = document.createElementNS(SVG, "rect");
hit.setAttribute("x", (i * step).toFixed(1));
hit.setAttribute("y", "0");
hit.setAttribute("width", step.toFixed(1));
hit.setAttribute("height", String(height));
hit.setAttribute("fill", "transparent");
const title = document.createElementNS(SVG, "title");
title.textContent = `${time(m.t)}: ${m.requests} ${plural(m.requests, "запрос", "запроса", "запросов")}, 304: ${m.notModified}, ошибок: ${m.errors}`;
hit.append(title);
nodes.push(hit);
});
svg.replaceChildren(...nodes);
const total = series.reduce((n, m) => n + m.requests, 0);
svg.setAttribute("aria-label", `Запросы по минутам за последний час: всего ${total}, максимум ${max} в минуту`);
}
// Events
function events(list) {
if (!list.length) {
$("events").replaceChildren(el("li", "empty", "Событий пока нет. Здесь появятся новые и перезалитые документы и сбои университета."));
return;
}
$("events").replaceChildren(
...list.map((e) => {
const [label, state] = EVENTS[e.kind] || [e.kind, ""];
return el(
"li",
"",
el("i", `dot ${state}`),
el("span", "when", `${label} · ${dateTime(e.t)}`),
el("span", "title", e.title),
e.text ? el("span", "text", e.text) : null,
);
}),
);
}
// Diagnostics
let diagnostics = null;
const selected = new Set();
function renderCodes() {
const counts = diagnostics?.counts || {};
$("codes").replaceChildren(
...Object.entries(counts)
.sort((a, z) => z[1] - a[1])
.map(([code, n]) => {
const chip = el("button", "chip", CODES[code] || code, el("span", "n", n));
chip.type = "button";
chip.title = code;
chip.setAttribute("aria-pressed", String(selected.has(code)));
chip.addEventListener("click", () => {
selected.has(code) ? selected.delete(code) : selected.add(code);
renderCodes();
renderProblems();
});
return chip;
}),
);
}
function renderProblems() {
if (!diagnostics) {
$("problems").replaceChildren(el("p", "empty", "Индекс каталога ещё собирается."));
return;
}
const q = $("q").value.trim().toLowerCase();
const matches = (doc, p) =>
(!selected.size || selected.has(p.code)) &&
(!q || [doc.name, doc.faculty, p.raw, ...(p.groups || [])].some((s) => s && s.toLowerCase().includes(q)));
const docs = diagnostics.items
.map((item) => ({ ...item, problems: item.problems.filter((p) => matches(item.doc, p)) }))
.filter((item) => item.problems.length);
const shown = docs.reduce((n, d) => n + d.problems.length, 0);
$("diag-count").textContent = q || selected.size ? `${shown} из ${diagnostics.problems}` : `${diagnostics.problems} в ${diagnostics.items.length} ${plural(diagnostics.items.length, "документе", "документах", "документах")}`;
$("failed").replaceChildren(
...(diagnostics.failed || []).map((f) => el("div", "failed-doc", el("b", "", `Не читается: ${f.name}`), el("div", "small", f.error))),
);
if (!docs.length) {
$("problems").replaceChildren(el("p", "empty", diagnostics.problems ? "Ничего не найдено." : "Подозрительных ячеек нет."));
return;
}
$("problems").replaceChildren(
...docs.map(({ doc, problems }) => {
const original = el("a", "", "Оригинал");
original.href = `/api/original/${encodeURIComponent(doc.id)}`;
return el(
"section",
"doc",
el("div", "doc-head", el("b", "", doc.name, doc.faculty ? el("span", "muted", ` · ${doc.faculty}`) : null), original),
...problems.map((p) =>
el(
"div",
"problem",
el(
"div",
"where",
[CODES[p.code] || p.code, (p.groups || []).join(", "), p.day ? `${DAYS[p.day]} ${p.start || ""}` : ""].filter(Boolean).join(" · "),
),
el("div", "", p.message),
p.raw ? el("q", "", p.raw) : null,
),
),
);
}),
);
}
// Loading
let timer = null;
async function load() {
try {
// From the origin, not the page's address: a page opened as user:password@host cannot fetch
// relative URLs, which would carry the credentials.
const response = await fetch(new URL("data.json", location.origin + location.pathname), { cache: "no-store" });
if (response.status === 401) throw new Error("Сессия закончилась: обновите страницу и введите пароль.");
if (!response.ok) throw new Error(`Сервер ответил ${response.status}.`);
const d = await response.json();
$("error").hidden = true;
$("sub").textContent = `Статус сервера · каталог ${d.catalog?.year || "—"}`;
tiles(d);
traffic(d.traffic);
events(d.events || []);
diagnostics = d.diagnostics;
for (const code of [...selected]) if (!diagnostics?.counts?.[code]) selected.delete(code);
renderCodes();
renderProblems();
$("updated").textContent = `Обновлено в ${new Date().toLocaleTimeString("ru-RU", { timeZone: TZ })}`;
} catch (error) {
$("error").textContent = error.message || "Не удалось загрузить данные.";
$("error").hidden = false;
}
}
function schedule() {
clearInterval(timer);
if (!document.hidden) timer = setInterval(load, REFRESH_MS);
}
$("reload").addEventListener("click", load);
$("q").addEventListener("input", renderProblems);
document.addEventListener("visibilitychange", () => {
if (!document.hidden) load();
schedule();
});
load();
schedule();

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/);
});