Files
psu-schedule/server/status/status.js
Zakhar Timoshenko 3ae7b38fca 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.
2026-10-21 00:04:33 +03:00

336 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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();