feat(admin): Redesign — 5 Bereiche + Übersicht-Dashboard + Sub-Navigation
Von 8 gemischten Tabs auf 5 logische Bereiche verdichtet: - 📊 Übersicht (neu, Start): Kennzahlen (Nutzer, Anfragen, Notfälle) + LLM-Backend/GPU + Schnell-Sprünge. - 👥 Nutzer: Sub-Tabs Verwalten + Gespräche. - 🚨 Notfälle. - 🖥 System: Sub-Tabs Status (inkl. LLM-Steuerung) + Metriken + Log. - ⚙ Konfiguration: Sub-Tabs Einstellungen + Wörterbuch. - Sub-Navigation (Segmented-Buttons) für Bereiche mit mehreren Ansichten. - Bestehende Content-Blöcke + Loader unverändert wiederverwendet (nur neu gebündelt) -> geringes Risiko. loadOverview neu (vorhandene Endpunkte). - Doku §7.5 auf 5-Bereiche-Struktur aktualisiert; Wörterbuch-Hinweis (alle 8 Sprachen, Zeile-anklicken-bearbeiten) korrigiert. 167 grün, JS-Syntax ok. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
35fd3a5d15
commit
487c8aa3c1
3 changed files with 165 additions and 46 deletions
136
app/web/app.js
136
app/web/app.js
|
|
@ -673,43 +673,137 @@ async function adminFetch(url, method = "GET", body = null) {
|
|||
// ---------- Panel öffnen / schließen ----------
|
||||
function openAdminPanel() {
|
||||
$("#admin-panel").classList.remove("hidden");
|
||||
switchAdminTab("users");
|
||||
switchAdminTab("overview");
|
||||
}
|
||||
|
||||
$("#admin-close").addEventListener("click", () => $("#admin-panel").classList.add("hidden"));
|
||||
|
||||
// ---------- Tab-Umschalter ----------
|
||||
function switchAdminTab(name) {
|
||||
// ---------- Tab-Struktur: 5 Bereiche, je mit 1+ Abschnitten ----------
|
||||
// section = vorhandener Content-Block (#admin-tab-<section>); Bereiche mit mehreren
|
||||
// Abschnitten bekommen eine Sub-Navigation.
|
||||
const ADMIN_TABS = {
|
||||
overview: [["overview", "Übersicht"]],
|
||||
users: [["users", "Verwalten"], ["chat", "Gespräche"]],
|
||||
emergency: [["emergency", "Notfälle"]],
|
||||
system: [["status", "Status"], ["metrics", "Metriken"], ["log", "Log"]],
|
||||
config: [["settings", "Einstellungen"], ["words", "Wörterbuch"]],
|
||||
};
|
||||
const SECTION_LOADER = {
|
||||
overview: () => loadOverview(),
|
||||
users: () => loadAdminUsers(),
|
||||
chat: () => loadChatUsers(),
|
||||
emergency: () => loadEmergencyEvents(),
|
||||
status: () => loadStatus(),
|
||||
metrics: () => loadMetrics(),
|
||||
words: () => loadWoerterbuch(),
|
||||
settings: () => loadSettings(),
|
||||
log: () => {}, // Log: Nutzer verbindet manuell
|
||||
};
|
||||
|
||||
function _setTabActive(btn, active) {
|
||||
btn.classList.toggle("border-blue-600", active);
|
||||
btn.classList.toggle("text-blue-600", active);
|
||||
btn.classList.toggle("dark:border-blue-400", active);
|
||||
btn.classList.toggle("dark:text-blue-400", active);
|
||||
btn.classList.toggle("border-transparent", !active);
|
||||
btn.classList.toggle("text-slate-500", !active);
|
||||
}
|
||||
|
||||
function showAdminSection(section, top) {
|
||||
document.querySelectorAll('[id^="admin-tab-"]').forEach((el) => el.classList.add("hidden"));
|
||||
$("#admin-tab-" + name).classList.remove("hidden");
|
||||
|
||||
document.querySelectorAll(".admin-tab").forEach((btn) => {
|
||||
const active = btn.dataset.tab === name;
|
||||
btn.classList.toggle("border-blue-600", active);
|
||||
btn.classList.toggle("text-blue-600", active);
|
||||
btn.classList.toggle("dark:border-blue-400", active);
|
||||
btn.classList.toggle("dark:text-blue-400", active);
|
||||
btn.classList.toggle("border-transparent", !active);
|
||||
btn.classList.toggle("text-slate-500", !active);
|
||||
const panel = $("#admin-tab-" + section);
|
||||
if (panel) panel.classList.remove("hidden");
|
||||
// Sub-Tab markieren
|
||||
document.querySelectorAll(".admin-subtab").forEach((b) => {
|
||||
const active = b.dataset.sec === section;
|
||||
b.classList.toggle("bg-blue-600", active);
|
||||
b.classList.toggle("text-white", active);
|
||||
b.classList.toggle("border-blue-600", active);
|
||||
b.classList.toggle("text-slate-600", !active);
|
||||
b.classList.toggle("dark:text-slate-300", !active);
|
||||
b.classList.toggle("border-slate-300", !active);
|
||||
b.classList.toggle("dark:border-slate-600", !active);
|
||||
});
|
||||
const loader = SECTION_LOADER[section];
|
||||
if (loader) loader();
|
||||
}
|
||||
|
||||
if (name === "users") loadAdminUsers();
|
||||
if (name === "chat") loadChatUsers();
|
||||
if (name === "emergency") loadEmergencyEvents();
|
||||
if (name === "status") loadStatus();
|
||||
if (name === "metrics") loadMetrics();
|
||||
if (name === "words") loadWoerterbuch();
|
||||
if (name === "log") { /* User muss manuell verbinden */ }
|
||||
if (name === "settings") loadSettings();
|
||||
function switchAdminTab(top) {
|
||||
const sections = ADMIN_TABS[top] || [[top, top]];
|
||||
document.querySelectorAll(".admin-tab").forEach((btn) => _setTabActive(btn, btn.dataset.tab === top));
|
||||
|
||||
const subnav = $("#admin-subnav");
|
||||
if (sections.length <= 1) {
|
||||
subnav.classList.add("hidden");
|
||||
subnav.innerHTML = "";
|
||||
showAdminSection(sections[0][0], top);
|
||||
} else {
|
||||
subnav.classList.remove("hidden");
|
||||
subnav.innerHTML = sections.map(([sec, label]) =>
|
||||
`<button class="admin-subtab text-xs rounded-lg border px-3 py-1.5 whitespace-nowrap transition-colors border-slate-300 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700" data-sec="${sec}">${escHtml(label)}</button>`
|
||||
).join("");
|
||||
subnav.querySelectorAll(".admin-subtab").forEach((b) =>
|
||||
b.addEventListener("click", () => showAdminSection(b.dataset.sec, top))
|
||||
);
|
||||
showAdminSection(sections[0][0], top);
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll(".admin-tab").forEach((btn) =>
|
||||
btn.addEventListener("click", () => switchAdminTab(btn.dataset.tab))
|
||||
);
|
||||
$("#load-overview").addEventListener("click", loadOverview);
|
||||
$("#load-emergency").addEventListener("click", loadEmergencyEvents);
|
||||
$("#load-status").addEventListener("click", loadStatus);
|
||||
$("#load-metrics").addEventListener("click", loadMetrics);
|
||||
|
||||
// ════════════════════════════════════════
|
||||
// TAB: ÜBERSICHT (Dashboard)
|
||||
// ════════════════════════════════════════
|
||||
async function loadOverview() {
|
||||
const c = $("#overview-content");
|
||||
c.innerHTML = '<p class="text-sm text-slate-400">lade …</p>';
|
||||
const [users, usage, emergencies, llm] = await Promise.all([
|
||||
adminFetch("/api/admin/users"),
|
||||
adminFetch("/api/admin/usage"),
|
||||
adminFetch("/api/admin/emergency-events"),
|
||||
adminFetch("/api/admin/llm/status"),
|
||||
]);
|
||||
const userCount = users ? users.length : "—";
|
||||
const reqTotal = usage ? usage.reduce((s, u) => s + (u.total_requests || 0), 0) : "—";
|
||||
const emCount = emergencies ? emergencies.length : "—";
|
||||
const backend = llm ? ({ ollama: "Ollama", llamacpp: "llama.cpp" }[llm.backend] || llm.backend) : "—";
|
||||
const gpu = (llm && llm.gpus && llm.gpus.length)
|
||||
? llm.gpus.map((g) => `GPU ${g.index}: ${g.percent}%`).join(" · ") : "—";
|
||||
|
||||
const stat = (label, value, sub) => `
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400">${escHtml(label)}</p>
|
||||
<p class="text-2xl font-semibold mt-1">${escHtml(value)}</p>
|
||||
${sub ? `<p class="text-xs text-slate-400 mt-0.5 truncate">${escHtml(sub)}</p>` : ""}
|
||||
</div>`;
|
||||
|
||||
const jump = (to, label) =>
|
||||
`<button class="ov-jump text-sm rounded-lg border border-slate-300 dark:border-slate-600 px-3 py-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors" data-jump="${to}">${escHtml(label)}</button>`;
|
||||
|
||||
c.innerHTML = `
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
${stat("Nutzer", userCount)}
|
||||
${stat("Anfragen gesamt", reqTotal)}
|
||||
${stat("Notfälle", emCount, (typeof emCount === "number" && emCount > 0) ? "protokolliert" : "")}
|
||||
${stat("LLM-Backend", backend, llm ? llm.model : "")}
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
|
||||
<h4 class="font-semibold text-sm mb-2">GPU-Auslastung</h4>
|
||||
<p class="text-xs font-mono text-slate-600 dark:text-slate-300">${escHtml(gpu)}</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
${jump("users", "→ Nutzer")} ${jump("system", "→ System")} ${jump("emergency", "→ Notfälle")}
|
||||
</div>`;
|
||||
c.querySelectorAll(".ov-jump").forEach((b) =>
|
||||
b.addEventListener("click", () => switchAdminTab(b.dataset.jump)));
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════
|
||||
// TAB: NUTZER
|
||||
// ════════════════════════════════════════
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue