- 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.
338 lines
14 KiB
JavaScript
338 lines
14 KiB
JavaScript
// 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();
|