feat: add admin operations dashboard

This commit is contained in:
ik
2026-09-12 21:35:18 +07:00
parent 7c28a0c36a
commit 6e18e8e565
4 changed files with 47 additions and 2 deletions
+43
View File
@@ -0,0 +1,43 @@
---
import Layout from "../../layouts/Layout.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>
<main class="admin-dashboard" data-api-url={apiUrl}>
<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>
<section class="admin-dashboard-content" aria-live="polite"></section>
</main>
<script>
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 sessionBar = document.querySelector<HTMLElement>(".admin-session-bar");
const logout = document.querySelector<HTMLButtonElement>("[data-admin-logout]");
let token = "";
let sessionTimer: ReturnType<typeof setTimeout> | 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 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); };
async function authorizedJson(path: string) { const response = await fetch(`${root?.dataset.apiUrl}${path}`, {headers:{Authorization:`Bearer ${token}`}}); if (response.status === 401 || response.status === 429) { endSession(); throw new Error(response.status === 429 ? "Слишком много попыток входа. Повторите позже." : "Неверный или истёкший административный токен."); } if (!response.ok) throw new Error("Не удалось загрузить административные данные."); keepSession(); return response.json(); }
async function publicJson(path: string) { const response = await fetch(`${root?.dataset.apiUrl}${path}`); return response.ok ? response.json() : []; }
async function loadDashboard() {
if (!content) return;
error?.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] = await Promise.all([authorizedJson("/api/v1/admin/imports?limit=5"), publicJson("/api/v1/source-status")]);
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"; return `<li><span><i class="status-dot ${safeState}"></i>${esc(source.name)}</span><strong>${sourceLabels[safeState]}</strong></li>`; }).join("");
const importRows = (imports as Record<string, unknown>[]).map(run => `<li><span>${esc(run.status)}</span><time>${esc(new Date(String(run.started_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><h2>Состояние источников</h2><ul>${sourceRows || "<li>Нет данных</li>"}</ul><a href="/status">Публичная страница состояния →</a></section><section><h2>Последние импорты</h2><ul>${importRows || "<li>Запусков пока нет</li>"}</ul></section></div>`;
}
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("Вы вышли из административной панели."));
</script>
</Layout>