-
-
+
-
Ссылка проверяется скачиванием: если не открывается — не сохранится, данные на сайте останутся прежними.
- Значение хранится в файле настроек и имеет приоритет над .env — именно по нему сайт берёт расписание.
-
+
Ссылка проверяется скачиванием: если не открывается — не сохранится, данные на сайте останутся прежними.
+ Значение хранится в файле настроек и имеет приоритет над .env.
+
-
-
Обновление
-
-
-
По интервалу расписание перечитывается, только если на Диске изменилась дата файла. Кнопка «Обновить сейчас» скачивает таблицу всегда — её и нажимайте сразу после правок.
-
+
+ - Файл при последнем обновлении
- —
+ - Дата файла на Диске
- —
+ - Отпечаток файла
- —
+
+
По интервалу таблица перечитывается, только если на Диске изменилась дата файла.
+ Кнопка «Обновить сейчас» скачивает таблицу всегда — её и нажимайте сразу после правок.
+
-
-
+
+ Входы в админку
+
+ - Последняя попытка
- —
+ - Последний успешный вход
- —
+ - Осталось попыток
- —
+
+
+
+
+ Каждая попытка запоминается: откуда, когда и чем закончилась. Порог и время блокировки задаются в .env.
+
+
+
+ Данные расписания
+
+ - Групп
- —
+ - Обновлено
- —
+ - Файл настроек
- —
+
+
-
+
@@ -198,71 +302,87 @@
return isNaN(d) ? String(value) : d.toLocaleString("ru-RU", { dateStyle: "short", timeStyle: "short" });
}
- function chip(key, value, cls) {
- const div = document.createElement("div");
- div.className = "admin-chip";
- div.innerHTML = `
${key}
`;
- const v = div.querySelector(".v");
- v.textContent = value;
- if (cls) v.classList.add(cls);
- return div;
+ /** «5 минут назад» для свежести данных. */
+ function ago(value) {
+ if (!value) return "";
+ const d = new Date(value);
+ if (isNaN(d)) return "";
+ const sec = Math.max(0, Math.round((Date.now() - d.getTime()) / 1000));
+ if (sec < 60) return "только что";
+ const min = Math.round(sec / 60);
+ if (min < 60) return `${min} мин назад`;
+ const hours = Math.round(min / 60);
+ if (hours < 24) return `${hours} ч назад`;
+ return `${Math.round(hours / 24)} дн назад`;
}
function loginText(info) {
if (!info) return "нет данных";
- // у успешного входа поля ok нет — там итог и так известен
const status = info.ok === undefined ? "" : ` · ${info.ok ? "успешно" : "неудачно"}`;
return `${fmt(info.at)} · ${info.ip}${status}`;
}
- function renderState(state) {
- const chips = $("status-chips");
- chips.innerHTML = "";
- chips.appendChild(chip("Интервал обновления", `${state.interval_minutes} мин`));
- chips.appendChild(chip("Обновлено", fmt(state.last_updated)));
- chips.appendChild(chip(
- "Файл при последнем обновлении",
- state.changed ? "изменился" : "без изменений",
- state.changed ? "good" : null,
- ));
- chips.appendChild(chip("Дата файла на Диске", fmt(state.table_modified)));
- chips.appendChild(chip("Групп в расписании", String(state.groups)));
- if (state.content_hash) chips.appendChild(chip("sha256 файла", state.content_hash));
- chips.appendChild(chip("Текущая ссылка", state.link || "не задана"));
- if (state.settings_file) chips.appendChild(chip("Файл настроек", state.settings_file));
+ function setText(id, value, cls) {
+ const el = $(id);
+ if (!el) return;
+ el.textContent = value;
+ el.classList.remove("good", "bad");
+ if (cls) el.classList.add(cls);
+ }
- const auth = $("auth-chips");
- auth.innerHTML = "";
+ function renderState(state) {
+ // крупная строка состояния
+ const hero = $("hero");
+ const hasError = Boolean(state.error);
+ hero.classList.toggle("is-error", hasError);
+ hero.classList.toggle("is-ok", !hasError && state.has_data);
+ $("hero-title").textContent = hasError
+ ? "Ошибка загрузки расписания"
+ : (state.has_data ? "Работает" : "Жду данные");
+ const parts = [];
+ if (state.last_updated) parts.push(`обновлено ${fmt(state.last_updated)} (${ago(state.last_updated)})`);
+ parts.push(`групп: ${state.groups}`);
+ parts.push(`интервал ${state.interval_minutes} мин`);
+ $("hero-sub").textContent = parts.join(" · ");
+
+ const alert = $("hero-alert");
+ const warnings = [];
+ if (state.error) warnings.push("Ошибка загрузки: " + state.error);
+ if (state.settings_save_error) {
+ warnings.push("Настройки не сохраняются на диск: " + state.settings_save_error +
+ " — после перезапуска службы ссылка и интервал вернутся к значениям из .env");
+ }
+ alert.classList.toggle("warn", !state.error && Boolean(state.settings_save_error));
+ alert.textContent = warnings.join(" ");
+ alert.classList.toggle("hidden", !warnings.length);
+
+ // ссылка
+ $("link").value = state.link || "";
+ const open = $("link-open");
+ open.href = state.link || "#";
+ open.classList.toggle("hidden", !state.link);
+
+ // обновление
+ $("interval").value = state.interval_minutes;
+ setText("fact-changed", state.changed ? "изменился" : "без изменений", state.changed ? "good" : null);
+ setText("fact-modified", fmt(state.table_modified));
+ setText("fact-hash", state.content_hash || "—");
+
+ // входы
const attempt = state.last_attempt;
- auth.appendChild(chip(
- "Последняя попытка входа",
- loginText(attempt),
- attempt ? (attempt.ok ? "good" : "bad") : null,
- ));
- auth.appendChild(chip("Последний успешный вход", loginText(state.last_success)));
+ setText("auth-attempt", loginText(attempt), attempt ? (attempt.ok ? "good" : "bad") : null);
+ setText("auth-success", loginText(state.last_success));
const throttle = state.throttle || {};
if (throttle.locked_until) {
- auth.appendChild(chip("Вход заблокирован до", fmt(throttle.locked_until), "bad"));
+ setText("auth-throttle", `заблокировано до ${fmt(throttle.locked_until)}`, "bad");
} else if (throttle.max_attempts) {
- auth.appendChild(chip(
- "Осталось попыток",
- `${throttle.attempts_left} из ${throttle.max_attempts}`,
- ));
+ setText("auth-throttle", `${throttle.attempts_left} из ${throttle.max_attempts}`);
}
- $("link").value = state.link || "";
- $("interval").value = state.interval_minutes;
-
- const err = $("state-error");
- err.textContent = state.error ? "Ошибка загрузки: " + state.error : "";
- err.classList.toggle("hidden", !state.error);
-
- const warn = $("state-warn");
- warn.textContent = state.settings_save_error
- ? "Настройки не сохраняются на диск: " + state.settings_save_error +
- " — после перезапуска службы ссылка и интервал вернутся к значениям из .env."
- : "";
- warn.classList.toggle("hidden", !state.settings_save_error);
+ // данные
+ setText("fact-groups", String(state.groups));
+ setText("fact-updated", state.last_updated ? `${fmt(state.last_updated)} (${ago(state.last_updated)})` : "—");
+ setText("fact-settings", state.settings_file || "—");
}
function showLogin(show) {
@@ -278,6 +398,11 @@
return () => { button.disabled = false; button.textContent = original; };
}
+ async function withButton(button, text, fn) {
+ const done = busy(button, text);
+ try { await fn(); } finally { done(); }
+ }
+
async function loadState() {
const r = await api("/state");
if (r.status === 401) { showLogin(true); return; }
@@ -289,11 +414,6 @@
renderState(r.data);
}
- async function withButton(button, text, fn) {
- const done = busy(button, text);
- try { await fn(); } finally { done(); }
- }
-
$("login-form").addEventListener("submit", async (e) => {
e.preventDefault();
const error = $("login-error");
@@ -350,6 +470,13 @@
showLogin(true);
});
+ $("theme-toggle").addEventListener("click", () => {
+ const current = document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light";
+ const next = current === "dark" ? "light" : "dark";
+ document.documentElement.setAttribute("data-theme", next);
+ try { localStorage.setItem("theme", next); } catch (_) {}
+ });
+
loadState();