"use strict"; const state = { csrf: "", version: "dev", policies: [], profiles: [], clients: [], route: "dashboard", policyFilter: "", clientFilter: "", clientStatus: "all", clientProfile: "all", loading: false, }; const refs = { loginView: document.querySelector("#login-view"), appView: document.querySelector("#app-view"), loginForm: document.querySelector("#login-form"), loginError: document.querySelector("#login-error"), tokenInput: document.querySelector("#admin-token"), toggleToken: document.querySelector("#toggle-token"), logout: document.querySelector("#logout-button"), pageContent: document.querySelector("#page-content"), pageTitle: document.querySelector("#page-title"), pageEyebrow: document.querySelector("#page-eyebrow"), primaryAction: document.querySelector("#primary-action"), refresh: document.querySelector("#refresh-button"), lastRefresh: document.querySelector("#last-refresh"), serverVersion: document.querySelector("#server-version"), navPolicyCount: document.querySelector("#nav-policy-count"), navProfileCount: document.querySelector("#nav-profile-count"), navClientCount: document.querySelector("#nav-client-count"), modalBackdrop: document.querySelector("#modal-backdrop"), modal: document.querySelector("#modal"), modalTitle: document.querySelector("#modal-title"), modalEyebrow: document.querySelector("#modal-eyebrow"), modalContent: document.querySelector("#modal-content"), modalClose: document.querySelector("#modal-close"), confirmBackdrop: document.querySelector("#confirm-backdrop"), confirmTitle: document.querySelector("#confirm-title"), confirmMessage: document.querySelector("#confirm-message"), confirmSubmit: document.querySelector("#confirm-submit"), confirmCancel: document.querySelector("#confirm-cancel"), toastRegion: document.querySelector("#toast-region"), }; const routeMeta = { dashboard: { title: "Übersicht", eyebrow: "Verwaltung", action: "" }, policies: { title: "Richtlinien", eyebrow: "Policy Repository", action: "Richtlinie hochladen" }, profiles: { title: "Profile", eyebrow: "Zuweisungen", action: "Profil erstellen" }, clients: { title: "Clients", eyebrow: "Agent-Status", action: "" }, }; function escapeHTML(value) { return String(value ?? "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function encoded(value) { return encodeURIComponent(String(value)); } function decoded(value) { return decodeURIComponent(String(value)); } function formatDate(value, withSeconds = false) { if (!value) return "—"; const date = new Date(value); if (Number.isNaN(date.getTime())) return "—"; return new Intl.DateTimeFormat("de-DE", { dateStyle: "medium", timeStyle: withSeconds ? "medium" : "short", }).format(date); } function relativeDate(value) { if (!value) return "nie"; const date = new Date(value); const seconds = Math.round((date.getTime() - Date.now()) / 1000); const abs = Math.abs(seconds); const formatter = new Intl.RelativeTimeFormat("de-DE", { numeric: "auto" }); if (abs < 60) return formatter.format(seconds, "second"); if (abs < 3600) return formatter.format(Math.round(seconds / 60), "minute"); if (abs < 86400) return formatter.format(Math.round(seconds / 3600), "hour"); return formatter.format(Math.round(seconds / 86400), "day"); } function formatBytes(bytes) { const value = Number(bytes || 0); if (value < 1024) return `${value} B`; const units = ["KiB", "MiB", "GiB", "TiB"]; let size = value; let unit = -1; do { size /= 1024; unit += 1; } while (size >= 1024 && unit < units.length - 1); return `${size.toLocaleString("de-DE", { maximumFractionDigits: size >= 10 ? 1 : 2 })} ${units[unit]}`; } function shortHash(value, length = 12) { if (!value) return "—"; return `${String(value).slice(0, length)}…`; } function isStale(client) { if (!client.reported_at) return true; return Date.now() - new Date(client.reported_at).getTime() > 24 * 60 * 60 * 1000; } async function request(path, options = {}) { const method = (options.method || "GET").toUpperCase(); const headers = new Headers(options.headers || {}); if (!["GET", "HEAD", "OPTIONS"].includes(method) && state.csrf) { headers.set("X-CSRF-Token", state.csrf); } if (options.json !== undefined) { headers.set("Content-Type", "application/json"); options.body = JSON.stringify(options.json); delete options.json; } const response = await fetch(path, { credentials: "same-origin", ...options, method, headers, }); if (response.status === 401) { showLogin(); throw new Error("Die Sitzung ist abgelaufen. Bitte erneut anmelden."); } if (!response.ok) { let message = `HTTP ${response.status}`; try { const body = await response.json(); message = body.error || message; } catch (_) { // Keep the HTTP status as fallback. } throw new Error(message); } if (response.status === 204) return null; const type = response.headers.get("Content-Type") || ""; return type.includes("application/json") ? response.json() : response; } async function bootstrap() { bindStaticEvents(); try { const session = await request("/ui/api/session"); state.csrf = session.csrf_token; state.version = session.version || "dev"; showApp(); await loadAll(); } catch (_) { showLogin(); } } function bindStaticEvents() { refs.loginForm.addEventListener("submit", login); refs.toggleToken.addEventListener("click", () => { const visible = refs.tokenInput.type === "text"; refs.tokenInput.type = visible ? "password" : "text"; refs.toggleToken.textContent = visible ? "Anzeigen" : "Verbergen"; }); refs.logout.addEventListener("click", logout); refs.refresh.addEventListener("click", () => loadAll(true)); refs.primaryAction.addEventListener("click", () => { if (state.route === "policies") openUploadDialog(); if (state.route === "profiles") openProfileEditor(null); }); document.querySelectorAll(".nav-item").forEach((button) => { button.addEventListener("click", () => navigate(button.dataset.route)); }); refs.pageContent.addEventListener("click", handlePageClick); refs.pageContent.addEventListener("input", handlePageInput); refs.pageContent.addEventListener("change", handlePageInput); refs.modalClose.addEventListener("click", closeModal); refs.modalBackdrop.addEventListener("click", (event) => { if (event.target === refs.modalBackdrop) closeModal(); }); document.addEventListener("keydown", (event) => { if (event.key === "Escape" && !refs.modalBackdrop.classList.contains("hidden")) closeModal(); }); window.addEventListener("hashchange", () => { const route = location.hash.replace(/^#\/?/, ""); if (routeMeta[route]) setRoute(route, false); }); } async function login(event) { event.preventDefault(); refs.loginError.textContent = ""; const submit = refs.loginForm.querySelector("button[type='submit']"); submit.disabled = true; submit.textContent = "Anmeldung läuft …"; try { const session = await request("/ui/api/session", { method: "POST", json: { token: refs.tokenInput.value }, }); state.csrf = session.csrf_token; state.version = session.version || "dev"; refs.tokenInput.value = ""; showApp(); await loadAll(); } catch (error) { refs.loginError.textContent = error.message === "invalid credentials" ? "Das Admin-Token ist ungültig." : error.message; } finally { submit.disabled = false; submit.textContent = "Anmelden"; } } async function logout() { try { await request("/ui/api/session", { method: "DELETE" }); } catch (_) { // A local logout is still useful if the server session is already invalid. } state.csrf = ""; state.policies = []; state.profiles = []; state.clients = []; showLogin(); } function showLogin() { refs.appView.classList.add("hidden"); refs.loginView.classList.remove("hidden"); refs.loginError.textContent = ""; setTimeout(() => refs.tokenInput.focus(), 0); } function showApp() { refs.loginView.classList.add("hidden"); refs.appView.classList.remove("hidden"); refs.serverVersion.textContent = `Backend ${state.version}`; const hashRoute = location.hash.replace(/^#\/?/, ""); setRoute(routeMeta[hashRoute] ? hashRoute : "dashboard", false); } async function loadAll(notify = false) { if (state.loading) return; state.loading = true; refs.refresh.disabled = true; refs.refresh.textContent = "Lädt …"; if (!state.policies.length && !state.profiles.length && !state.clients.length) { refs.pageContent.innerHTML = loadingMarkup("Verwaltungsdaten werden geladen …"); } try { const [policies, profiles, clients] = await Promise.all([ request("/api/v1/admin/policies"), request("/api/v1/admin/profiles"), request("/api/v1/admin/clients"), ]); state.policies = policies || []; state.profiles = profiles || []; state.clients = clients || []; updateCounts(); renderRoute(); refs.lastRefresh.textContent = `Stand ${new Intl.DateTimeFormat("de-DE", { timeStyle: "short" }).format(new Date())}`; if (notify) toast("success", "Aktualisiert", "Die Verwaltungsdaten sind auf dem neuesten Stand."); } catch (error) { refs.pageContent.innerHTML = errorState(error.message); if (notify) toast("error", "Aktualisierung fehlgeschlagen", error.message); } finally { state.loading = false; refs.refresh.disabled = false; refs.refresh.textContent = "Aktualisieren"; } } function updateCounts() { refs.navPolicyCount.textContent = state.policies.length; refs.navProfileCount.textContent = state.profiles.length; refs.navClientCount.textContent = state.clients.length; } function navigate(route) { if (!routeMeta[route]) return; location.hash = route; setRoute(route, false); } function setRoute(route, updateHash = true) { state.route = route; if (updateHash) location.hash = route; document.querySelectorAll(".nav-item").forEach((button) => { button.classList.toggle("active", button.dataset.route === route); }); const meta = routeMeta[route]; refs.pageTitle.textContent = meta.title; refs.pageEyebrow.textContent = meta.eyebrow; refs.primaryAction.textContent = meta.action; refs.primaryAction.classList.toggle("hidden", !meta.action); renderRoute(); refs.pageContent.focus({ preventScroll: true }); } function renderRoute() { if (refs.appView.classList.contains("hidden")) return; if (state.route === "dashboard") renderDashboard(); if (state.route === "policies") renderPolicies(); if (state.route === "profiles") renderProfiles(); if (state.route === "clients") renderClients(); } function renderDashboard() { const versions = state.policies.reduce((sum, policy) => sum + policy.versions.length, 0); const healthy = state.clients.filter((client) => client.success && !isStale(client)).length; const failed = state.clients.filter((client) => !client.success && !isStale(client)).length; const stale = state.clients.filter(isStale).length; const healthPercent = state.clients.length ? Math.round((healthy / state.clients.length) * 100) : 0; const recentVersions = state.policies .flatMap((policy) => policy.versions.map((version) => ({ ...version, policy: policy.name }))) .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)) .slice(0, 7); const recentClients = [...state.clients] .sort((a, b) => new Date(b.reported_at) - new Date(a.reported_at)) .slice(0, 6); refs.pageContent.innerHTML = `
${statCard("Richtlinienobjekte", state.policies.length, `${versions} unveränderliche Versionen`)} ${statCard("Profile", state.profiles.length, "Geordnete Richtliniensätze")} ${statCard("Gemeldete Clients", state.clients.length, `${healthy} aktuell erfolgreich`)} ${statCard("Handlungsbedarf", failed + stale, `${failed} Fehler · ${stale} länger als 24 h still`)}

Letzte Richtlinienversionen

${recentVersions.length ? ` ${recentVersions.map((version) => ` `).join("")}
RichtlinieVersionZeitpunktGröße
${escapeHTML(version.policy)} ${escapeHTML(version.version)} ${escapeHTML(relativeDate(version.created_at))} ${formatBytes(version.size)}
` : emptyInline("Noch keine Richtlinienversion vorhanden.")}

Client-Gesundheit

${healthPercent}%aktuell erfolgreich
Erfolgreich${healthy}
Fehler${failed}
Veraltet${stale}

Zuletzt gemeldete Clients

${recentClients.length ? clientTable(recentClients, false) : emptyInline("Noch kein Agent hat einen Status gemeldet.")}
`; } function statCard(label, value, detail) { return `
${escapeHTML(label)}${escapeHTML(value)}${escapeHTML(detail)}
`; } function renderPolicies() { refs.pageContent.innerHTML = `
${state.policies.length} Objekte
${policyCards()}
`; } function policyCards() { const query = state.policyFilter.trim().toLowerCase(); const policies = state.policies.filter((policy) => policy.name.toLowerCase().includes(query)); if (!policies.length) { return state.policies.length ? emptyState("⌕", "Keine Richtlinie gefunden", "Passe den Suchbegriff an.", "") : emptyState("▤", "Noch keine Richtlinie", "Lade die erste Microsoft-GPO-Sicherung als ZIP hoch.", ``); } return `
${policies.map((policy) => { const versions = [...policy.versions].reverse(); const latest = versions[0]; return `

${escapeHTML(policy.name)}

${policy.versions.length} Version${policy.versions.length === 1 ? "" : "en"}
Aktuell: ${escapeHTML(latest?.version || "—")} Geändert: ${escapeHTML(relativeDate(latest?.created_at))} ${formatBytes(latest?.size || 0)}
${versions.map((version, index) => `
${escapeHTML(version.version)} ${index === 0 ? 'latest' : ""}
${escapeHTML(formatDate(version.created_at))}
${version.note ? escapeHTML(version.note) : 'Keine Notiz'}
Semantik${escapeHTML(shortHash(version.semantic_sha256, 18))}
${version.policy_file_count} Policy-Dateien · ${version.file_count} gesamt · ${formatBytes(version.size)}
`).join("")}
`; }).join("")}
`; } function renderProfiles() { if (!state.profiles.length) { refs.pageContent.innerHTML = emptyState("◫", "Noch kein Profil", "Ein Profil definiert die Reihenfolge und Versionen, die ein Client anwenden soll.", ``); return; } refs.pageContent.innerHTML = `

Richtlinienzuweisungen

Die Reihenfolge bestimmt, in welcher Reihenfolge LGPO die Sicherungen importiert.

${state.profiles.map((profile) => `

${escapeHTML(profile.name)}

Aktualisiert ${escapeHTML(relativeDate(profile.updated_at))}
${profile.policies.length} Richtlinien
${profile.policies.map((ref, index) => `
${index + 1}${escapeHTML(ref.policy)}${escapeHTML(ref.version)}
`).join("")}
`).join("")}
`; } function renderClients() { const profiles = [...new Set(state.clients.map((client) => client.profile).filter(Boolean))].sort(); const visible = filteredClients(); refs.pageContent.innerHTML = `
${visible.length} von ${state.clients.length}
${visible.length ? clientTable(visible, true) : emptyInline("Keine Clients entsprechen dem Filter.")}
`; } function filteredClients() { const query = state.clientFilter.trim().toLowerCase(); return state.clients.filter((client) => { const haystack = [client.client_id, client.hostname, client.profile, client.message, client.operating_system].join(" ").toLowerCase(); if (query && !haystack.includes(query)) return false; if (state.clientProfile !== "all" && client.profile !== state.clientProfile) return false; if (state.clientStatus === "stale" && !isStale(client)) return false; if (state.clientStatus === "success" && (!client.success || isStale(client))) return false; if (state.clientStatus === "error" && (client.success || isStale(client))) return false; return true; }); } function clientTable(clients, includeActions) { return `${includeActions ? "" : ""}${clients.map((client) => { const stale = isStale(client); const status = stale ? 'Veraltet' : client.success ? 'Erfolgreich' : 'Fehler'; return ` ${includeActions ? `` : ""} `; }).join("")}
ClientStatusProfilGenerationLetzte MeldungSystem
${escapeHTML(client.hostname || client.client_id)}
${escapeHTML(client.client_id)}
${client.message ? `
${escapeHTML(client.message)}
` : ""}
${status} ${escapeHTML(client.profile || "—")} ${escapeHTML(shortHash(client.generation, 10))} ${escapeHTML(relativeDate(client.reported_at))}
${escapeHTML(client.operating_system || "—")}
Agent ${escapeHTML(client.agent_version || "—")}
`; } function handlePageInput(event) { if (event.target.id === "policy-search") { state.policyFilter = event.target.value; const list = document.querySelector("#policy-list"); if (list) list.innerHTML = policyCards(); } if (event.target.id === "client-search") state.clientFilter = event.target.value; if (event.target.id === "client-status") state.clientStatus = event.target.value; if (event.target.id === "client-profile") state.clientProfile = event.target.value; if (["client-search", "client-status", "client-profile"].includes(event.target.id)) { const table = document.querySelector("#client-table"); const visible = filteredClients(); if (table) table.innerHTML = visible.length ? clientTable(visible, true) : emptyInline("Keine Clients entsprechen dem Filter."); } } async function handlePageClick(event) { const button = event.target.closest("[data-action]"); if (!button) return; const action = button.dataset.action; if (action === "retry") await loadAll(true); if (action === "go-policies") navigate("policies"); if (action === "go-clients") navigate("clients"); if (action === "upload-policy" || action === "create-profile") { action === "upload-policy" ? openUploadDialog() : openProfileEditor(null); } if (action === "upload-to-policy") openUploadDialog(decoded(button.dataset.policy)); if (action === "edit-profile") openProfileEditor(state.profiles.find((profile) => profile.name === decoded(button.dataset.profile))); if (action === "delete-policy") await deletePolicy(decoded(button.dataset.policy)); if (action === "delete-version") await deleteVersion(decoded(button.dataset.policy), decoded(button.dataset.version)); if (action === "download-version") await downloadVersion(decoded(button.dataset.policy), decoded(button.dataset.version), button); if (action === "delete-profile") await deleteProfile(decoded(button.dataset.profile)); if (action === "delete-client") await deleteClient(decoded(button.dataset.client)); if (action === "copy") await copyText(button.dataset.copy || ""); } function openUploadDialog(fixedPolicy = "") { openModal("Policy Repository", fixedPolicy ? "Neue Richtlinienversion" : "Richtlinie hochladen", `

Buchstaben, Zahlen, Punkt, Unterstrich und Bindestrich.

ZIP mit backup.xml und DomainSysvol/GPO.

`); refs.modalContent.querySelector("[data-modal-close]").addEventListener("click", closeModal); refs.modalContent.querySelector("#upload-form").addEventListener("submit", async (event) => { event.preventDefault(); const form = event.currentTarget; const error = form.querySelector("#upload-error"); const submit = form.querySelector("button[type='submit']"); const progress = form.querySelector("#upload-progress"); error.textContent = ""; submit.disabled = true; submit.textContent = "Upload läuft …"; progress.classList.remove("hidden"); const policy = fixedPolicy || form.elements.policy.value.trim(); const data = new FormData(); data.append("bundle", form.elements.bundle.files[0]); data.append("note", form.elements.note.value); data.append("force", form.elements.force.checked ? "true" : "false"); try { const result = await request(`/api/v1/admin/policies/${encodeURIComponent(policy)}/versions`, { method: "POST", body: data }); closeModal(); await loadAll(); navigate("policies"); toast("success", result.created ? "Version angelegt" : "Keine Änderung erkannt", result.created ? `${policy} · ${result.version.version}` : `Der semantische Inhalt entspricht bereits ${result.version.version}.`); } catch (uploadError) { error.textContent = uploadError.message; } finally { submit.disabled = false; submit.textContent = "Prüfen und hochladen"; progress.classList.add("hidden"); } }); } function openProfileEditor(profile) { if (!state.policies.length) { toast("error", "Keine Richtlinien vorhanden", "Lade zuerst mindestens eine Richtlinie hoch."); return; } const editing = Boolean(profile); let rows = profile ? profile.policies.map((item) => ({ ...item })) : [{ policy: state.policies[0].name, version: "latest" }]; openModal("Zuweisungen", editing ? "Profil bearbeiten" : "Profil erstellen", `

Reihenfolge

Später importierte Richtlinien können frühere Einstellungen überschreiben.

`, true); const rowsElement = refs.modalContent.querySelector("#profile-rows"); const renderRows = () => { rowsElement.innerHTML = rows.map((row, index) => { const policy = state.policies.find((item) => item.name === row.policy) || state.policies[0]; if (!policy) return ""; const versions = [...policy.versions].reverse(); const validVersion = row.version === "latest" || versions.some((version) => version.version === row.version) ? row.version : "latest"; row.policy = policy.name; row.version = validVersion; return `
${index + 1}
`; }).join(""); }; renderRows(); refs.modalContent.querySelector("[data-modal-close]").addEventListener("click", closeModal); refs.modalContent.querySelector("#add-profile-row").addEventListener("click", () => { const used = new Set(rows.map((row) => row.policy)); const next = state.policies.find((policy) => !used.has(policy.name)); if (!next) { toast("error", "Keine weitere Richtlinie", "Jede Richtlinie darf pro Profil nur einmal vorkommen."); return; } rows.push({ policy: next.name, version: "latest" }); renderRows(); }); rowsElement.addEventListener("change", (event) => { const rowElement = event.target.closest("[data-row]"); if (!rowElement) return; const index = Number(rowElement.dataset.row); if (event.target.dataset.field === "policy") { rows[index] = { policy: event.target.value, version: "latest" }; renderRows(); } if (event.target.dataset.field === "version") rows[index].version = event.target.value; }); rowsElement.addEventListener("click", (event) => { const button = event.target.closest("[data-row-action]"); if (!button) return; const index = Number(button.closest("[data-row]").dataset.row); if (button.dataset.rowAction === "up" && index > 0) [rows[index - 1], rows[index]] = [rows[index], rows[index - 1]]; if (button.dataset.rowAction === "down" && index < rows.length - 1) [rows[index + 1], rows[index]] = [rows[index], rows[index + 1]]; if (button.dataset.rowAction === "remove" && rows.length > 1) rows.splice(index, 1); renderRows(); }); refs.modalContent.querySelector("#profile-form").addEventListener("submit", async (event) => { event.preventDefault(); const form = event.currentTarget; const error = form.querySelector("#profile-error"); const submit = form.querySelector("button[type='submit']"); const name = profile?.name || form.elements.name.value.trim(); const unique = new Set(rows.map((row) => row.policy)); if (unique.size !== rows.length) { error.textContent = "Eine Richtlinie darf im Profil nur einmal vorkommen."; return; } error.textContent = ""; submit.disabled = true; submit.textContent = "Speichert …"; try { await request(`/api/v1/admin/profiles/${encodeURIComponent(name)}`, { method: "PUT", json: { policies: rows } }); closeModal(); await loadAll(); navigate("profiles"); toast("success", editing ? "Profil aktualisiert" : "Profil erstellt", `${name} enthält ${rows.length} Richtlinien.`); } catch (saveError) { error.textContent = saveError.message; } finally { submit.disabled = false; submit.textContent = "Profil speichern"; } }); } async function deletePolicy(name) { const ok = await confirmAction("Richtlinie löschen", `Alle Versionen und ZIP-Artefakte von „${name}“ werden dauerhaft gelöscht. Profile, die diese Richtlinie verwenden, müssen vorher angepasst werden.`, "Richtlinie löschen"); if (!ok) return; try { await request(`/api/v1/admin/policies/${encodeURIComponent(name)}`, { method: "DELETE" }); await loadAll(); toast("success", "Richtlinie gelöscht", name); } catch (error) { toast("error", "Löschen nicht möglich", error.message); } } async function deleteVersion(policy, version) { const ok = await confirmAction("Version löschen", `Die unveränderliche Version „${version}“ von „${policy}“ wird einschließlich ZIP-Artefakt gelöscht. Fest angeheftete Profilversionen müssen vorher geändert werden.`, "Version löschen"); if (!ok) return; try { await request(`/api/v1/admin/policies/${encodeURIComponent(policy)}/versions/${encodeURIComponent(version)}`, { method: "DELETE" }); await loadAll(); toast("success", "Version gelöscht", version); } catch (error) { toast("error", "Löschen nicht möglich", error.message); } } async function deleteProfile(name) { const ok = await confirmAction("Profil löschen", `Das Profil „${name}“ wird dauerhaft entfernt. Bereits konfigurierte Agents erhalten danach für dieses Profil HTTP 404.`, "Profil löschen"); if (!ok) return; try { await request(`/api/v1/admin/profiles/${encodeURIComponent(name)}`, { method: "DELETE" }); await loadAll(); toast("success", "Profil gelöscht", name); } catch (error) { toast("error", "Löschen nicht möglich", error.message); } } async function deleteClient(clientID) { const ok = await confirmAction("Client-Eintrag entfernen", `Der zuletzt gespeicherte Status von „${clientID}“ wird entfernt. Der Agent erscheint bei seiner nächsten Meldung automatisch wieder.`, "Eintrag entfernen"); if (!ok) return; try { await request(`/api/v1/admin/clients/${encodeURIComponent(clientID)}`, { method: "DELETE" }); await loadAll(); toast("success", "Client-Eintrag entfernt", clientID); } catch (error) { toast("error", "Entfernen nicht möglich", error.message); } } async function downloadVersion(policy, version, button) { const old = button.textContent; button.disabled = true; button.textContent = "Lädt …"; try { const response = await request(`/api/v1/admin/policies/${encodeURIComponent(policy)}/versions/${encodeURIComponent(version)}/artifact`); const blob = await response.blob(); const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; anchor.download = `${policy}-${version}.zip`; document.body.appendChild(anchor); anchor.click(); anchor.remove(); URL.revokeObjectURL(url); } catch (error) { toast("error", "ZIP konnte nicht geladen werden", error.message); } finally { button.disabled = false; button.textContent = old; } } async function copyText(text) { try { await navigator.clipboard.writeText(text); toast("success", "Kopiert", "Der Hash wurde in die Zwischenablage kopiert."); } catch (_) { toast("error", "Kopieren fehlgeschlagen", "Die Zwischenablage ist für diese Seite nicht verfügbar."); } } function openModal(eyebrow, title, content, wide = false) { refs.modalEyebrow.textContent = eyebrow; refs.modalTitle.textContent = title; refs.modalContent.innerHTML = content; refs.modal.classList.toggle("wide-modal", wide); refs.modalBackdrop.classList.remove("hidden"); document.body.style.overflow = "hidden"; setTimeout(() => refs.modalContent.querySelector("input, select, textarea, button")?.focus(), 0); } function closeModal() { refs.modalBackdrop.classList.add("hidden"); refs.modal.classList.remove("wide-modal"); refs.modalContent.innerHTML = ""; document.body.style.overflow = ""; } function confirmAction(title, message, submitLabel) { refs.confirmTitle.textContent = title; refs.confirmMessage.textContent = message; refs.confirmSubmit.textContent = submitLabel; refs.confirmBackdrop.classList.remove("hidden"); return new Promise((resolve) => { const finish = (value) => { refs.confirmBackdrop.classList.add("hidden"); refs.confirmSubmit.removeEventListener("click", accept); refs.confirmCancel.removeEventListener("click", cancel); refs.confirmBackdrop.removeEventListener("click", backdrop); resolve(value); }; const accept = () => finish(true); const cancel = () => finish(false); const backdrop = (event) => { if (event.target === refs.confirmBackdrop) finish(false); }; refs.confirmSubmit.addEventListener("click", accept); refs.confirmCancel.addEventListener("click", cancel); refs.confirmBackdrop.addEventListener("click", backdrop); refs.confirmCancel.focus(); }); } function toast(type, title, message) { const node = document.createElement("div"); node.className = `toast ${type}`; node.innerHTML = `
${escapeHTML(title)}

${escapeHTML(message)}

`; node.querySelector("button").addEventListener("click", () => node.remove()); refs.toastRegion.appendChild(node); setTimeout(() => node.remove(), 6000); } function loadingMarkup(message) { return `
${escapeHTML(message)}
`; } function errorState(message) { return emptyState("!", "Daten konnten nicht geladen werden", message, ''); } function emptyState(icon, title, message, action) { return `
${escapeHTML(icon)}

${escapeHTML(title)}

${escapeHTML(message)}

${action}
`; } function emptyInline(message) { return `

${escapeHTML(message)}

`; } bootstrap();