103 lines
16 KiB
Plaintext
103 lines
16 KiB
Plaintext
---
|
||
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 => ({"&":"&","<":"<",">":">","'":"'",'"':"""}[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>
|