249 lines
8.4 KiB
JavaScript
249 lines
8.4 KiB
JavaScript
const TOKEN_KEY = "hexawetter_admin_token";
|
||
const el = (id) => document.getElementById(id);
|
||
|
||
function getToken() {
|
||
return sessionStorage.getItem(TOKEN_KEY);
|
||
}
|
||
|
||
function setToken(token) {
|
||
if (token) sessionStorage.setItem(TOKEN_KEY, token);
|
||
else sessionStorage.removeItem(TOKEN_KEY);
|
||
}
|
||
|
||
async function api(path, options = {}) {
|
||
const headers = { ...(options.headers || {}) };
|
||
const token = getToken();
|
||
if (token) headers.Authorization = `Bearer ${token}`;
|
||
const res = await fetch(path, { ...options, headers });
|
||
if (res.status === 401) {
|
||
setToken(null);
|
||
showLogin();
|
||
throw new Error("Sitzung abgelaufen");
|
||
}
|
||
if (!res.ok) {
|
||
const text = await res.text();
|
||
throw new Error(`${res.status}: ${text}`);
|
||
}
|
||
return res.json();
|
||
}
|
||
|
||
function formatBytes(bytes) {
|
||
if (!bytes) return "0 B";
|
||
const units = ["B", "KB", "MB", "GB"];
|
||
let v = bytes, u = 0;
|
||
while (v >= 1024 && u < units.length - 1) { v /= 1024; u++; }
|
||
return `${v.toFixed(u === 0 ? 0 : 1)} ${units[u]}`;
|
||
}
|
||
|
||
async function fetchBlobUrl(path) {
|
||
const token = getToken();
|
||
const res = await fetch(path, { headers: token ? { Authorization: `Bearer ${token}` } : {} });
|
||
if (!res.ok) throw new Error(`${res.status}`);
|
||
const blob = await res.blob();
|
||
return URL.createObjectURL(blob);
|
||
}
|
||
|
||
async function downloadAdminFile(path, filename) {
|
||
const url = await fetchBlobUrl(path);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = filename;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
async function loadRadarData() {
|
||
try {
|
||
const local = await api("/api/admin/radar/files");
|
||
el("radarFiles").innerHTML = local.files.length ? local.files.slice().reverse().map((file) => `
|
||
<tr>
|
||
<td>${file.name}</td>
|
||
<td>${formatBytes(file.size_bytes)}</td>
|
||
<td>${new Date(file.modified_utc).toLocaleString("de-DE")}</td>
|
||
<td><button type="button" class="btn btn-secondary" data-download="${file.download_url}" data-name="${file.name}">Download</button></td>
|
||
</tr>
|
||
`).join("") : `<tr><td colspan="4" class="muted">Noch keine lokalen Radar-Dateien.</td></tr>`;
|
||
el("radarFiles").querySelectorAll("[data-download]").forEach((btn) => {
|
||
btn.addEventListener("click", () => downloadAdminFile(btn.dataset.download, btn.dataset.name));
|
||
});
|
||
} catch {
|
||
el("radarFiles").innerHTML = `<tr><td colspan="4" class="muted">Rohdaten nicht verfügbar</td></tr>`;
|
||
}
|
||
|
||
try {
|
||
const rendered = await api("/api/admin/radar/rendered?limit=12");
|
||
el("renderedCount").textContent = `${rendered.count} Frame(s)`;
|
||
const frames = rendered.frames || [];
|
||
if (!frames.length) {
|
||
el("renderedFrames").innerHTML = `<p class="muted">Noch keine gerenderten Frames.</p>`;
|
||
return;
|
||
}
|
||
el("renderedFrames").innerHTML = frames.map((f, i) => `
|
||
<figure class="rendered-frame" id="renderedFrame${i}">
|
||
<div class="muted" style="padding:40px;text-align:center">Lade…</div>
|
||
<figcaption>${f.filename}</figcaption>
|
||
</figure>
|
||
`).join("");
|
||
await Promise.allSettled(frames.map(async (f, i) => {
|
||
const url = await fetchBlobUrl(f.image_url);
|
||
const figure = el(`renderedFrame${i}`);
|
||
if (!figure) return;
|
||
figure.innerHTML = `<img src="${url}" alt="Radar ${f.filename}" loading="lazy" /><figcaption>${f.filename}</figcaption>`;
|
||
}));
|
||
} catch {
|
||
el("renderedCount").textContent = "–";
|
||
el("renderedFrames").innerHTML = `<p class="muted">Renderer nicht verfügbar.</p>`;
|
||
}
|
||
}
|
||
|
||
function showLogin() {
|
||
el("loginSection").classList.remove("admin-hidden");
|
||
el("dashboardSection").classList.add("admin-hidden");
|
||
el("logoutBtn").classList.add("admin-hidden");
|
||
}
|
||
|
||
function showDashboard() {
|
||
el("loginSection").classList.add("admin-hidden");
|
||
el("dashboardSection").classList.remove("admin-hidden");
|
||
el("logoutBtn").classList.remove("admin-hidden");
|
||
}
|
||
|
||
function renderServices(services) {
|
||
el("serviceGrid").innerHTML = (services || []).map((s) => `
|
||
<div class="source-status ${s.status}">
|
||
<strong>${s.name}</strong>
|
||
<span>${s.status}${s.latency_ms ? ` · ${s.latency_ms} ms` : ""}</span>
|
||
${s.http_status ? `<small>HTTP ${s.http_status}</small>` : ""}
|
||
${s.error ? `<small>${s.error}</small>` : ""}
|
||
</div>
|
||
`).join("");
|
||
}
|
||
|
||
function renderStorage(storage) {
|
||
el("storageGrid").innerHTML = Object.entries(storage || {}).map(([key, info]) => `
|
||
<div class="admin-stat">
|
||
<strong>${formatBytes(info.bytes || 0)}</strong>
|
||
<span>${key}: ${info.files || 0} Dateien</span>
|
||
</div>
|
||
`).join("");
|
||
}
|
||
|
||
function renderInfra(infra) {
|
||
el("infraGrid").innerHTML = Object.entries(infra || {}).map(([key, status]) => `
|
||
<div class="admin-stat">
|
||
<strong>${status}</strong>
|
||
<span>${key}</span>
|
||
</div>
|
||
`).join("");
|
||
}
|
||
|
||
async function loadDashboard() {
|
||
const data = await api("/api/admin/dashboard");
|
||
el("adminTime").textContent = `Stand: ${new Date(data.time_utc).toLocaleString("de-DE")}`;
|
||
renderInfra(data.infrastructure);
|
||
renderServices(data.services);
|
||
renderStorage(data.storage);
|
||
el("configBlock").textContent = JSON.stringify(data.config, null, 2);
|
||
|
||
const settings = await api("/api/admin/settings");
|
||
const defaults = settings.defaults;
|
||
const overrides = settings.runtime_overrides || {};
|
||
el("setRateLimit").value = overrides.rate_limit_per_minute ?? defaults.rate_limit_per_minute;
|
||
el("setCacheForecast").value = overrides.cache_ttl_forecast ?? defaults.cache_ttl.forecast;
|
||
el("setCacheObs").value = overrides.cache_ttl_observations ?? defaults.cache_ttl.observations;
|
||
el("setCacheWarn").value = overrides.cache_ttl_warnings ?? defaults.cache_ttl.warnings;
|
||
el("setRadarRetention").value = overrides.radar_retention_hours ?? defaults.radar_retention_hours;
|
||
await loadRadarData();
|
||
}
|
||
|
||
el("loginForm").addEventListener("submit", async (e) => {
|
||
e.preventDefault();
|
||
el("loginError").textContent = "";
|
||
try {
|
||
const data = await api("/api/admin/login", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ username: el("adminUser").value, password: el("adminPass").value }),
|
||
});
|
||
setToken(data.access_token);
|
||
showDashboard();
|
||
await loadDashboard();
|
||
} catch (err) {
|
||
el("loginError").textContent = err.message.includes("401") ? "Ungültige Zugangsdaten" : err.message;
|
||
}
|
||
});
|
||
|
||
el("logoutBtn").addEventListener("click", () => {
|
||
setToken(null);
|
||
showLogin();
|
||
});
|
||
|
||
el("refreshBtn").addEventListener("click", () => loadDashboard().catch((e) => alert(e.message)));
|
||
el("clearCacheBtn").addEventListener("click", async () => {
|
||
if (!confirm("Redis-Cache wirklich leeren?")) return;
|
||
await api("/api/admin/cache/clear", { method: "POST" });
|
||
alert("Cache geleert");
|
||
await loadDashboard();
|
||
});
|
||
|
||
el("syncRadarBtn").addEventListener("click", async () => {
|
||
const button = el("syncRadarBtn");
|
||
button.disabled = true;
|
||
const label = button.textContent;
|
||
button.textContent = "Sync läuft…";
|
||
try {
|
||
const result = await api("/api/admin/radar/sync?limit=6", { method: "POST" });
|
||
alert(`Sync abgeschlossen: ${result.downloaded?.length || 0} neu, ${result.skipped?.length || 0} übersprungen`);
|
||
await loadRadarData();
|
||
} catch (err) {
|
||
alert(err.message);
|
||
} finally {
|
||
button.disabled = false;
|
||
button.textContent = label;
|
||
}
|
||
});
|
||
|
||
el("settingsForm").addEventListener("submit", async (e) => {
|
||
e.preventDefault();
|
||
const payload = {
|
||
rate_limit_per_minute: Number(el("setRateLimit").value),
|
||
cache_ttl_forecast: Number(el("setCacheForecast").value),
|
||
cache_ttl_observations: Number(el("setCacheObs").value),
|
||
cache_ttl_warnings: Number(el("setCacheWarn").value),
|
||
radar_retention_hours: Number(el("setRadarRetention").value),
|
||
};
|
||
try {
|
||
await api("/api/admin/settings", {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
el("settingsMsg").textContent = "Einstellungen in Redis gespeichert.";
|
||
} catch (err) {
|
||
el("settingsMsg").textContent = err.message;
|
||
}
|
||
});
|
||
|
||
async function init() {
|
||
try {
|
||
const status = await fetch("/api/admin/status").then((r) => r.json());
|
||
if (!status.admin_enabled) {
|
||
el("loginError").textContent = "Admin ist nicht konfiguriert. Bitte ADMIN_PASSWORD_HASH und ADMIN_JWT_SECRET in .env setzen.";
|
||
return;
|
||
}
|
||
} catch { /* ignore */ }
|
||
|
||
if (getToken()) {
|
||
try {
|
||
showDashboard();
|
||
await loadDashboard();
|
||
return;
|
||
} catch {
|
||
setToken(null);
|
||
}
|
||
}
|
||
showLogin();
|
||
}
|
||
|
||
init();
|