feat: guard admin import cooldown
This commit is contained in:
@@ -25,11 +25,15 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
|||||||
const logout = document.querySelector<HTMLButtonElement>("[data-admin-logout]");
|
const logout = document.querySelector<HTMLButtonElement>("[data-admin-logout]");
|
||||||
let token = "";
|
let token = "";
|
||||||
let sessionTimer: ReturnType<typeof setTimeout> | undefined;
|
let sessionTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let importCooldownUntil = 0;
|
||||||
|
let importCooldownTimer: ReturnType<typeof setInterval> | undefined;
|
||||||
const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&","<":"<",">":">","'":"'",'"':"""}[char] ?? char));
|
const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&","<":"<",">":">","'":"'",'"':"""}[char] ?? char));
|
||||||
const fail = (message: string) => { if (error) { error.textContent = message; error.hidden = false; } };
|
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 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 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 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 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() {
|
async function loadDashboard() {
|
||||||
if (!content) return;
|
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 : "Ошибка загрузки."); } });
|
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("Вы вышли из административной панели."));
|
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 => {
|
content?.addEventListener("click", async event => {
|
||||||
const button = (event.target as HTMLElement).closest<HTMLButtonElement>("[data-history-export],[data-official-import],[data-refresh]"); if (!button) return;
|
const button = (event.target as HTMLElement).closest<HTMLButtonElement>("[data-history-export],[data-official-import],[data-refresh]"); if (!button) return;
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
|
|||||||
Reference in New Issue
Block a user