From 3ae7b38fcad3c77619d00e3d577897cb12447cf2 Mon Sep 17 00:00:00 2001 From: Zakhar Timoshenko Date: Wed, 21 Oct 2026 00:04:33 +0300 Subject: [PATCH] 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. --- README.md | 20 +- docker-compose.yml | 3 + server/alerts.js | 132 ++++++----- server/auth.js | 45 ++++ server/diagnostics.js | 3 +- server/events.js | 39 ++++ server/index.js | 55 ++++- server/metrics.js | 15 ++ server/status/index.html | 87 +++++++ server/status/status.css | 474 +++++++++++++++++++++++++++++++++++++++ server/status/status.js | 335 +++++++++++++++++++++++++++ test/api.test.js | 5 + test/metrics.test.js | 9 +- test/status.test.js | 54 +++++ test/watch.test.js | 14 +- 15 files changed, 1216 insertions(+), 74 deletions(-) create mode 100644 server/auth.js create mode 100644 server/events.js create mode 100644 server/status/index.html create mode 100644 server/status/status.css create mode 100644 server/status/status.js create mode 100644 test/status.test.js diff --git a/README.md b/README.md index d6b0d6c..120d4b5 100644 --- a/README.md +++ b/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=`, `?code=weeks_partly`. Число таких ячеек есть и в `/api/health` (`directory.problems`). Коды: +**Подозрительные ячейки.** После разбора каждый документ каталога проверяется (`server/diagnostics.js`), и `GET /api/diagnostics` показывает ячейки, которые прочитаны неуверенно, с исходным текстом. Фильтры: `?group=23-АРХ`, `?doc=`, `?code=weeks_partly`. Их число и список с фильтрами — на дашборде `/status`. Коды: - `weeks_partly` — пометка недель прочитана не целиком (`1-4н.;6-12н.`); - `weeks_unreadable` — пометка недель не прочитана (`2,6-12-16н`); - `weeks_like_date` — «недели» похожи на дату с опечаткой (`19,11, 03.12`); diff --git a/docker-compose.yml b/docker-compose.yml index e31d023..594169f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 уже есть в образе. diff --git a/server/alerts.js b/server/alerts.js index 539ec0a..f5eb331 100644 --- a/server/alerts.js +++ b/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; } diff --git a/server/auth.js b/server/auth.js new file mode 100644 index 0000000..efae8f0 --- /dev/null +++ b/server/auth.js @@ -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("Нужен пароль дашборда."); + }; +} diff --git a/server/diagnostics.js b/server/diagnostics.js index 4ae8d24..ba3f86c 100644 --- a/server/diagnostics.js +++ b/server/diagnostics.js @@ -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, diff --git a/server/events.js b/server/events.js new file mode 100644 index 0000000..480aabb --- /dev/null +++ b/server/events.js @@ -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); +} diff --git a/server/index.js b/server/index.js index 7cdefcb..e076f75 100644 --- a/server/index.js +++ b/server/index.js @@ -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) => { diff --git a/server/metrics.js b/server/metrics.js index 290d8ce..b99c994 100644 --- a/server/metrics.js +++ b/server/metrics.js @@ -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, diff --git a/server/status/index.html b/server/status/index.html new file mode 100644 index 0000000..89ba382 --- /dev/null +++ b/server/status/index.html @@ -0,0 +1,87 @@ + + + + + + + Статус · Расписание ПГУ + + + + + +
+
+

Расписание ПГУ

+

Статус сервера

+
+
+ + +
+
+
+ + +
+ +
+
+

Запросы за час

+ +
+
+
+ +
+ С ответом + Без изменений (304) + Ошибки + −60 мин−30 минсейчас +
+
+
+
+

Адреса

+ + + + + +
АдресЗапросовСреднееОшибок
+
+
+

Самые активные клиенты

+ + + + + +
АдресЗапросов
+

Адреса без последней части: точные не хранятся.

+
+
+
+ +
+
+

События

+
    +
    + +
    +
    +

    Подозрительные ячейки

    + +
    +
    + +
    +
    +
    +
    +
    +
    +
    + + diff --git a/server/status/status.css b/server/status/status.css new file mode 100644 index 0000000..0662c9f --- /dev/null +++ b/server/status/status.css @@ -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; + } +} diff --git a/server/status/status.js b/server/status/status.js new file mode 100644 index 0000000..fb58e81 --- /dev/null +++ b/server/status/status.js @@ -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(); diff --git a/test/api.test.js b/test/api.test.js index 93fd512..474928d 100644 --- a/test/api.test.js +++ b/test/api.test.js @@ -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"); diff --git a/test/metrics.test.js b/test/metrics.test.js index b1a2715..8ddba64 100644 --- a/test/metrics.test.js +++ b/test/metrics.test.js @@ -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); diff --git a/test/status.test.js b/test/status.test.js new file mode 100644 index 0000000..69712e1 --- /dev/null +++ b/test/status.test.js @@ -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, /