diff --git a/apps/web/src/pages/admin/index.astro b/apps/web/src/pages/admin/index.astro index 1e003e6..23f01d2 100644 --- a/apps/web/src/pages/admin/index.astro +++ b/apps/web/src/pages/admin/index.astro @@ -27,6 +27,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000"; let sessionTimer: ReturnType | undefined; let importCooldownUntil = 0; let importCooldownTimer: ReturnType | undefined; + let importHistoryOffset = 5; const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&","<":"<",">":">","'":"'",'"':"""}[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; } }; @@ -34,9 +35,14 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000"; const keepSession = () => { if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = setTimeout(() => endSession("Сессия завершена после 15 минут бездействия. Введите токен снова."), 15 * 60 * 1000); }; const syncImportButton = () => { const button = content?.querySelector("[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); }; + const importStatusLabels: Record = {running:"Выполняется",success:"Успешно",partial:"Частично",failed:"Ошибка"}; + const importRow = (run: Record) => { const state = String(run.status); const rows = Number(run.rows_seen ?? 0); const created = Number(run.rows_created ?? 0); const updated = Number(run.rows_updated ?? 0); const result = rows ? ` · ${rows} строк · +${created}/↻${updated}` : ""; const finished = run.finished_at ? ` · завершён ${new Date(String(run.finished_at)).toLocaleString("ru-RU")}` : ""; return `
  • ${esc(importStatusLabels[state] ?? state)}${esc(result)}
  • `; }; + const ensureImportMoreButton = () => { const section = content?.querySelector(".admin-dashboard-grid section:nth-child(2)"); const list = section?.querySelector("ul"); if (!section || !list || list.children.length < 5 || section.querySelector("[data-import-more]")) return; const button = document.createElement("button"); button.type = "button"; button.dataset.action = "quiet"; button.dataset.importMore = "true"; button.textContent = "Показать ещё импорты"; list.after(button); }; + window.setInterval(ensureImportMoreButton, 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; + importHistoryOffset = 5; error?.setAttribute("hidden", ""); status?.setAttribute("hidden", ""); content.setAttribute("aria-busy", "true"); content.innerHTML = ''; const diagnostics = await authorizedJson("/api/v1/admin/diagnostics"); const [imports, sources, history] = await Promise.all([authorizedJson("/api/v1/admin/imports?limit=5"), authorizedJson("/api/v1/admin/source-status"), authorizedJson("/api/v1/admin/moderation-history?limit=8")]); @@ -52,7 +58,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000"; const historyRows = (history as Record[]).map(event => { const action = String(event.action); const type = String(event.entity_type); return `
  • ${esc(typeLabels[type] ?? "Запись")} · ${esc(actionLabels[action] ?? action)}${event.reason ? `${esc(event.reason)}` : ""}
  • `; }).join(""); content.removeAttribute("aria-busy"); content.innerHTML = `
    Уловы на проверке${esc(reports.pending ?? 0)}Открыть очередь →Наблюдения в staging${esc((observations.staged ?? 0) + (observations.mapped ?? 0) + (observations.ready ?? 0))}Проверить источники →
    Одобрено уловов${esc(reports.approved ?? 0)}Участвуют в статистике
    Источников включено${esc(diagnostics.counts?.enabled_data_sources ?? 0)}из ${esc(diagnostics.counts?.data_sources ?? 0)}

    Состояние источников

      ${sourceRows || "
    • Нет данных
    • "}
    Публичная страница состояния →
    Проверить медиа →

    Последние импорты

      ${importRows || "
    • Запусков пока нет
    • "}

    Импорт обращается к официальному источнику и соблюдает cooldown.

    Последние решения

      ${historyRows || "
    • Решений пока нет
    • "}

    Экспорт обезличен: без UUID, модератора, причин и исходных данных.

    `; } - 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(); ensureImportMoreButton(); 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("[data-official-import]"); @@ -72,6 +78,19 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000"; } catch (cause) { fail(cause instanceof Error ? cause.message : "Операция не выполнена."); } finally { syncImportButton(); } }, true); + content?.addEventListener("click", async event => { + const button = (event.target as HTMLElement).closest("[data-import-more]"); + if (!button) return; + event.preventDefault(); event.stopImmediatePropagation(); button.disabled = true; + try { + const rows = await authorizedJson(`/api/v1/admin/imports?limit=5&offset=${importHistoryOffset}`) as Record[]; + const list = content?.querySelector(".admin-dashboard-grid section:nth-child(2) ul"); + if (list) list.insertAdjacentHTML("beforeend", rows.map(importRow).join("")); + importHistoryOffset += rows.length; + if (rows.length < 5) button.remove(); else button.disabled = false; + keepSession(); + } catch (cause) { button.disabled = false; fail(cause instanceof Error ? cause.message : "Не удалось загрузить историю импортов."); } + }, true); content?.addEventListener("click", async event => { const button = (event.target as HTMLElement).closest("[data-history-export],[data-official-import],[data-refresh]"); if (!button) return; button.disabled = true;