"use strict"; const state = { session: null, timer: null, groups: null, storage: null, activity: null }; const loginView = document.querySelector("#login-view"); const appView = document.querySelector("#app-view"); const content = document.querySelector("#content"); const nav = document.querySelector("#navigation"); const toast = document.querySelector("#toast"); const esc = value => String(value ?? "").replace(/[&<>'"]/g, char => ({"&":"&","<":"<",">":">","'":"'",'"':"""}[char])); const bytes = value => { let number = Number(value || 0); const units = ["B", "kB", "MB", "GB", "TB", "PB"]; let unit = 0; while (Math.abs(number) >= 1024 && unit < units.length - 1) { number /= 1024; unit += 1; } const digits = number >= 100 || unit === 0 ? 0 : 1; return `${number.toLocaleString("de-DE", {minimumFractionDigits: digits, maximumFractionDigits: digits})} ${units[unit]}`; }; const decimal = (value, digits = 1) => Number(value || 0).toLocaleString("de-DE", {minimumFractionDigits: digits, maximumFractionDigits: digits}); const utcTime = value => { if (!value) return "—"; const date = new Date(value); if (Number.isNaN(date.getTime())) return "—"; const pad = number => String(number).padStart(2, "0"); return `${pad(date.getUTCDate())}.${pad(date.getUTCMonth() + 1)}.${date.getUTCFullYear()} ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())} UTC`; }; const actionLabel = value => ({read: "Lesen", write: "Schreiben", move: "Umbenennen/Verschieben", delete: "Löschen"}[value] || value || "—"); const nodeTypeLabel = value => ({group: "Gruppe", user: "Benutzer", computer: "Computer", unknown: "Unbekannt"}[value] || value); const backupStateLabel = value => ({starting: "Startet", running: "Läuft", completed: "Abgeschlossen", failed: "Fehlgeschlagen", waiting: "Wartet", disabled: "Deaktiviert"}[value] || value || "Unbekannt"); function backupMessage(data) { const message = String(data.message || ""); if (!message) return data.enabled ? `Täglich um ${String(data.scheduledHour).padStart(2, "0")}:00 UTC geplant.` : "Sicherungen sind nicht eingerichtet."; if (message === "Starting backup") return "Sicherung wird gestartet."; if (message === "Measuring backup payload") return "Sicherungsumfang wird ermittelt."; if (message.startsWith("Backup payload: ")) return `Sicherungsumfang: ${message.slice(16)}`; if (message.startsWith("Syncing ")) return `${message.slice(8)} wird synchronisiert.`; const completed = message.match(/^Backup completed; (\d+) snapshot\(s\) retained$/); if (completed) return `Sicherung abgeschlossen; ${completed[1]} Sicherungsstände werden aufbewahrt.`; if (data.state === "failed") return "Sicherung fehlgeschlagen. Einzelheiten stehen im Sicherungsprotokoll."; if (data.state === "completed") return "Sicherung abgeschlossen."; if (data.state === "running") return "Sicherung läuft."; if (data.state === "starting") return "Sicherung wird gestartet."; return "Kein Laufstatus verfügbar."; } const isoDay = date => date.toISOString().slice(0, 10); const sum = (rows, field) => (rows || []).reduce((total, row) => total + Number(row[field] || 0), 0); async function api(path, options = {}) { const response = await fetch(path, { ...options, headers: {"Content-Type": "application/json", ...(options.headers || {})}, credentials: "same-origin", }); let body = {}; try { body = await response.json(); } catch (_) { /* no body */ } if (response.status === 401 && path !== "/api/login") { showLogin(); throw new Error("Die Sitzung ist abgelaufen. Bitte erneut anmelden."); } if (!response.ok) throw new Error(body.error || `Anfrage fehlgeschlagen (${response.status})`); return body; } function notice(message) { toast.textContent = message; toast.hidden = false; window.setTimeout(() => { toast.hidden = true; }, 4000); } function showLogin() { clearInterval(state.timer); state.session = null; appView.hidden = true; loginView.hidden = false; document.querySelector("#login-form input[name=username]").focus(); } function showApp(session) { state.session = {user: session.user, expiresAt: session.expiresAt}; document.querySelector("#session-user").textContent = session.user; loginView.hidden = true; appView.hidden = false; navigate(location.pathname === "/" ? "/overview" : location.pathname, true); } function setLoading() { content.innerHTML = '
Wird geladen…
'; } function pageHead(title, intro, extra = "") { return `

${esc(title)}

${esc(intro)}

${extra}
`; } function empty(message) { return `
${esc(message)}
`; } function badge(text, kind = "") { return `${esc(text)}`; } function routeFor(path) { if (path.startsWith("/storage/data")) return "storage-data"; if (path.startsWith("/storage/users")) return "storage-users"; if (path.startsWith("/shares")) return "shares"; if (path.startsWith("/activity")) return "activity"; if (path.startsWith("/backup")) return "backup"; if (path.startsWith("/system")) return "system"; return "overview"; } async function navigate(path, replace = false) { clearInterval(state.timer); state.timer = null; const route = routeFor(path); if (replace) history.replaceState({}, "", path); else history.pushState({}, "", path); nav.querySelectorAll("a").forEach(link => link.classList.toggle("active", link.dataset.route === route)); document.querySelector(".sidebar").classList.remove("open"); setLoading(); try { if (route === "overview") await renderOverview(); if (route === "shares") await renderShares(); if (route === "storage-data") await renderStorage("data"); if (route === "storage-users") await renderStorage("users"); if (route === "activity") await renderActivity(); if (route === "backup") await renderBackup(); if (route === "system") await renderSystem(); content.focus(); } catch (error) { content.innerHTML = pageHead("Seite konnte nicht geladen werden", error.message) + `
${empty("Dienststatus prüfen und erneut versuchen.")}
`; } } function eventRows(events) { if (!events?.length) return 'Keine passenden Ereignisse'; return events.map(event => ` ${esc(utcTime(event.timestamp))} ${esc(event.user)}${esc(event.clientIp)}${esc(event.share)} ${badge(actionLabel(event.action || event.operation))} ${esc(event.path || "—")} ${event.success ? badge("Erfolgreich") : badge(event.result || "Fehlgeschlagen", "error")} `).join(""); } async function renderOverview() { const data = await api("/api/overview"); const usage = data.usage || {}; const totals = usage.totals || {}; const backup = data.backup || {}; content.innerHTML = pageHead("Übersicht", "Speicherbelegung, Aktivität und Sicherungsstatus.", `Stand ${esc(utcTime(usage.scannedAt))}`) + `
Daten${bytes(totals.dataBytes)}
Private + FSLogix${bytes(Number(totals.privateBytes || 0) + Number(totals.fslogixBytes || 0))}
Aktive Gruppen${esc(data.activeGroups)}
Ereignisse · 48 Std.${esc(data.eventCount)}

Größte Datengruppen

Alle anzeigen
${usageTable((usage.groups || []).slice(0, 7), "group")}

Sicherung

Details
${badge(backupStateLabel(backup.state || (backup.enabled ? "waiting" : "disabled")), backup.state === "failed" ? "error" : "")}${esc(backupMessage(backup))}
${decimal(backup.percent)} % · ${bytes(backup.transferredBytes)} / ${bytes(backup.totalBytes)}

Letzte Dateiaktivitäten

Protokoll öffnen
${eventRows(data.recentEvents)}
Zeit (UTC)BenutzerClientFreigabeAktionPfadErgebnis
`; bindInternalLinks(); } function usageTable(rows, type) { if (!rows.length) return empty("Die erste Speicherprüfung ist noch nicht abgeschlossen."); const maximum = Math.max(...rows.map(row => Number(type === "group" ? row.bytes : row.totalBytes)), 1); return `
${type === "user" ? "" : ""}${rows.map(row => { const value = Number(type === "group" ? row.bytes : row.totalBytes); return `${type === "user" ? `` : ""}`; }).join("")}
${type === "group" ? "Gruppenordner" : "Benutzer"}PrivateFSLogixBelegung
${esc(row.name)}${bytes(row.privateBytes)}${bytes(row.fslogixBytes)}${bytes(value)}
`; } function nodeMatches(node, query) { if (!query) return true; if (`${node.name} ${node.sam} ${node.type} ${nodeTypeLabel(node.type)}`.toLowerCase().includes(query)) return true; return (node.members || []).some(child => nodeMatches(child, query)); } function treeNodes(nodes, query = "") { return nodes.filter(node => nodeMatches(node, query)).map(node => { const children = treeNodes(node.members || [], query); const title = `${esc(nodeTypeLabel(node.type))} ${esc(node.name)}${node.sam && node.sam !== node.name ? ` ${esc(node.sam)}` : ""}${node.cycle ? ` ${badge("Zyklus", "warn")}` : ""}`; return children ? `
${title}${children}
` : `
${title}
`; }).join(""); } async function renderShares() { const data = await api("/api/groups"); state.groups = data; const groups = data.groups || []; content.innerHTML = pageHead("Dateifreigaben", "FS_*-Ordnergruppen und ihre wirksamen verschachtelten Mitgliedschaften.", `Verzeichnisstand ${esc(utcTime(data.fetchedAt))}`) + ` ${data.truncated ? `

${badge("Ergebnis gekürzt", "warn")} WEB_MAX_GROUP_NODES erhöhen, um alle Mitglieder anzuzeigen.

` : ""}

Gruppenordner

${groups.length}
`; const list = document.querySelector("#group-list"); const tree = document.querySelector("#tree-panel"); let selected = groups[0] || null; let query = ""; const draw = () => { const visible = groups.filter(group => `${group.name} ${group.sam} ${group.folder}`.toLowerCase().includes(query) || group.members.some(node => nodeMatches(node, query))); if (selected && !visible.includes(selected)) selected = visible[0] || null; list.innerHTML = visible.length ? visible.map(group => `
  • `).join("") : empty("Keine Gruppe entspricht dem Filter."); if (!selected) tree.innerHTML = empty("Gruppenordner auswählen."); else tree.innerHTML = `

    ${esc(selected.folder)}

    ${esc(selected.sam)} · ${selected.userCount} wirksame Benutzer
    ${selected.active ? badge("Aktiv") : badge("Archiviert", "warn")}
    ${treeNodes(selected.members, query) || empty("Keine direkten Mitglieder")}
    `; list.querySelectorAll("button[data-guid]").forEach(button => button.addEventListener("click", () => { selected = groups.find(group => group.guid === button.dataset.guid); draw(); })); }; document.querySelector("#group-filter").addEventListener("input", event => { query = event.target.value.trim().toLowerCase(); draw(); }); draw(); } async function renderStorage(type) { const data = await api("/api/storage"); state.storage = data; let query = ""; let sort = "size-desc"; content.innerHTML = pageHead(type === "data" ? "Datenbelegung" : "Benutzerbelegung", type === "data" ? "Belegter Speicher je aktivem Gruppenordner unter /Data." : "Gemeinsame Belegung von /Private und /FSLogix je Benutzer.", `Geprüft ${esc(utcTime(data.scannedAt))} in ${esc(data.scanSeconds || 0)} s`) + `
    `; const draw = () => { const source = [...(type === "data" ? data.groups || [] : data.users || [])]; let rows = source.filter(row => row.name.toLowerCase().includes(query)); const field = type === "data" ? "bytes" : "totalBytes"; rows.sort((a, b) => sort === "name" ? a.name.localeCompare(b.name) : sort === "size-asc" ? Number(a[field]) - Number(b[field]) : Number(b[field]) - Number(a[field])); document.querySelector("#storage-table").innerHTML = `

    ${rows.length} Einträge · ${bytes(sum(rows, field))} angezeigt

    ${usageTable(rows, type === "data" ? "group" : "user")}`; }; document.querySelector("#storage-filter").addEventListener("input", event => { query = event.target.value.trim().toLowerCase(); draw(); }); document.querySelector("#storage-sort").addEventListener("change", event => { sort = event.target.value; draw(); }); draw(); } async function renderActivity() { const today = new Date(); const yesterday = new Date(Date.now() - 86400000); content.innerHTML = pageHead("Aktivitätsprotokoll", "Lese-, Schreib-, Umbenennungs-/Verschiebe- und Löschvorgänge nach Datum, Identität, Freigabe, Ergebnis oder Pfad durchsuchen.") + `

    Zeit (UTC)BenutzerClientFreigabeAktionPfadErgebnis

    `; const form = document.querySelector("#activity-filter"); let cursor = 0; const load = async append => { const params = new URLSearchParams(new FormData(form)); params.set("limit", "100"); if (cursor) params.set("cursor", String(cursor)); const result = await api(`/api/activity?${params}`); state.activity = result; const rows = document.querySelector("#activity-rows"); if (append) rows.insertAdjacentHTML("beforeend", eventRows(result.events)); else rows.innerHTML = eventRows(result.events); document.querySelector("#activity-summary").textContent = `${result.matched.toLocaleString("de-DE")} passende Ereignisse`; const more = document.querySelector("#load-more"); cursor = result.nextCursor || 0; more.hidden = !result.nextCursor; for (const [id, values] of [["users-list", result.facets.users], ["shares-list", result.facets.shares]]) { document.querySelector(`#${id}`).innerHTML = values.filter(Boolean).map(value => `