feat: guard admin import cooldown
CI / backend-and-migrations (push) Waiting to run
CI / astro-build (push) Waiting to run
CI / dependency-audit (push) Waiting to run
CI / compose-e2e (push) Waiting to run

This commit is contained in:
ik
2026-09-21 20:16:43 +07:00
parent 2f3f6acd78
commit 26ad9b5194
+22
View File
@@ -25,11 +25,15 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
const logout = document.querySelector<HTMLButtonElement>("[data-admin-logout]");
let token = "";
let sessionTimer: ReturnType<typeof setTimeout> | undefined;
let importCooldownUntil = 0;
let importCooldownTimer: ReturnType<typeof setInterval> | undefined;
const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&amp;","<":"&lt;",">":"&gt;","'":"&#39;",'"':"&quot;"}[char] ?? char));
const fail = (message: string) => { if (error) { error.textContent = message; error.hidden = false; } };
const succeed = (message: string) => { if (error) error.hidden = true; if (status) { status.textContent = message; status.hidden = false; } };
const endSession = (message?: string) => { token = ""; if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = undefined; if (login) { login.hidden = false; login.reset(); } if (sessionBar) sessionBar.hidden = true; if (content) content.innerHTML = ""; if (message) fail(message); };
const keepSession = () => { if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = setTimeout(() => endSession("Сессия завершена после 15 минут бездействия. Введите токен снова."), 15 * 60 * 1000); };
const syncImportButton = () => { const button = content?.querySelector<HTMLButtonElement>("[data-official-import]"); if (!button) return; const seconds = Math.max(0, Math.ceil((importCooldownUntil - Date.now()) / 1000)); button.disabled = seconds > 0; button.textContent = seconds > 0 ? `Повторить через ${seconds < 60 ? `${seconds} с` : `${Math.ceil(seconds / 60)} мин`}` : "Запустить импорт"; button.setAttribute("aria-disabled", String(seconds > 0)); };
const startImportCooldown = (seconds: number) => { if (!Number.isFinite(seconds) || seconds <= 0) return; importCooldownUntil = Math.max(importCooldownUntil, Date.now() + Math.ceil(seconds) * 1000); if (importCooldownTimer) clearInterval(importCooldownTimer); syncImportButton(); importCooldownTimer = setInterval(() => { syncImportButton(); if (Date.now() >= importCooldownUntil && importCooldownTimer) { clearInterval(importCooldownTimer); importCooldownTimer = undefined; } }, 1000); };
async function authorizedJson(path: string) { const response = await fetch(`${root?.dataset.apiUrl}${path}`, {headers:{Authorization:`Bearer ${token}`}}); if (!response.ok) { if (adminEndsSession(response.status)) endSession(); throw new Error(adminErrorMessage(response.status, "Не удалось загрузить административные данные.")); } keepSession(); return response.json(); }
async function loadDashboard() {
if (!content) return;
@@ -48,6 +52,24 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
}
login?.addEventListener("submit", async event => { event.preventDefault(); token = String(new FormData(login).get("token") || ""); try { await loadDashboard(); login.hidden = true; if (sessionBar) sessionBar.hidden = false; } catch (cause) { if (content) { content.removeAttribute("aria-busy"); content.innerHTML = ""; } fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } });
logout?.addEventListener("click", () => endSession("Вы вышли из административной панели."));
content?.addEventListener("click", async event => {
const button = (event.target as HTMLElement).closest<HTMLButtonElement>("[data-official-import]");
if (!button) return;
event.preventDefault(); event.stopImmediatePropagation();
if (Date.now() < importCooldownUntil) { syncImportButton(); return; }
button.disabled = true;
try {
const response = await fetch(`${root?.dataset.apiUrl}/api/v1/admin/imports/official-records`, {method:"POST",headers:{Authorization:`Bearer ${token}`} });
if (!response.ok) {
const retryAfter = Number(response.headers.get("Retry-After") ?? 0);
if (response.status === 429 && retryAfter > 0) startImportCooldown(retryAfter);
if (adminEndsSession(response.status)) endSession();
throw new Error(response.status === 429 ? "Источник на cooldown. Повторный запуск временно заблокирован." : response.status === 502 ? "Официальный источник временно недоступен. Старые данные сохранены." : adminErrorMessage(response.status, "Не удалось запустить импорт."));
}
keepSession(); await loadDashboard(); succeed("Импорт запущен. Список запусков обновлён.");
} catch (cause) { fail(cause instanceof Error ? cause.message : "Операция не выполнена."); }
finally { syncImportButton(); }
}, true);
content?.addEventListener("click", async event => {
const button = (event.target as HTMLElement).closest<HTMLButtonElement>("[data-history-export],[data-official-import],[data-refresh]"); if (!button) return;
button.disabled = true;