Files
psu-schedule/server/alerts.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

206 lines
9.5 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.
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.
// 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 || "";
// Tests point this at a local stand-in.
const API = process.env.TELEGRAM_API || "https://api.telegram.org";
// With the public address, messages link to /api/diagnostics of the document.
const PUBLIC = (process.env.PSU_PUBLIC_URL || "").replace(/\/$/, "");
// The university is often down for a few minutes; only a longer outage is worth a message.
const DOWN_AFTER = Number(process.env.PSU_ALERT_SOURCE_DOWN_MS ?? 60 * 60 * 1000);
const STATE = "alerts.json";
const MAX_TEXT = 3900;
const MAX_DOCS = 8;
const MAX_PROBLEMS = 4;
const DAYS = ["", "пн", "вт", "ср", "чт", "пт", "сб", "вс"];
/** For /api/health: whether messages go out and how the last one went. */
export const telegram = { enabled: Boolean(TOKEN && CHAT), sent: 0, lastSentAt: null, lastError: null };
/** What the last message knew: every document with its date and its suspicious cells. */
let state;
let sourceNotified = false;
async function load() {
if (state !== undefined) return state;
try {
state = JSON.parse(await readFile(DATA + STATE, "utf8"));
} catch {
state = null;
}
return state;
}
async function save(next) {
state = next;
await mkdir(DATA, { recursive: true });
await writeFile(DATA + STATE, JSON.stringify(next));
}
/** Sends a message; false when it did not go out (the error is kept for /api/health). */
export async function send(text) {
if (!telegram.enabled) return false;
try {
const response = await fetch(`${API}/bot${TOKEN}/sendMessage`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
chat_id: CHAT,
text: text.length > MAX_TEXT ? text.slice(0, MAX_TEXT) + "\n…" : text,
disable_web_page_preview: true,
}),
signal: AbortSignal.timeout(15000),
});
if (!response.ok) throw new Error(`Telegram ответил ${response.status}: ${(await response.text()).slice(0, 200)}`);
telegram.sent++;
telegram.lastSentAt = new Date().toISOString();
telegram.lastError = null;
return true;
} catch (error) {
// Never the token in a log or in /api/health.
telegram.lastError = String(error.message).replaceAll(TOKEN, "…");
console.error(`Telegram: ${telegram.lastError}`);
return false;
}
}
const signature = (p) => `${p.code}|${(p.groups || []).join(",")}|${p.day ?? ""}|${p.start ?? ""}|${p.raw ?? ""}`;
const link = (id) => (PUBLIC ? `\n${PUBLIC}/api/diagnostics?doc=${id}` : "");
const problemLine = (p) =>
`• ${[(p.groups || []).join(", "), p.day ? `${DAYS[p.day]} ${p.start || ""}`.trim() : ""].filter(Boolean).join(", ")}: ${p.message}` +
(p.raw ? `\n «${p.raw.replace(/\s+/g, " ").slice(0, 90)}»` : "");
/** The catalog's documents as the index read them. */
function snapshot(idx, catalog) {
const docs = {};
for (const { id, file, schedule } of idx.docs)
docs[id] = {
name: file.name,
faculty: file.faculty || schedule.source.faculty || "",
modified: file.modified,
groups: schedule.groups,
problems: diagnose(schedule),
};
for (const f of idx.failed) {
const file = catalog.files.find((c) => c.id === f.id);
docs[f.id] = { name: f.name, faculty: file?.faculty || "", modified: file?.modified || null, groups: [], problems: [], failed: f.error };
}
return docs;
}
/**
* 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 (!idx) return;
const was = await load();
const docs = snapshot(idx, catalog);
const stored = (d) => ({ ...d, problems: d.problems.map(signature) });
const next = {
docs: Object.fromEntries(Object.entries(docs).map(([id, d]) => [id, stored(d)])),
year: catalog.year,
yearWarning: was?.yearWarning || null,
announced: Boolean(was?.announced),
};
const sections = [];
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}` });
}
}
// The new year's folder is late: the catalog still shows last year's documents.
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 (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: 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 (failure) {
const since = Date.parse(failure.since);
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Приложения показывают сохранённые копии расписаний.`,
);
return;
}
if (downRecorded) {
downRecorded = false;
await recordEvents([{ kind: "source_up", title: "Каталог университета снова отвечает" }]);
}
if (sourceNotified && (await send("Каталог университета снова отвечает."))) sourceNotified = false;
}