"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", 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) { if (!data.enabled) return "Sicherungen sind nicht eingerichtet."; if (!data.automaticEnabled) return "Automatische Sicherungen sind ausgeschaltet; manueller Start ist verfügbar."; return `Täglich um ${String(data.scheduledHour).padStart(2, "0")}:00 UTC geplant.`; } if (message === "Starting backup") return "Sicherung wird gestartet."; if (message === "Creating consistent state database snapshot") return "Konsistenter Datenbankstand wird erstellt."; if (message === "Creating encrypted group archives") return "Verschlüsselte Gruppenarchive werden erstellt."; if (message.startsWith("Archiving group ")) return `Gruppenarchiv ${message.slice(16)} wird erstellt.`; 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.interrupted) return "Sicherung wurde durch einen Neustart oder Prozessabbruch unterbrochen."; 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() { state.accessDirty = false; 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("/access")) return "access"; if (path.startsWith("/shares")) return "shares"; if (path.startsWith("/reconciliation")) return "reconciliation"; if (path.startsWith("/activity/fslogix")) return "activity-fslogix"; if (path.startsWith("/activity")) return "activity"; if (path.startsWith("/trash")) return "trash"; if (path.startsWith("/backup")) return "backup"; if (path.startsWith("/report")) return "report"; if (path.startsWith("/system")) return "system"; return "overview"; } async function navigate(path, replace = false) { if (state.accessDirty && !window.confirm("Ungespeicherte Änderungen verwerfen?")) { if (replace && state.currentPath) history.replaceState({}, "", state.currentPath); return; } state.accessDirty = false; state.currentPath = path; 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 === "access") await renderAccess(); if (route === "reconciliation") await renderReconciliation(); if (route === "storage-data") await renderStorage("data"); if (route === "storage-users") await renderStorage("users"); if (route === "activity") await renderActivity(); if (route === "activity-fslogix") await renderActivity("fslogix"); if (route === "trash") await renderTrash(); if (route === "backup") await renderBackup(); if (route === "report") await renderReport(); 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 Datenordner${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" ? "Datenordner" : "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", "Verwaltete Ordner und ihre individuellen Benutzerberechtigungen.", `Verzeichnisstand ${esc(utcTime(data.fetchedAt))}`) + ` ${data.truncated ? `

${badge("Ergebnis gekürzt", "warn")} Mitgliedschaftsliste ist unvollständig.

` : ""}

Datenordner

${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("Kein Ordner entspricht dem Filter."); if (!selected) tree.innerHTML = empty("Datenordner 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 Benutzer mit Zugriff")}
    `; 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(); } const permissionLabels = ["Kein Zugriff", "Lesen", "Lesen + Ändern", "Lesen + Ändern + Löschen"]; async function renderAccess() { let data = await api("/api/access"); let folderId = data.folders.find(folder => folder.active)?.id || data.folders[0]?.id; let rules = [], query = "", userQuery = "", userFilter = "all", busy = false; let folderFilter = data.folders.length && !data.folders.some(item => item.active) ? "archived" : "active"; const folder = () => data.folders.find(item => item.id === folderId); const normalizedRules = value => JSON.stringify(value.filter(rule => rule.level > 0).map(rule => [rule.principalId, rule.level]).sort((a, b) => a[0].localeCompare(b[0]))); const loadDraft = () => { rules = (folder()?.permissions || []).filter(rule => rule.level > 0).map(rule => ({...rule})); state.accessDirty = false; }; const dirty = () => normalizedRules(rules) !== normalizedRules(folder()?.permissions || []); const canLeave = () => !dirty() || window.confirm("Ungespeicherte Änderungen verwerfen?"); const levelFor = sid => rules.find(rule => rule.principalId === sid)?.level || 0; content.innerHTML = `
    ${pageHead("Zugriffsverwaltung", "Individuelle Benutzerrechte je Datenordner festlegen.")}
    `; const root = content.querySelector(".access-view"); const detail = root.querySelector("#access-detail"); const message = root.querySelector("#access-message"); function updateDirty() { state.accessDirty = dirty(); const status = detail.querySelector("#access-save-state"); if (status) status.textContent = busy ? "Änderungen werden angewendet…" : dirty() ? "Ungespeicherte Änderungen" : "Alle Änderungen gespeichert"; const save = detail.querySelector("#access-save"); if (save) save.disabled = busy || !dirty() || !folder()?.active; const discard = detail.querySelector("#access-discard"); if (discard) discard.hidden = !dirty(); } async function save(body) { if (busy) return; busy = true; message.hidden = true; root.querySelectorAll("button,input,select").forEach(input => { input.disabled = true; }); updateDirty(); try { data = await api("/api/access", {method: "POST", body: JSON.stringify(body)}); if (body.action === "create-folder") { folderFilter = "active"; query = ""; userQuery = ""; userFilter = "all"; folderId = data.folders.find(item => item.name === body.name.trim())?.id; } if (body.action === "archive-folder") folderFilter = "archived"; if (body.action === "restore-folder") folderFilter = "active"; if (!root.isConnected) return; loadDraft(); draw(); message.className = "access-message success"; message.textContent = "Gespeichert. Die Änderungen sind wirksam."; message.hidden = false; } catch (error) { if (!root.isConnected) return; message.className = "access-message failure"; message.textContent = error.message; message.hidden = false; message.scrollIntoView({block: "nearest"}); } finally { busy = false; if (root.isConnected) { root.querySelectorAll("button,input,select").forEach(input => { input.disabled = false; }); detail.querySelectorAll("[data-unavailable]").forEach(input => { input.disabled = true; }); updateDirty(); } } } function dialog(title, explanation, label, action, initial = null, danger = false) { const modal = document.createElement("dialog"); modal.className = "access-dialog"; modal.setAttribute("aria-label", title); modal.innerHTML = `

    ${esc(title)}

    ${esc(explanation)}

    ${initial !== null ? `` : ""}
    `; document.body.append(modal); modal.querySelector("[data-cancel]").addEventListener("click", () => modal.close()); modal.addEventListener("close", () => modal.remove()); modal.querySelector("form").addEventListener("submit", event => { event.preventDefault(); const input = modal.querySelector("input"), name = input?.value.trim(); if (initial !== null && !name) { input.setCustomValidity("Bitte einen Namen eingeben."); input.reportValidity(); return; } modal.close(); action(name); }); modal.querySelector("input")?.addEventListener("input", event => event.target.setCustomValidity("")); modal.showModal(); modal.querySelector("input")?.focus(); } function drawList() { const items = data.folders.filter(item => folderFilter === "all" || item.active === (folderFilter === "active")) .filter(item => item.name.toLocaleLowerCase("de").includes(query.toLocaleLowerCase("de"))) .sort((a, b) => a.name.localeCompare(b.name, "de")); root.querySelector("#access-list").innerHTML = items.length ? items.map(item => { const count = item.permissions.filter(rule => rule.level > 0).length; const subtitle = item.active ? `${count} Benutzer` : "Archiviert"; return ``; }).join("") : `
    ${query ? "Keine Treffer. Suchbegriff ändern." : "Keine Ordner in dieser Ansicht."}
    `; root.querySelectorAll("[data-select]").forEach(button => button.addEventListener("click", () => { if (button.dataset.select === folderId || !canLeave()) return; folderId = button.dataset.select; userQuery = ""; userFilter = "all"; loadDraft(); drawList(); drawDetail(); })); } function drawUsers() { const users = data.users.filter(item => item.available !== false || (folder()?.permissions || []).some(rule => rule.principalId === item.sid)) .sort((a, b) => a.name.localeCompare(b.name, "de", {numeric: true}) || a.sam.localeCompare(b.sam, "de")); const visible = users.filter(item => `${item.sam} ${item.name}`.toLocaleLowerCase("de").includes(userQuery.toLocaleLowerCase("de"))) .filter(item => userFilter === "all" || (userFilter === "granted" ? levelFor(item.sid) > 0 : levelFor(item.sid) === 0)); detail.querySelector("#access-users").innerHTML = visible.length ? visible.map(item => `
    ${esc(item.name)}${esc(item.sam)}${item.available === false ? " · Nicht mehr im AD" : ""}
    ${permissionLabels.map((label, level) => ``).join("")}
    `).join("") : '
    Keine Benutzer für diesen Filter.
    '; detail.querySelectorAll("[data-user-rule]").forEach(button => button.addEventListener("click", () => { const sid = button.dataset.userRule, level = Number(button.dataset.level); rules = rules.filter(rule => rule.principalId !== sid); if (level) rules.push({kind: "user", principalId: sid, level}); if (userFilter !== "all") drawUsers(); else { button.closest(".access-level-options").querySelectorAll("button").forEach(option => option.setAttribute("aria-pressed", String(Number(option.dataset.level) === level))); updateUserCount(visible.length); } updateDirty(); })); updateUserCount(visible.length); } function updateUserCount(visibleCount) { detail.querySelector("#access-user-count").textContent = `${visibleCount} angezeigt · ${rules.filter(rule => rule.level > 0).length} ${folder()?.active ? "mit Zugriff" : "mit gespeicherten Rechten"} · ${dirty() ? "Vorschau der ungespeicherten Rechte" : "Gespeicherte Rechte"}`; } function drawDetail() { const selected = folder(); if (!selected) { detail.innerHTML = data.folders.length ? '

    Kein Ordner in dieser Ansicht

    Den Ordnerstatus wechseln oder einen neuen Ordner anlegen.

    ' : '

    Ersten Ordner anlegen

    Mit „Neuer Ordner“ beginnen. Anschließend die Rechte für einzelne AD-Benutzer festlegen.

    '; return; } detail.innerHTML = `

    ${esc(selected.name)}

    ${selected.active ? "Zugriff für jeden AD-Benutzer einzeln festlegen. Ohne Zuweisung kein Zugriff." : "Dieser Ordner ist archiviert und für Benutzer nicht erreichbar. Gespeicherte Rechte gelten nach dem Wiederherstellen."}

    Benutzerrechte

    AD-Benutzer${permissionLabels.map((label, level) => `${level}${esc(label)}`).join("")}
    `; detail.querySelector("#access-user-filter").value = userFilter; detail.querySelector("#access-user-search").addEventListener("input", event => { userQuery = event.target.value; drawUsers(); }); detail.querySelector("#access-user-filter").addEventListener("change", event => { userFilter = event.target.value; drawUsers(); }); detail.querySelector("#access-save").addEventListener("click", () => save({action: "set-permissions", id: selected.id, permissions: rules})); detail.querySelector("#access-discard").addEventListener("click", () => { loadDraft(); drawDetail(); }); detail.querySelector("#access-archive").addEventListener("click", () => { if (!canLeave()) return; dialog(selected.active ? "Ordner archivieren?" : "Ordner wiederherstellen?", selected.active ? `„${selected.name}“ wird für Benutzer ausgeblendet. Alle Dateien bleiben erhalten.` : `„${selected.name}“ wird mit den gespeicherten Rechten wieder freigegeben.`, selected.active ? "Archivieren" : "Wiederherstellen", () => save({action: selected.active ? "archive-folder" : "restore-folder", id: selected.id}), null, selected.active); }); drawUsers(); updateDirty(); } function draw() { root.querySelector("#access-search").value = query; root.querySelector("#folder-status").value = folderFilter; drawList(); drawDetail(); } root.querySelector("#access-search").addEventListener("input", event => { query = event.target.value; drawList(); }); root.querySelector("#folder-status").addEventListener("change", event => { if (!canLeave()) { event.target.value = folderFilter; return; } folderFilter = event.target.value; const visible = data.folders.filter(item => folderFilter === "all" || item.active === (folderFilter === "active")); if (!visible.some(item => item.id === folderId)) { folderId = visible[0]?.id; userQuery = ""; userFilter = "all"; } loadDraft(); drawList(); drawDetail(); }); root.querySelector("#access-create").addEventListener("click", () => { if (!canLeave()) return; dialog("Neuen Datenordner anlegen", "Der Ordner startet ohne Benutzerzugriff. Danach die gewünschten AD-Benutzer individuell berechtigen.", "Anlegen", name => save({action: "create-folder", name}), ""); }); loadDraft(); 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 Datenordner 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(stream = "main") { const today = new Date(); const yesterday = new Date(Date.now() - 86400000); const fslogix = stream === "fslogix"; const endpoint = fslogix ? "/api/fslogix-activity" : "/api/activity"; content.innerHTML = pageHead( fslogix ? "FSLogix-Protokoll" : "Aktivitätsprotokoll", fslogix ? "FSLogix-Profilcontainer-Aktivitäten getrennt vom regulären Dateiprotokoll durchsuchen." : "Lese-, Schreib-, Umbenennungs- 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; let loaded = 0; let matched = null; const load = async append => { const params = new URLSearchParams(new FormData(form)); params.set("limit", "100"); if (cursor) params.set("cursor", String(cursor)); if (append) { params.set("count", "0"); params.set("facets", "0"); } const result = await api(`${endpoint}?${params}`); state.activity = result; const rows = document.querySelector("#activity-rows"); if (append) rows.insertAdjacentHTML("beforeend", eventRows(result.events)); else rows.innerHTML = eventRows(result.events); loaded = append ? loaded + result.events.length : result.events.length; if (!append) matched = result.matchedExact ? result.matched : null; const shown = matched === null ? `${loaded.toLocaleString("de-DE")}${result.hasMore ? "+" : ""}` : matched.toLocaleString("de-DE"); const indexNote = result.indexing?.ready === false ? ` · Indexaufbau ${Number(result.indexing.percent || 0).toLocaleString("de-DE")} %` : ""; document.querySelector("#activity-summary").textContent = `${shown} passende Ereignisse${indexNote}`; const more = document.querySelector("#load-more"); cursor = result.nextCursor || 0; more.hidden = !result.hasMore; if (!append) { for (const [id, values] of [["users-list", result.facets.users], ["shares-list", result.facets.shares]]) { document.querySelector(`#${id}`).innerHTML = values.filter(Boolean).map(value => `