diff --git a/admin.html b/admin.html index c4ed138..682eb29 100644 --- a/admin.html +++ b/admin.html @@ -6,7 +6,7 @@ MischLabs Admin - +
@@ -101,12 +101,27 @@
Noch nicht geladen.
+ +
+

Verzeichnis-Größen-Scanner

+

+ Scanne Ordnergrößen auf den Host-Volumes (z.B. /host/volume2 oder /host/volume1/docker), um Speicherfresser zu finden. +

+
+ + +
+ +
Watchtower - +
+ + +
Noch nicht geladen.
@@ -182,6 +197,44 @@ - + + + + + + + diff --git a/admin.js b/admin.js index 3ada7e5..d175db4 100644 --- a/admin.js +++ b/admin.js @@ -387,7 +387,10 @@ function renderServicesStatus(services = []) { return ` -

${item.name}

+
+

${item.name}

+ 🔍 +
@@ -395,6 +398,16 @@ function renderServicesStatus(services = []) { `; }).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 => { @@ -536,12 +549,21 @@ function renderContainers(containers) {
${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'))); }); @@ -786,6 +808,262 @@ function startOpsAutoRefresh() { }, 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'); @@ -934,6 +1212,8 @@ async function boot() { renderEditor(); initCollapsibleCards(); initTabNavigation(); + initModals(); + initFolderScanner(); refreshOps(); startOpsAutoRefresh(); } @@ -976,6 +1256,12 @@ 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) => { diff --git a/server.js b/server.js index b912e56..a8559ee 100644 --- a/server.js +++ b/server.js @@ -509,6 +509,127 @@ async function getDisks() { return disks; } +function isPathAllowed(userPath) { + if (!userPath) return false; + const resolved = path.resolve(userPath); + return resolved.startsWith('/host/volume1') || resolved.startsWith('/host/volume2') || resolved === '/'; +} + +async function getDirectorySize(dirPath, maxDepth = 3, currentDepth = 0) { + let size = 0; + try { + const stats = await fsp.lstat(dirPath); + if (stats.isSymbolicLink()) return 0; + if (stats.isFile()) { + return stats.size; + } + if (stats.isDirectory()) { + if (currentDepth >= maxDepth) return 0; + const files = await fsp.readdir(dirPath, { withFileTypes: true }); + const promises = files.map(async (file) => { + const fullPath = path.join(dirPath, file.name); + if (file.isSymbolicLink()) return 0; + if (file.isDirectory()) { + return getDirectorySize(fullPath, maxDepth, currentDepth + 1); + } + if (file.isFile()) { + try { + const fileStats = await fsp.lstat(fullPath); + return fileStats.size; + } catch { + return 0; + } + } + return 0; + }); + const sizes = await Promise.all(promises); + size = sizes.reduce((acc, curr) => acc + curr, 0); + } + } catch (err) { + // Ignore errors + } + return size; +} + +async function scanDirectoryChildren(dirPath) { + if (!isPathAllowed(dirPath)) { + throw new Error('Unzulässiger Pfad. Zugriff verweigert.'); + } + + const files = await fsp.readdir(dirPath, { withFileTypes: true }); + const results = []; + + const promises = files.map(async (file) => { + const fullPath = path.join(dirPath, file.name); + try { + if (file.isSymbolicLink()) { + results.push({ name: file.name, path: fullPath, isDirectory: false, size: 0, isLink: true }); + return; + } + + if (file.isDirectory()) { + const dirSize = await getDirectorySize(fullPath, 2); + results.push({ name: file.name, path: fullPath, isDirectory: true, size: dirSize }); + } else { + const stats = await fsp.lstat(fullPath); + results.push({ name: file.name, path: fullPath, isDirectory: false, size: stats.size }); + } + } catch { + results.push({ name: file.name, path: fullPath, isDirectory: file.isDirectory(), size: 0, error: true }); + } + }); + + await Promise.all(promises); + results.sort((a, b) => b.size - a.size); + return results; +} + +function inspectService(urlStr) { + return new Promise((resolve) => { + try { + const parsed = new URL(urlStr); + const client = parsed.protocol === 'https:' ? https : http; + const start = Date.now(); + const req = client.get(parsed.href, { + timeout: 4000, + headers: { 'User-Agent': 'MischLabs-Diagnostics/1.0' } + }, (res) => { + const chunks = []; + let bodyLength = 0; + res.on('data', (chunk) => { + if (bodyLength < 2000) { + chunks.push(chunk); + bodyLength += chunk.length; + } + }); + res.on('end', () => { + const duration = Date.now() - start; + const bodyPreview = Buffer.concat(chunks).toString('utf8').slice(0, 1000); + resolve({ + ok: res.statusCode >= 200 && res.statusCode < 400, + statusCode: res.statusCode, + statusMessage: res.statusMessage, + latency: duration, + headers: res.headers, + bodyPreview: bodyPreview + }); + }); + }); + + req.on('timeout', () => { + req.destroy(); + resolve({ ok: false, error: 'Timeout nach 4 Sekunden', latency: 4000 }); + }); + + req.on('error', (err) => { + resolve({ ok: false, error: err.message, latency: Date.now() - start }); + }); + } catch (err) { + resolve({ ok: false, error: err.message, latency: 0 }); + } + }); +} + function parseWatchtower(lines) { const sessions = []; let current = null; @@ -828,6 +949,70 @@ const server = http.createServer(async (req, res) => { return; } + const containerLogsMatch = req.url?.match(/^\/api\/containers\/([^/?#]+)\/logs(?:[?#].*)?$/); + if (containerLogsMatch) { + if (req.method !== 'GET') { + json(res, 405, { error: 'Method not allowed' }); + return; + } + + try { + await verifyAdminRequest(req); + const containerName = decodeURIComponent(containerLogsMatch[1]); + const urlParams = new URL(req.url, 'http://localhost').searchParams; + const tail = Number(urlParams.get('tail') || 150); + const logs = await dockerLogs(containerName, tail); + json(res, 200, { name: containerName, logs }); + } catch (error) { + json(res, error.statusCode || 500, { error: error.message }); + } + return; + } + + if (req.url?.startsWith('/api/services/inspect')) { + if (req.method !== 'GET') { + json(res, 405, { error: 'Method not allowed' }); + return; + } + + try { + await verifyAdminRequest(req); + const urlParams = new URL(req.url, 'http://localhost').searchParams; + const inspectUrl = urlParams.get('url'); + if (!inspectUrl) { + json(res, 400, { error: 'Missing url parameter' }); + return; + } + const data = await inspectService(inspectUrl); + json(res, 200, data); + } catch (error) { + json(res, error.statusCode || 500, { error: error.message }); + } + return; + } + + if (req.url?.startsWith('/api/disks/scan-folder')) { + if (req.method !== 'GET') { + json(res, 405, { error: 'Method not allowed' }); + return; + } + + try { + await verifyAdminRequest(req); + const urlParams = new URL(req.url, 'http://localhost').searchParams; + const scanPath = urlParams.get('path'); + if (!scanPath) { + json(res, 400, { error: 'Missing path parameter' }); + return; + } + const data = await scanDirectoryChildren(scanPath); + json(res, 200, { path: scanPath, children: data }); + } catch (error) { + json(res, error.statusCode || 500, { error: error.message }); + } + return; + } + await serveStatic(req, res); }); diff --git a/style.css b/style.css index 1ef4ef7..47697e0 100644 --- a/style.css +++ b/style.css @@ -1437,3 +1437,121 @@ main { background-color: var(--down); box-shadow: 0 0 4px rgba(251, 113, 133, 0.3); } + +/* ========================================================================== + Advanced Diagnostics & Terminal Modals (Premium Elements) + ========================================================================== */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(8, 11, 20, 0.75); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + display: flex; + align-items: center; + justify-content: center; + z-index: 2000; + opacity: 0; + pointer-events: none; + transition: opacity 0.25s cubic-bezier(0.16, 1, 0.3, 1); +} + +.modal-overlay.is-active { + opacity: 1; + pointer-events: auto; +} + +.modal-card { + background: var(--surface-1); + border: 1px solid var(--border-strong); + border-radius: calc(var(--radius) * 1.5); + width: 90%; + max-width: 680px; + max-height: 85vh; + display: flex; + flex-direction: column; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5), 0 0 100px rgba(99, 102, 241, 0.05); + transform: translateY(20px) scale(0.96); + transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); + overflow: hidden; +} + +.modal-overlay.is-active .modal-card { + transform: translateY(0) scale(1); +} + +.modal-header { + padding: 16px 20px; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + background: var(--surface-2); +} + +.modal-header h3 { + margin: 0; + font-size: 1.15rem; + font-weight: 700; + color: #fff; +} + +.modal-close-btn { + background: none; + border: none; + color: var(--dim); + font-size: 1.6rem; + cursor: pointer; + line-height: 1; + padding: 4px; + transition: color 0.15s ease, transform 0.15s ease; +} + +.modal-close-btn:hover { + color: #fff; + transform: scale(1.1); +} + +.modal-body { + padding: 20px; + overflow-y: auto; + flex-grow: 1; +} + +/* Terminal Console Theme for logs */ +.terminal-logs { + background: #090d16; + border: 1px solid #1e293b; + border-radius: var(--radius); + padding: 14px; + color: #38bdf8; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 0.82rem; + line-height: 1.5; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-all; + max-height: 420px; + box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.8); +} + +/* Custom folders scanner text sizes & margins */ +.scanner-folder-link:hover { + color: #22d3ee !important; + text-shadow: 0 0 8px rgba(34, 211, 238, 0.4); +} + +/* Live Inspect highlights */ +.text-ok { + color: var(--ok); +} +.text-down { + color: var(--down); +} +.service-inspect-trigger:hover { + background: rgba(255, 255, 255, 0.18) !important; + transform: scale(1.15); +}