Files
rf4-spotter/apps/web/src/pages/admin/index.astro
T
2026-09-21 20:23:32 +07:00

103 lines
16 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
import Layout from "../../layouts/Layout.astro";
import AdminNav from "../../components/AdminNav.astro";
const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
---
<Layout title="Административная панель — RF4 Spotter">
<section class="form-hero"><div><span class="eyebrow"><b>ADMIN</b> Центр управления</span><h1>Панель<br/><em>модератора</em></h1></div><p>Очереди, состояние источников и последние импорты в одном безопасном обзоре.</p></section>
<section class="admin-dashboard" data-api-url={apiUrl}>
<AdminNav />
<form class="admin-login" autocomplete="off"><label>Административный токен<input name="token" type="password" required autocomplete="off" /></label><button data-action="primary" type="submit">Открыть панель</button></form>
<p class="privacy">Токен существует только в памяти вкладки. Сессия завершится после 15 минут бездействия.</p>
<div class="admin-session-bar" hidden><span>Административная сессия активна</span><button data-action="secondary" type="button" data-admin-logout>Выйти</button></div>
<div class="notice error" data-admin-error role="alert" hidden></div>
<div class="notice success" data-admin-status role="status" hidden></div>
<section class="admin-dashboard-content" aria-live="polite"></section>
</section>
<script>
import { adminEndsSession, adminErrorMessage } from "../../lib/admin-errors";
const root = document.querySelector<HTMLElement>(".admin-dashboard");
const login = document.querySelector<HTMLFormElement>(".admin-login");
const content = document.querySelector<HTMLElement>(".admin-dashboard-content");
const error = document.querySelector<HTMLElement>("[data-admin-error]");
const status = document.querySelector<HTMLElement>("[data-admin-status]");
const sessionBar = document.querySelector<HTMLElement>(".admin-session-bar");
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;
let importHistoryOffset = 5;
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); };
const importStatusLabels: Record<string, string> = {running:"Выполняется",success:"Успешно",partial:"Частично",failed:"Ошибка"};
const importRow = (run: Record<string, unknown>) => { 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 `<li><span>${esc(importStatusLabels[state] ?? state)}<small>${esc(result)}</small></span><time>${esc(new Date(String(run.started_at)).toLocaleString("ru-RU"))}<small>${esc(finished)}</small></time></li>`; };
const ensureImportMoreButton = () => { const section = content?.querySelector<HTMLElement>(".admin-dashboard-grid section:nth-child(2)"); const list = section?.querySelector<HTMLUListElement>("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 = '<div class="loading-grid" aria-hidden="true"><div class="loading-card"></div><div class="loading-card"></div></div>';
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")]);
const reports = diagnostics.counts?.catch_reports ?? {}; const observations = diagnostics.counts?.external_observations ?? {};
const sourceLabels: Record<string, string> = {healthy:"Работает",waiting:"Ожидает",stale:"Устарел",disabled:"Отключён",source_changed:"Изменился",temporarily_limited:"Временно недоступен"};
const sourceRows = (sources as Record<string, unknown>[]).map(source => { const state = String(source.status); const safeState = Object.hasOwn(sourceLabels, state) ? state : "waiting"; const cooldown = Number(source.cooldown_seconds ?? 0); const detail = cooldown > 0 ? ` · cooldown ${Math.ceil(cooldown / 60)} мин` : source.backoff_recommended ? " · backoff рекомендован" : ""; const success = source.last_success_at ? ` · успех ${new Date(String(source.last_success_at)).toLocaleString("ru-RU")}` : " · успешных запусков нет"; return `<li><span><i class="status-dot ${safeState}"></i>${esc(source.name)}<small>${esc(detail + success)}</small></span><strong>${sourceLabels[safeState]}</strong></li>`; }).join("");
const savedCooldown = Math.max(0, ...(sources as Record<string, unknown>[]).map(source => Number(source.cooldown_seconds ?? 0)).filter(Number.isFinite));
if (savedCooldown > 0) startImportCooldown(savedCooldown);
const importLabels: Record<string, string> = {running:"Выполняется",success:"Успешно",partial:"Частично",failed:"Ошибка"};
const importRows = (imports as Record<string, unknown>[]).map(run => { const status = 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 `<li><span>${esc(importLabels[status] ?? status)}<small>${esc(result)}</small></span><time>${esc(new Date(String(run.started_at)).toLocaleString("ru-RU"))}<small>${esc(finished)}</small></time></li>`; }).join("");
const actionLabels: Record<string, string> = {approved:"Одобрено",rejected:"Отклонено",pending:"Возвращено на проверку",published:"Опубликовано",mapped:"Сопоставлено",ready:"Готово"};
const typeLabels: Record<string, string> = {catch_report:"Улов",external_observation:"Внешнее наблюдение"};
const historyRows = (history as Record<string, unknown>[]).map(event => { const action = String(event.action); const type = String(event.entity_type); return `<li><span><b>${esc(typeLabels[type] ?? "Запись")}</b> · ${esc(actionLabels[action] ?? action)}${event.reason ? `<small>${esc(event.reason)}</small>` : ""}</span><time>${esc(new Date(String(event.decided_at)).toLocaleString("ru-RU"))}</time></li>`; }).join("");
content.removeAttribute("aria-busy"); content.innerHTML = `<div class="admin-kpis"><a href="/admin/moderation"><span>Уловы на проверке</span><strong>${esc(reports.pending ?? 0)}</strong><small>Открыть очередь →</small></a><a href="/admin/external-sources"><span>Наблюдения в staging</span><strong>${esc((observations.staged ?? 0) + (observations.mapped ?? 0) + (observations.ready ?? 0))}</strong><small>Проверить источники →</small></a><article><span>Одобрено уловов</span><strong>${esc(reports.approved ?? 0)}</strong><small>Участвуют в статистике</small></article><article><span>Источников включено</span><strong>${esc(diagnostics.counts?.enabled_data_sources ?? 0)}</strong><small>из ${esc(diagnostics.counts?.data_sources ?? 0)}</small></article></div><div class="admin-dashboard-grid"><section><div class="admin-section-head"><h2>Состояние источников</h2><button type="button" data-action="secondary" data-refresh>Обновить</button></div><ul>${sourceRows || "<li>Нет данных</li>"}</ul><a href="/status">Публичная страница состояния →</a><br /><a href="/admin/media">Проверить медиа →</a></section><section><div class="admin-section-head"><h2>Последние импорты</h2><button type="button" data-action="secondary" data-official-import>Запустить импорт</button></div><ul>${importRows || "<li>Запусков пока нет</li>"}</ul><p class="privacy">Импорт обращается к официальному источнику и соблюдает cooldown.</p></section><section class="admin-history"><div class="admin-section-head"><h2>Последние решения</h2><button type="button" data-action="secondary" data-history-export>Экспорт JSON</button></div><ul>${historyRows || "<li>Решений пока нет</li>"}</ul><p class="privacy">Экспорт обезличен: без UUID, модератора, причин и исходных данных.</p></section></div>`;
}
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<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-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<string, unknown>[];
const list = content?.querySelector<HTMLUListElement>(".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<HTMLButtonElement>("[data-history-export],[data-official-import],[data-refresh]"); if (!button) return;
button.disabled = true;
try { if (button.hasAttribute("data-refresh")) { await loadDashboard(); succeed("Данные обновлены."); } else if (button.hasAttribute("data-official-import")) { const response = await fetch(`${root?.dataset.apiUrl}/api/v1/admin/imports/official-records`, {method:"POST",headers:{Authorization:`Bearer ${token}`}}); if (!response.ok) { if (adminEndsSession(response.status)) endSession(); throw new Error(response.status === 502 ? "Официальный источник временно недоступен. Старые данные сохранены." : adminErrorMessage(response.status, "Не удалось запустить импорт.")); } keepSession(); await loadDashboard(); succeed("Импорт запущен. Список запусков обновлён."); } else { const response = await fetch(`${root?.dataset.apiUrl}/api/v1/admin/moderation-history-export`, {headers:{Authorization:`Bearer ${token}`}}); if (!response.ok) { if (adminEndsSession(response.status)) endSession(); throw new Error(adminErrorMessage(response.status, "Не удалось выгрузить журнал решений.")); } const blob = await response.blob(); const link = document.createElement("a"); link.href = URL.createObjectURL(blob); link.download = "rf4spotter-moderation-history.json"; link.click(); URL.revokeObjectURL(link.href); keepSession(); } }
catch (cause) { fail(cause instanceof Error ? cause.message : "Операция не выполнена."); }
finally { button.disabled = false; }
});
</script>
</Layout>