// 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();