Files
psu-schedule/server/status/status.js
Zakhar Timoshenko c0487e4c2a feat: enhance user-agent identification and implement backoff strategy for university source requests
- Added PSU_CONTACT environment variable to docker-compose for owner contact in User-Agent.
- Updated source.js to include user-agent construction with service version and contact information.
- Implemented exponential backoff strategy for failed requests to the university, with retry logic based on HTTP response status.
- Modified status.js to display backoff information in the status response.
- Adjusted watch.js to reduce request frequency during university downtime.
- Enhanced test coverage with new backoff.test.js to validate backoff behavior and user-agent format.
2026-10-22 01:17:54 +03:00

338 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.catalog?.backoff?.retryAt ? ` · следующая попытка ${dateTime(d.catalog.backoff.retryAt)}` : "")
: 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();