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:
20
README.md
20
README.md
@@ -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`);
|
||||
|
||||
@@ -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 уже есть в образе.
|
||||
|
||||
132
server/alerts.js
132
server/alerts.js
@@ -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
45
server/auth.js
Normal 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("Нужен пароль дашборда.");
|
||||
};
|
||||
}
|
||||
@@ -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
39
server/events.js
Normal 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);
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
87
server/status/index.html
Normal 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
474
server/status/status.css
Normal 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
335
server/status/status.js
Normal 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();
|
||||
@@ -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");
|
||||
|
||||
@@ -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
54
test/status.test.js
Normal 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);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user