const AUTHORITY = 'https://auth.mischlabs.de/realms/mischlabs'; const CLIENT_ID = 'mischlabs-admin'; const REDIRECT_URI = `${window.location.origin}/admin.html`; const SCOPES = 'openid profile email'; const CONFIG_URL = '/services.json?v=1'; const LOCAL_CONFIG_KEY = 'mischlabs.dashboard.config'; const TOKEN_KEY = 'mischlabs.admin.tokens'; const DASHBOARD_TOKEN_KEY = 'mischlabs.dashboard.tokens'; const PKCE_KEY = 'mischlabs.admin.pkce'; const loginButton = document.querySelector('#loginButton'); const logoutButton = document.querySelector('#logoutButton'); const headerUserBadge = document.querySelector('#headerUserBadge'); const loginPanel = document.querySelector('#loginPanel'); const adminPanel = document.querySelector('#adminPanel'); const loginMessage = document.querySelector('#loginMessage'); const adminUser = document.querySelector('#adminUser'); const serviceEditor = document.querySelector('#serviceEditor'); const checkAllButton = document.querySelector('#checkAllButton'); const saveLocalButton = document.querySelector('#saveLocalButton'); const resetLocalButton = document.querySelector('#resetLocalButton'); const downloadConfigButton = document.querySelector('#downloadConfigButton'); const refreshOpsButton = document.querySelector('#refreshOpsButton'); const runWatchtowerButton = document.querySelector('#runWatchtowerButton'); const dangerModeButton = document.querySelector('#dangerModeButton'); const dangerState = document.querySelector('#dangerState'); const diskStatus = document.querySelector('#diskStatus'); const watchtowerStatus = document.querySelector('#watchtowerStatus'); const containerStatus = document.querySelector('#containerStatus'); const servicesStatusBoard = document.querySelector('#servicesStatusBoard'); const refreshServicesButton = document.querySelector('#refreshServicesButton'); let config = null; let tokenSet = null; let opsRefreshTimer = null; let latestWatchtower = null; let lastManualWatchtowerRun = null; let latestContainers = []; let dangerToken = null; let dangerExpiresAt = 0; let dangerTimer = null; let opsAutoRefreshTimer = null; function base64UrlEncode(buffer) { return btoa(String.fromCharCode(...new Uint8Array(buffer))) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/g, ''); } function randomString(length = 64) { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; const bytes = new Uint8Array(length); crypto.getRandomValues(bytes); return [...bytes].map((byte) => chars[byte % chars.length]).join(''); } async function createChallenge(verifier) { const data = new TextEncoder().encode(verifier); const digest = await crypto.subtle.digest('SHA-256', data); return base64UrlEncode(digest); } function decodeJwt(token) { const payload = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'); const json = decodeURIComponent(atob(payload).split('').map((char) => ( `%${(`00${char.charCodeAt(0).toString(16)}`).slice(-2)}` )).join('')); return JSON.parse(json); } function getStoredTokens() { try { const raw = sessionStorage.getItem(TOKEN_KEY) || sessionStorage.getItem(DASHBOARD_TOKEN_KEY); return raw ? JSON.parse(raw) : null; } catch { return null; } } function storeTokens(tokens) { sessionStorage.setItem(TOKEN_KEY, JSON.stringify(tokens)); sessionStorage.setItem(DASHBOARD_TOKEN_KEY, JSON.stringify(tokens)); } function clearTokens() { sessionStorage.removeItem(TOKEN_KEY); sessionStorage.removeItem(DASHBOARD_TOKEN_KEY); sessionStorage.removeItem(PKCE_KEY); } function isTokenValid(tokens) { if (!tokens?.id_token) return false; try { const claims = decodeJwt(tokens.id_token); return Date.now() < claims.exp * 1000; } catch { return false; } } async function login() { const verifier = randomString(); const challenge = await createChallenge(verifier); const state = randomString(32); sessionStorage.setItem(PKCE_KEY, JSON.stringify({ verifier, state })); const params = new URLSearchParams({ client_id: CLIENT_ID, redirect_uri: REDIRECT_URI, response_type: 'code', scope: SCOPES, state, code_challenge: challenge, code_challenge_method: 'S256' }); window.location.href = `${AUTHORITY}/protocol/openid-connect/auth?${params}`; } async function handleCallback() { const url = new URL(window.location.href); const code = url.searchParams.get('code'); const state = url.searchParams.get('state'); const error = url.searchParams.get('error'); if (error) { loginMessage.textContent = `Keycloak Fehler: ${error}`; window.history.replaceState({}, document.title, REDIRECT_URI); return; } if (!code) return; const stored = JSON.parse(sessionStorage.getItem(PKCE_KEY) || '{}'); if (!stored.verifier || stored.state !== state) { loginMessage.textContent = 'SSO Antwort konnte nicht verifiziert werden.'; return; } const body = new URLSearchParams({ grant_type: 'authorization_code', client_id: CLIENT_ID, redirect_uri: REDIRECT_URI, code, code_verifier: stored.verifier }); try { const response = await fetch(`${AUTHORITY}/protocol/openid-connect/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body }); if (!response.ok) { throw new Error(`Token endpoint returned ${response.status}`); } const tokens = await response.json(); storeTokens(tokens); window.history.replaceState({}, document.title, REDIRECT_URI); } catch (error) { loginMessage.textContent = `Token-Austausch fehlgeschlagen: ${error.message}. Keycloak Client/Web Origins pruefen.`; } } function authorize(tokens) { const claims = decodeJwt(tokens.id_token); const username = String(claims.preferred_username || '').toLowerCase(); const email = String(claims.email || '').toLowerCase(); const allowed = username === 'mrdiderot' || email === 'mail.misch@pm.me'; if (!allowed) { loginMessage.textContent = `Angemeldet als ${claims.preferred_username || claims.email}, aber nicht fuer diese Adminseite freigeschaltet.`; return null; } return claims; } async function loadConfig() { const local = localStorage.getItem(LOCAL_CONFIG_KEY); if (local) return JSON.parse(local); const response = await fetch(CONFIG_URL, { cache: 'no-store' }); return response.json(); } function categoryOptions(selected) { return config.categories.map((category) => ( `` )).join(''); } function renderEditor() { serviceEditor.innerHTML = config.services.map((service, index) => `
${service.name} ${service.domain}
Noch nicht geprueft.
`).join(''); serviceEditor.querySelectorAll('select,input').forEach((input) => { input.addEventListener('change', () => { const row = input.closest('.admin-row'); const service = config.services[Number(row.dataset.index)]; service[input.dataset.field] = input.value; }); }); serviceEditor.querySelectorAll('.check-one').forEach((button) => { button.addEventListener('click', () => checkRow(button.closest('.admin-row'))); }); } async function probe(url) { const started = performance.now(); const controller = new AbortController(); const timeout = window.setTimeout(() => controller.abort(), 6000); try { const response = await fetch(url, { mode: 'no-cors', cache: 'no-store', signal: controller.signal }); return { status: 'online', ms: Math.round(performance.now() - started), detail: response.type === 'opaque' ? 'Erreichbar. HTTP-Details wegen Browser-CORS nicht lesbar.' : `HTTP ${response.status}` }; } catch (error) { const isTimeout = error.name === 'AbortError'; return { status: isTimeout ? 'offline' : 'unknown', ms: Math.round(performance.now() - started), detail: isTimeout ? 'Timeout nach 6 Sekunden.' : 'Der Browser blockiert diese Pruefung wahrscheinlich wegen CORS/CORB. Der Dienst kann trotzdem online sein.' }; } finally { window.clearTimeout(timeout); } } async function checkRow(row) { const service = config.services[Number(row.dataset.index)]; const diagnostic = row.querySelector('.diagnostic'); diagnostic.textContent = 'Pruefe...'; row.classList.remove('is-online', 'is-offline', 'is-unknown'); const result = await probe(service.url); row.classList.toggle('is-online', result.status === 'online'); row.classList.toggle('is-offline', result.status === 'offline'); row.classList.toggle('is-unknown', result.status === 'unknown'); const label = { online: 'Online', offline: 'Fehler', unknown: 'Nicht im Browser pruefbar' }[result.status]; diagnostic.textContent = `${label} - ${result.ms} ms - ${result.detail}`; } function saveLocal() { localStorage.setItem(LOCAL_CONFIG_KEY, JSON.stringify(config, null, 2)); } function downloadConfig() { const blob = new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = 'services.json'; link.click(); URL.revokeObjectURL(url); } function formatBytes(bytes) { if (!Number.isFinite(bytes)) return '--'; const units = ['B', 'KB', 'MB', 'GB', 'TB']; let value = bytes; let unit = 0; while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit += 1; } return `${value.toFixed(value >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`; } function renderServicesStatus(services = []) { if (!servicesStatusBoard) return; if (!Array.isArray(services) || services.length === 0) { servicesStatusBoard.innerHTML = '
Keine Dienst-Statusdaten vom Server erhalten.
'; return; } // Create a map of status by URL for easy lookup const statusMap = new Map(); services.forEach((s) => { if (s && s.url) { statusMap.set(s.url, s); } }); // We want to render: // 1. The main Landing Page (mischlabs.de) // 2. All services defined in the config (from config.services) const itemsToRender = []; // Main landing page check const mainSiteUrl = 'https://mischlabs.de'; const mainSiteStatus = statusMap.get(mainSiteUrl); if (mainSiteStatus) { itemsToRender.push({ name: 'MischLabs Landing Page', domain: 'mischlabs.de', url: mainSiteUrl, status: mainSiteStatus }); } // Services from editor config if (config && Array.isArray(config.services)) { config.services.forEach((service) => { const serviceStatus = statusMap.get(service.url); if (serviceStatus) { itemsToRender.push({ name: service.name, domain: service.domain, url: service.url, status: serviceStatus }); } else { // Fallback if URL didn't match exactly but we can find a substring/partial match, // or just show it as unknown const fallbackStatus = services.find(s => s && s.url && s.url.includes(service.domain)); itemsToRender.push({ name: service.name, domain: service.domain, url: service.url, status: fallbackStatus || { url: service.url, ok: false, error: 'Nicht geprueft' } }); } }); } const offlineCount = itemsToRender.filter(item => !item.status.ok).length; const totalCount = itemsToRender.length; const summaryEl = document.querySelector('#servicesStatusSummary'); if (summaryEl) { if (offlineCount > 0) { summaryEl.innerHTML = `${offlineCount} offline / ${totalCount} gesamt`; } else { summaryEl.innerHTML = `Alle online (${totalCount})`; } } servicesStatusBoard.innerHTML = itemsToRender.map((item) => { const s = item.status; let statusClass = 'is-online'; let statusLabel = `HTTP ${s.statusCode || '200'}`; let latencyText = `${s.ms ?? '--'} ms`; if (s.error || s.ok === false) { statusClass = 'is-offline'; statusLabel = s.statusCode ? `HTTP ${s.statusCode}` : 'Offline'; latencyText = s.error || 'Verbindung fehlgeschlagen'; } else if (s.ms > 1000) { statusClass = 'is-warning'; statusLabel = 'Traege'; } const tooltip = `${item.name}\nDomain: ${item.domain}\nStatus: ${statusLabel}\nLatenz: ${latencyText}`; return `

${item.name}

🔍
`; }).join(''); servicesStatusBoard.querySelectorAll('.service-inspect-trigger').forEach((trigger) => { trigger.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); const url = trigger.dataset.url; const name = trigger.dataset.name; openServiceInspectModal(url, name); }); }); const miniServices = document.getElementById('mini-services-status'); if (miniServices) { let ledHtml = itemsToRender.map(item => { const ok = item.status.ok; return ``; }).join(''); miniServices.innerHTML = `
${offlineCount > 0 ? `${offlineCount} offline` : `Alle online`} (${totalCount} Dienste)
${ledHtml}
`; } } function renderDisks(disks = []) { diskStatus.innerHTML = disks.map((disk) => { if (disk.error) { const hint = disk.mount ? `Mount fehlt: ${disk.mount}` : disk.error; return `
${disk.label}${hint}
`; } const level = disk.usedPercent >= 90 ? 'danger' : disk.usedPercent >= 80 ? 'warn' : 'ok'; return `
${disk.label} ${formatBytes(disk.used)} / ${formatBytes(disk.total)} (${disk.usedPercent}%)
`; }).join(''); const miniDisks = document.getElementById('mini-disks-status'); if (miniDisks) { miniDisks.innerHTML = disks.map((disk) => { if (disk.error) return `
${disk.label}: Fehler
`; return `
${disk.label}${disk.usedPercent}%
`; }).join(''); } } function renderWatchtower(watchtower) { latestWatchtower = watchtower; const officialSession = watchtower?.lastSession; const statusGenerated = watchtower?.generatedAt ? new Date(watchtower.generatedAt).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '--'; const officialHtml = officialSession ? `
Offizieller Lauf${officialSession.finishedAt ? new Date(officialSession.finishedAt).toLocaleString('de-DE') : '--'}
Geprueft${officialSession.scanned ?? '--'}
Aktualisiert${officialSession.updated ?? '--'}
Fehler${officialSession.failed ?? '--'}
Status aktualisiert${statusGenerated}
` : `
Offizieller Lauf${watchtower?.tail?.slice(-2)?.join(' | ') || 'Noch nicht gefunden'}
Status aktualisiert${statusGenerated}
`; const miniWatchtower = document.getElementById('mini-watchtower-status'); if (miniWatchtower) { let statusText = 'Keine Daten'; let subText = ''; if (lastManualWatchtowerRun) { statusText = lastManualWatchtowerRun.running ? 'Läuft manuell...' : 'Manuell beendet'; subText = lastManualWatchtowerRun.startedAt ? new Date(lastManualWatchtowerRun.startedAt).toLocaleTimeString('de-DE') : ''; } else if (officialSession) { statusText = 'Up-to-date'; subText = `Lauf: ${officialSession.finishedAt ? new Date(officialSession.finishedAt).toLocaleDateString('de-DE') : '--'}`; } miniWatchtower.innerHTML = `
${statusText}
${subText}
`; } if (lastManualWatchtowerRun) { const run = lastManualWatchtowerRun; const session = run.session; const finished = run.finishedAt ? new Date(run.finishedAt).toLocaleString('de-DE') : 'laeuft noch'; const state = run.running ? 'Laeuft' : `Beendet (${run.exitCode ?? '--'})`; watchtowerStatus.innerHTML = ` ${officialHtml}
Manuell${state}
Gestartet${run.startedAt ? new Date(run.startedAt).toLocaleString('de-DE') : '--'}
Fertig${finished}
Manuell geprueft${session?.scanned ?? '--'}
Manuell aktualisiert${session?.updated ?? '--'}
Manuelle Fehler${session?.failed ?? (run.exitCode ? 1 : 0)}
`; return; } watchtowerStatus.innerHTML = officialHtml; } function renderContainers(containers) { if (!Array.isArray(containers)) { containerStatus.textContent = containers?.error || 'Containerdaten nicht verfuegbar.'; return; } latestContainers = containers; const dangerActive = Date.now() < dangerExpiresAt && Boolean(dangerToken); const important = [ 'mischlabs', 'watchtower', 'gitea', 'gitea-runner', 'auth', 'nextcloud_app', 'vaultwarden', 'photography-website', 'michess', 'calibre-web', 'audiobookshelf' ]; const rows = containers .filter((container) => important.includes(container.name)) .sort((a, b) => important.indexOf(a.name) - important.indexOf(b.name)); containerStatus.innerHTML = rows.map((container) => `
${container.name} ${container.status}
`).join(''); containerStatus.querySelectorAll('.view-container-logs').forEach((button) => { button.addEventListener('click', () => { openLogsModal(button.closest('.container-line').dataset.container); }); }); containerStatus.querySelectorAll('.restart-container').forEach((button) => { button.addEventListener('click', () => restartContainer(button.closest('.container-line'))); }); const miniContainers = document.getElementById('mini-containers-status'); if (miniContainers && Array.isArray(containers)) { const importantList = [ 'mischlabs', 'watchtower', 'gitea', 'gitea-runner', 'auth', 'nextcloud_app', 'vaultwarden', 'photography-website', 'michess', 'calibre-web', 'audiobookshelf' ]; const activeImportant = containers.filter(c => importantList.includes(c.name) && c.state === 'running').length; const totalImportant = containers.filter(c => importantList.includes(c.name)).length; miniContainers.innerHTML = `
${activeImportant} / ${totalImportant} online
${containers.length} Container insgesamt
`; } } async function restartContainer(row) { const name = row?.dataset.container; if (!name) return; const dangerActive = Date.now() < dangerExpiresAt && Boolean(dangerToken); const confirmed = window.confirm(`${name} jetzt neu starten? Der Dienst ist kurz nicht erreichbar.${dangerActive ? '\n\nDanger-Modus ist aktiv.' : ''}`); if (!confirmed) return; const button = row.querySelector('.restart-container'); const status = row.querySelector('span'); const previousText = status.textContent; button.disabled = true; status.textContent = 'Neustart laeuft...'; try { const response = await fetch(`/api/containers/${encodeURIComponent(name)}/restart`, { method: 'POST', headers: { Authorization: `Bearer ${tokenSet.id_token}`, ...(dangerActive ? { 'X-MischLabs-Danger-Token': dangerToken } : {}) } }); const payload = await response.json().catch(() => ({})); if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`); status.textContent = `Neugestartet um ${new Date(payload.restartedAt).toLocaleTimeString('de-DE')}`; window.clearTimeout(opsRefreshTimer); opsRefreshTimer = window.setTimeout(refreshOps, 3500); } catch (error) { status.textContent = `Neustart fehlgeschlagen: ${error.message}`; button.disabled = false; window.setTimeout(() => { status.textContent = previousText; }, 6000); } } function updateDangerState() { const remaining = Math.max(0, dangerExpiresAt - Date.now()); if (!dangerToken || remaining <= 0) { dangerToken = null; dangerExpiresAt = 0; dangerState.hidden = true; dangerState.textContent = ''; dangerModeButton.textContent = 'Danger entsperren'; dangerModeButton.classList.remove('is-active-danger'); window.clearInterval(dangerTimer); dangerTimer = null; if (latestContainers.length) renderContainers(latestContainers); return; } const seconds = Math.ceil(remaining / 1000); dangerState.hidden = false; dangerState.textContent = `Danger-Modus aktiv: geschuetzte Neustarts noch ${seconds}s erlaubt.`; dangerModeButton.textContent = `Danger aktiv ${seconds}s`; dangerModeButton.classList.add('is-active-danger'); } async function unlockDangerMode() { const confirmed = window.confirm('Danger-Modus fuer 2 Minuten aktivieren?\n\nDamit koennen auch geschuetzte Dienste wie Watchtower, Gitea, Keycloak oder Datenbanken neu gestartet werden. Nutze das nur bewusst.'); if (!confirmed) return; dangerModeButton.disabled = true; try { const response = await fetch('/api/danger/unlock', { method: 'POST', headers: { Authorization: `Bearer ${tokenSet.id_token}` } }); const payload = await response.json().catch(() => ({})); if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`); dangerToken = payload.token; dangerExpiresAt = new Date(payload.expiresAt).getTime(); updateDangerState(); if (latestContainers.length) renderContainers(latestContainers); window.clearInterval(dangerTimer); dangerTimer = window.setInterval(updateDangerState, 1000); } catch (error) { dangerState.hidden = false; dangerState.textContent = `Danger-Modus konnte nicht aktiviert werden: ${error.message}`; } finally { dangerModeButton.disabled = false; } } async function runWatchtowerOnce() { const confirmed = window.confirm('Watchtower jetzt manuell starten? Je nach Updates koennen Dienste kurz neu starten.'); if (!confirmed) return; const previousText = runWatchtowerButton.textContent; runWatchtowerButton.disabled = true; runWatchtowerButton.textContent = 'Laeuft...'; watchtowerStatus.innerHTML = '
Manueller LaufGestartet...
'; try { const response = await fetch('/api/watchtower/run-once', { method: 'POST', headers: { Authorization: `Bearer ${tokenSet.id_token}` } }); const payload = await response.json().catch(() => ({})); if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`); lastManualWatchtowerRun = { id: payload.fullId || payload.id, name: payload.name, running: true, startedAt: payload.startedAt, session: null, tail: [] }; watchtowerStatus.innerHTML = `
Manueller LaufGestartet
Container${payload.name}
`; window.clearTimeout(opsRefreshTimer); pollWatchtowerRun(payload.fullId || payload.id); } catch (error) { watchtowerStatus.textContent = `Watchtower konnte nicht gestartet werden: ${error.message}`; } finally { window.setTimeout(() => { runWatchtowerButton.disabled = false; runWatchtowerButton.textContent = previousText; }, 5000); } } async function pollWatchtowerRun(runId, attempt = 0) { try { const response = await fetch(`/api/watchtower/runs/${encodeURIComponent(runId)}`, { cache: 'no-store', headers: { Authorization: `Bearer ${tokenSet.id_token}` } }); const payload = await response.json().catch(() => ({})); if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`); lastManualWatchtowerRun = payload; renderWatchtower(latestWatchtower); if (payload.running && attempt < 60) { opsRefreshTimer = window.setTimeout(() => pollWatchtowerRun(runId, attempt + 1), 5000); return; } opsRefreshTimer = window.setTimeout(refreshOps, 4000); } catch (error) { watchtowerStatus.textContent = `Manueller Watchtower-Lauf nicht lesbar: ${error.message}`; } } async function refreshOps(options = {}) { const silent = Boolean(options.silent); if (!silent) { diskStatus.textContent = 'Lade...'; watchtowerStatus.textContent = 'Lade...'; containerStatus.textContent = 'Lade...'; if (servicesStatusBoard) { servicesStatusBoard.textContent = 'Lade...'; } } if (lastManualWatchtowerRun && lastManualWatchtowerRun.running) { try { const runId = lastManualWatchtowerRun.id; const response = await fetch(`/api/watchtower/runs/${encodeURIComponent(runId)}`, { cache: 'no-store', headers: { Authorization: `Bearer ${tokenSet?.id_token}` } }); if (response.ok) { const payload = await response.json().catch(() => ({})); lastManualWatchtowerRun = payload; } } catch (e) { console.warn('Failed to refresh manual watchtower run status:', e); } } try { const response = await fetch('/api/status', { cache: 'no-store' }); if (!response.ok) throw new Error(`HTTP ${response.status}`); const status = await response.json(); renderDisks(status.disks); renderWatchtower(status.watchtower); renderContainers(status.containers); renderServicesStatus(status.services); } catch (error) { diskStatus.textContent = `Statusproxy nicht erreichbar: ${error.message}`; watchtowerStatus.textContent = 'Nicht verfuegbar.'; containerStatus.textContent = 'Nicht verfuegbar.'; if (servicesStatusBoard) { servicesStatusBoard.textContent = `Nicht verfuegbar: ${error.message}`; } } } function startOpsAutoRefresh() { window.clearInterval(opsAutoRefreshTimer); opsAutoRefreshTimer = window.setInterval(() => { if (document.visibilityState === 'visible') { refreshOps({ silent: true }); } }, 60000); } let currentLogsContainer = ''; async function fetchContainerLogs(containerName, tail = 150) { const consoleEl = document.getElementById('logsModalConsole'); if (!consoleEl) return; consoleEl.textContent = 'Lade Logs...'; try { const response = await fetch(`/api/containers/${encodeURIComponent(containerName)}/logs?tail=${tail}`, { headers: { Authorization: `Bearer ${tokenSet?.id_token}` } }); const data = await response.json(); if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`); if (typeof data.logs === 'string') { consoleEl.textContent = data.logs || 'Keine Logs vorhanden.'; } else { consoleEl.textContent = JSON.stringify(data.logs, null, 2); } consoleEl.scrollTop = consoleEl.scrollHeight; } catch (error) { consoleEl.textContent = `Fehler beim Laden der Logs: ${error.message}`; } } function openLogsModal(containerName) { currentLogsContainer = containerName; const overlay = document.getElementById('logsModalOverlay'); const title = document.getElementById('logsModalTitle'); const tailSelect = document.getElementById('logsTailSelect'); if (overlay && title) { title.textContent = `Logs von "${containerName}"`; if (tailSelect) { tailSelect.value = "150"; } overlay.removeAttribute('hidden'); overlay.classList.add('is-active'); fetchContainerLogs(containerName, 150); } } function closeLogsModal() { const overlay = document.getElementById('logsModalOverlay'); if (overlay) { overlay.setAttribute('hidden', ''); overlay.classList.remove('is-active'); currentLogsContainer = ''; } } async function openServiceInspectModal(url, name) { const overlay = document.getElementById('serviceModalOverlay'); const title = document.getElementById('serviceModalTitle'); const content = document.getElementById('serviceModalContent'); if (!overlay || !content) return; title.textContent = `Inspektion: ${name}`; content.innerHTML = '
Führe Live-Diagnose aus...
'; overlay.removeAttribute('hidden'); overlay.classList.add('is-active'); try { const response = await fetch(`/api/services/inspect?url=${encodeURIComponent(url)}`, { headers: { Authorization: `Bearer ${tokenSet?.id_token}` } }); const data = await response.json(); if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`); const statusClass = data.ok ? 'text-ok' : 'text-down'; let headersHtml = ''; if (data.headers) { headersHtml = Object.entries(data.headers).map(([key, val]) => `
${key}: ${val}
`).join(''); } let previewHtml = ''; if (data.bodyPreview) { const escaped = data.bodyPreview .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); previewHtml = `

Antwort-Vorschau (erste 1000 Zeichen):

${escaped}
`; } content.innerHTML = `
Status: ${data.statusCode ? `HTTP ${data.statusCode} ${data.statusMessage || ''}` : 'Fehler'}
Latenz: ${data.latency} ms
${data.error ? `
Fehlerursache:${data.error}
` : ''}

HTTP Header:

${headersHtml || '
Keine Header geladen
'}
${previewHtml} `; } catch (error) { content.innerHTML = `
Fehler bei Diagnose:${error.message}
`; } } function closeServiceModal() { const overlay = document.getElementById('serviceModalOverlay'); if (overlay) { overlay.setAttribute('hidden', ''); overlay.classList.remove('is-active'); } } async function startFolderScan(dirPath) { const resultsEl = document.getElementById('scannerResults'); const btn = document.getElementById('scannerStartBtn'); if (!resultsEl || !btn) return; btn.disabled = true; const originalText = btn.textContent; btn.textContent = 'Scanne...'; resultsEl.removeAttribute('hidden'); resultsEl.innerHTML = '
Scanne Verzeichnis und berechne Größen. Bitte warten...
'; try { const response = await fetch(`/api/disks/scan-folder?path=${encodeURIComponent(dirPath)}`, { headers: { Authorization: `Bearer ${tokenSet?.id_token}` } }); const data = await response.json(); if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`); if (!data.children || data.children.length === 0) { resultsEl.innerHTML = '
Keine Dateien oder Verzeichnisse gefunden oder Zugriff verweigert.
'; return; } resultsEl.innerHTML = data.children.map((child) => { const sizeStr = formatBytes(child.size); const icon = child.isDirectory ? '📁' : child.isLink ? '🔗' : '📄'; const nameColor = child.isDirectory ? '#67e8f9' : '#fff'; let clickAction = ''; if (child.isDirectory && !child.error) { clickAction = `class="scanner-folder-link" data-path="${child.path}" style="cursor: pointer; color: ${nameColor}; text-decoration: underline;"`; } else { clickAction = `style="color: ${nameColor};"`; } const errorText = child.error ? ' (Fehler beim Lesen)' : ''; return `
${icon} ${child.name}${errorText} ${sizeStr}
`; }).join(''); resultsEl.querySelectorAll('.scanner-folder-link').forEach((link) => { link.addEventListener('click', () => { const nextPath = link.dataset.path; document.getElementById('scannerPathInput').value = nextPath; startFolderScan(nextPath); }); }); } catch (error) { resultsEl.innerHTML = `
Scan fehlgeschlagen:${error.message}
`; } finally { btn.disabled = false; btn.textContent = originalText; } } function initFolderScanner() { const btn = document.getElementById('scannerStartBtn'); const input = document.getElementById('scannerPathInput'); if (btn && input) { btn.addEventListener('click', () => { const val = input.value.trim(); if (val) startFolderScan(val); }); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { const val = input.value.trim(); if (val) startFolderScan(val); } }); } } function initModals() { const logsCloseBtn = document.getElementById('logsModalCloseBtn'); const logsOverlay = document.getElementById('logsModalOverlay'); const logsTailSelect = document.getElementById('logsTailSelect'); const logsRefreshBtn = document.getElementById('logsModalRefreshBtn'); if (logsCloseBtn) { logsCloseBtn.addEventListener('click', closeLogsModal); } if (logsOverlay) { logsOverlay.addEventListener('click', (e) => { if (e.target === logsOverlay) closeLogsModal(); }); } if (logsTailSelect) { logsTailSelect.addEventListener('change', () => { if (currentLogsContainer) { fetchContainerLogs(currentLogsContainer, Number(logsTailSelect.value)); } }); } if (logsRefreshBtn) { logsRefreshBtn.addEventListener('click', () => { if (currentLogsContainer) { const tail = logsTailSelect ? Number(logsTailSelect.value) : 150; fetchContainerLogs(currentLogsContainer, tail); } }); } const serviceCloseBtn = document.getElementById('serviceModalCloseBtn'); const serviceOverlay = document.getElementById('serviceModalOverlay'); if (serviceCloseBtn) { serviceCloseBtn.addEventListener('click', closeServiceModal); } if (serviceOverlay) { serviceOverlay.addEventListener('click', (e) => { if (e.target === serviceOverlay) closeServiceModal(); }); } } function initCollapsibleCards() { document.querySelectorAll('.ops-card-head').forEach((head) => { const card = head.closest('.ops-card'); if (!card) return; const bodyEl = card.querySelector('.services-status-board, .ops-list, .admin-table'); const id = bodyEl ? bodyEl.id : null; const storageKey = id ? `ops-card-collapsed-${id}` : null; if (storageKey) { let isCollapsed; const stored = localStorage.getItem(storageKey); if (stored === null) { // Default to collapsed for the service editor, expanded for others isCollapsed = (id === 'serviceEditor'); } else { isCollapsed = (stored === 'true'); } card.classList.toggle('is-collapsed', isCollapsed); } head.addEventListener('click', (event) => { if (event.target.closest('button')) { return; } card.classList.toggle('is-collapsed'); if (storageKey) { localStorage.setItem(storageKey, card.classList.contains('is-collapsed')); } }); }); } let activeTab = localStorage.getItem('active_admin_tab') || 'overview'; function switchTab(tabId) { activeTab = tabId; localStorage.setItem('active_admin_tab', tabId); const tabCards = { overview: document.getElementById('card-overview'), services: document.getElementById('card-services'), disks: document.getElementById('card-disks'), watchtower: document.getElementById('card-watchtower'), containers: document.getElementById('card-containers'), config: document.getElementById('editorCard') }; Object.entries(tabCards).forEach(([id, card]) => { if (!card) return; if (id === tabId) { card.classList.add('is-active'); card.classList.remove('admin-tab-content'); card.classList.remove('is-collapsed'); } else { card.classList.remove('is-active'); card.classList.add('admin-tab-content'); } }); document.querySelectorAll('.drawer-nav-item[data-tab]').forEach((item) => { const isActive = item.dataset.tab === tabId; item.classList.toggle('is-active', isActive); }); closeNavDrawer(); } function initTabNavigation() { const hamburgerMenuBtn = document.getElementById('hamburgerMenuBtn'); const adminNavDrawer = document.getElementById('adminNavDrawer'); const adminNavBackdrop = document.getElementById('adminNavBackdrop'); const drawerCloseBtn = document.getElementById('drawerCloseBtn'); if (hamburgerMenuBtn && adminNavDrawer && adminNavBackdrop) { hamburgerMenuBtn.addEventListener('click', () => { adminNavDrawer.classList.add('is-open'); adminNavBackdrop.classList.add('is-open'); }); const closeNavDrawerLocal = () => { adminNavDrawer.classList.remove('is-open'); adminNavBackdrop.classList.remove('is-open'); }; if (drawerCloseBtn) drawerCloseBtn.addEventListener('click', closeNavDrawerLocal); adminNavBackdrop.addEventListener('click', closeNavDrawerLocal); } document.querySelectorAll('.drawer-nav-item[data-tab]').forEach((item) => { item.addEventListener('click', () => { switchTab(item.dataset.tab); }); }); // Setup click handlers for mini cards inside the Overview tab document.querySelectorAll('.overview-mini-card[data-target-tab]').forEach((card) => { card.addEventListener('click', () => { switchTab(card.dataset.targetTab); }); }); switchTab(activeTab); } function closeNavDrawer() { const adminNavDrawer = document.getElementById('adminNavDrawer'); const adminNavBackdrop = document.getElementById('adminNavBackdrop'); if (adminNavDrawer && adminNavBackdrop) { adminNavDrawer.classList.remove('is-open'); adminNavBackdrop.classList.remove('is-open'); } } async function boot() { await handleCallback(); // Check if we just logged out const loggedOut = sessionStorage.getItem('mischlabs.admin.logged_out') === 'true'; if (loggedOut) { sessionStorage.removeItem('mischlabs.admin.logged_out'); loginPanel.hidden = false; adminPanel.hidden = true; loginButton.hidden = false; logoutButton.hidden = true; if (headerUserBadge) headerUserBadge.hidden = true; const hamburgerBtn = document.getElementById('hamburgerMenuBtn'); if (hamburgerBtn) hamburgerBtn.style.display = 'none'; return; } tokenSet = getStoredTokens(); if (isTokenValid(tokenSet)) { storeTokens(tokenSet); } if (!isTokenValid(tokenSet)) { if (loginMessage.textContent) { loginPanel.hidden = false; adminPanel.hidden = true; loginButton.hidden = false; logoutButton.hidden = true; if (headerUserBadge) headerUserBadge.hidden = true; const hamburgerBtn = document.getElementById('hamburgerMenuBtn'); if (hamburgerBtn) hamburgerBtn.style.display = 'none'; return; } // Automatic silent SSO login redirect login(); return; } const claims = authorize(tokenSet); if (!claims) return; config = await loadConfig(); loginPanel.hidden = true; adminPanel.hidden = false; // Hide top header buttons and show hamburger button const headerDashboardBtn = document.getElementById('headerDashboardBtn'); if (headerDashboardBtn) headerDashboardBtn.style.display = 'none'; loginButton.hidden = true; logoutButton.hidden = true; if (headerUserBadge) { headerUserBadge.hidden = false; headerUserBadge.innerHTML = `👤 Angemeldet als ${claims.preferred_username || claims.email}`; } const hamburgerBtn = document.getElementById('hamburgerMenuBtn'); if (hamburgerBtn) hamburgerBtn.style.display = 'flex'; adminUser.textContent = `Angemeldet als ${claims.preferred_username || claims.email}`; renderEditor(); initCollapsibleCards(); initTabNavigation(); initModals(); initFolderScanner(); refreshOps(); startOpsAutoRefresh(); } loginButton.addEventListener('click', login); const performLogout = () => { const idToken = tokenSet?.id_token; clearTokens(); sessionStorage.setItem('mischlabs.admin.logged_out', 'true'); const params = new URLSearchParams({ client_id: CLIENT_ID, post_logout_redirect_uri: REDIRECT_URI }); if (idToken) params.set('id_token_hint', idToken); window.location.href = `${AUTHORITY}/protocol/openid-connect/logout?${params}`; }; logoutButton.addEventListener('click', performLogout); const drawerLogoutBtn = document.getElementById('drawerLogoutBtn'); if (drawerLogoutBtn) { drawerLogoutBtn.addEventListener('click', performLogout); } checkAllButton.addEventListener('click', () => { [...serviceEditor.querySelectorAll('.admin-row')].forEach((row) => checkRow(row)); }); saveLocalButton.addEventListener('click', () => { saveLocal(); window.alert('Lokal gespeichert. Das Dashboard in diesem Browser nutzt diese Aenderungen.'); }); resetLocalButton.addEventListener('click', () => { localStorage.removeItem(LOCAL_CONFIG_KEY); window.location.reload(); }); downloadConfigButton.addEventListener('click', downloadConfig); refreshOpsButton.addEventListener('click', refreshOps); if (refreshServicesButton) { refreshServicesButton.addEventListener('click', () => refreshOps()); } runWatchtowerButton.addEventListener('click', runWatchtowerOnce); const viewWatchtowerLogsBtn = document.getElementById('viewWatchtowerLogsBtn'); if (viewWatchtowerLogsBtn) { viewWatchtowerLogsBtn.addEventListener('click', () => { openLogsModal('watchtower'); }); } dangerModeButton.addEventListener('click', unlockDangerMode); boot().catch((error) => { loginMessage.textContent = `Adminseite konnte nicht starten: ${error.message}`; });