diff --git a/Brain.md b/Brain.md index ffe4633..b5d94b1 100644 --- a/Brain.md +++ b/Brain.md @@ -143,3 +143,25 @@ mischlabs/ - Statusdiagnose nutzt Browser-Fetch mit `mode: "no-cors"`. Dadurch erkennt sie Erreichbarkeit/Timeouts, aber bei fremden Subdomains keine echten HTTP-Statuscodes. - Wenn der Browser die Pruefung wegen CORS/CORB blockiert (`Failed to fetch`), wird der Dienst **nicht** mehr als offline markiert, sondern als "Nicht im Browser pruefbar". Das betraf z. B. Vaultwarden/`password.mischlabs.de`. - Fuer echte Fehlerdetails braucht es ein kleines Backend bzw. einen Statusproxy, der serverseitig `HEAD`/`GET` prueft. + +## Statusproxy Stand 2026-05-21 +- `mischlabs` wurde von Nginx auf einen kleinen Node-Server (`server.js`) umgestellt. +- Der Node-Server serviert die statischen Dateien weiter und stellt `/api/status` bereit. +- `/api/status` liefert: + - Docker-Container via `/var/run/docker.sock` + - Watchtower-Logs und letzten Watchtower-Lauf + - Speicherplatz fuer `/volume1` und `/volume2`, im Container gemountet als `/host/volume1` und `/host/volume2` + - Serverseitige Service-Checks mit HTTP-Status und Latenz +- NAS-Compose fuer `mischlabs` braucht diese Mounts: + +```yaml +volumes: + - /var/run/docker.sock:/var/run/docker.sock + - /volume1:/host/volume1:ro + - /volume2:/host/volume2:ro +``` + +- Aktuelle NAS-Werte bei Planung: + - `/volume1`: 3.7T total, 2.5T used, 1.2T free, 68% + - `/volume2`: 104G total, 88G used, 12G free, 89% + - Watchtower: `Scanned=23`, `Updated=0`, `Failed=0` beim manuellen Lauf um 2026-05-21 14:16 UTC diff --git a/Dockerfile b/Dockerfile index 0c9f555..2abfaf7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,22 @@ -FROM nginx:alpine +FROM node:22-alpine -COPY nginx.conf /etc/nginx/conf.d/default.conf -COPY index.html /usr/share/nginx/html/ -COPY admin.html /usr/share/nginx/html/ -COPY offline.html /usr/share/nginx/html/ -COPY style.css /usr/share/nginx/html/ -COPY app.js /usr/share/nginx/html/ -COPY admin.js /usr/share/nginx/html/ -COPY services.json /usr/share/nginx/html/ -COPY manifest.webmanifest /usr/share/nginx/html/ -COPY sw.js /usr/share/nginx/html/ -COPY icons/ /usr/share/nginx/html/icons/ +WORKDIR /app + +COPY server.js /app/server.js +COPY index.html /app/public/ +COPY admin.html /app/public/ +COPY offline.html /app/public/ +COPY style.css /app/public/ +COPY app.js /app/public/ +COPY admin.js /app/public/ +COPY services.json /app/public/ +COPY manifest.webmanifest /app/public/ +COPY sw.js /app/public/ +COPY icons/ /app/public/icons/ + +ENV NODE_ENV=production +ENV PORT=80 EXPOSE 80 + +CMD ["node", "server.js"] diff --git a/admin.html b/admin.html index 2a3f1c5..bfba291 100644 --- a/admin.html +++ b/admin.html @@ -6,7 +6,7 @@ MischLabs Admin - +
@@ -53,10 +53,33 @@
+
+
+
+ NAS Speicher + +
+
Noch nicht geladen.
+
+ +
+
+ Watchtower +
+
Noch nicht geladen.
+
+ +
+
+ Container +
+
Noch nicht geladen.
+
+
- + diff --git a/admin.js b/admin.js index 9da538f..2246632 100644 --- a/admin.js +++ b/admin.js @@ -18,6 +18,10 @@ 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 diskStatus = document.querySelector('#diskStatus'); +const watchtowerStatus = document.querySelector('#watchtowerStatus'); +const containerStatus = document.querySelector('#containerStatus'); let config = null; let tokenSet = null; @@ -271,6 +275,104 @@ function downloadConfig() { 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 renderDisks(disks = []) { + diskStatus.innerHTML = disks.map((disk) => { + if (disk.error) { + return `
${disk.label}${disk.error}
`; + } + + const level = disk.usedPercent >= 90 ? 'danger' : disk.usedPercent >= 80 ? 'warn' : 'ok'; + return ` +
+ ${disk.label} + ${formatBytes(disk.used)} / ${formatBytes(disk.total)} (${disk.usedPercent}%) +
+
+ +
+ `; + }).join(''); +} + +function renderWatchtower(watchtower) { + const session = watchtower?.lastSession; + if (!session) { + watchtowerStatus.textContent = 'Kein Watchtower-Lauf gefunden.'; + return; + } + + const finished = session.finishedAt ? new Date(session.finishedAt).toLocaleString('de-DE') : '--'; + watchtowerStatus.innerHTML = ` +
Letzter Lauf${finished}
+
Geprueft${session.scanned ?? '--'}
+
Aktualisiert${session.updated ?? '--'}
+
Fehler${session.failed ?? '--'}
+ `; +} + +function renderContainers(containers) { + if (!Array.isArray(containers)) { + containerStatus.textContent = containers?.error || 'Containerdaten nicht verfuegbar.'; + return; + } + + 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(''); +} + +async function refreshOps() { + diskStatus.textContent = 'Lade...'; + watchtowerStatus.textContent = 'Lade...'; + containerStatus.textContent = 'Lade...'; + + 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); + } catch (error) { + diskStatus.textContent = `Statusproxy nicht erreichbar: ${error.message}`; + watchtowerStatus.textContent = 'Nicht verfuegbar.'; + containerStatus.textContent = 'Nicht verfuegbar.'; + } +} + async function boot() { await handleCallback(); tokenSet = getStoredTokens(); @@ -293,6 +395,7 @@ async function boot() { logoutButton.hidden = false; adminUser.textContent = `Angemeldet als ${claims.preferred_username || claims.email}`; renderEditor(); + refreshOps(); } loginButton.addEventListener('click', login); @@ -322,6 +425,7 @@ resetLocalButton.addEventListener('click', () => { }); downloadConfigButton.addEventListener('click', downloadConfig); +refreshOpsButton.addEventListener('click', refreshOps); boot().catch((error) => { loginMessage.textContent = `Adminseite konnte nicht starten: ${error.message}`; diff --git a/docker-compose.yml b/docker-compose.yml index 6803ab0..8451222 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,3 +5,7 @@ services: restart: unless-stopped ports: - "8085:80" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - /volume1:/host/volume1:ro + - /volume2:/host/volume2:ro diff --git a/index.html b/index.html index 39e06c2..73ca94d 100644 --- a/index.html +++ b/index.html @@ -13,7 +13,7 @@ - +
diff --git a/offline.html b/offline.html index 70a715f..7fac260 100644 --- a/offline.html +++ b/offline.html @@ -5,7 +5,7 @@ MischLabs - Offline - +
diff --git a/server.js b/server.js new file mode 100644 index 0000000..58f1bf5 --- /dev/null +++ b/server.js @@ -0,0 +1,311 @@ +const http = require('node:http'); +const https = require('node:https'); +const fs = require('node:fs'); +const fsp = require('node:fs/promises'); +const path = require('node:path'); +const { URL } = require('node:url'); + +const PORT = Number(process.env.PORT || 80); +const dockerPublicDir = path.join(__dirname, 'public'); +const PUBLIC_DIR = fs.existsSync(dockerPublicDir) ? dockerPublicDir : __dirname; +const DOCKER_SOCKET = process.env.DOCKER_SOCKET || '/var/run/docker.sock'; +const WATCHTOWER_CONTAINER = process.env.WATCHTOWER_CONTAINER || 'watchtower'; +const DISK_TARGETS = [ + { id: 'volume1', label: 'Volume 1', path: process.env.STATUS_VOLUME1_PATH || '/host/volume1' }, + { id: 'volume2', label: 'Volume 2', path: process.env.STATUS_VOLUME2_PATH || '/host/volume2' }, + { id: 'root', label: 'Root', path: '/' } +]; + +const MIME_TYPES = { + '.html': 'text/html; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.js': 'application/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.webmanifest': 'application/manifest+json; charset=utf-8', + '.png': 'image/png', + '.svg': 'image/svg+xml; charset=utf-8', + '.ico': 'image/x-icon' +}; + +const serviceUrls = [ + 'https://mischlabs.de', + 'https://auth.mischlabs.de/realms/mischlabs/account', + 'https://drive.mischlabs.de', + 'https://git.mischlabs.de', + 'https://movies.mischlabs.de', + 'https://audiobook.mischlabs.de', + 'https://password.mischlabs.de', + 'https://books.mischlabs.de', + 'https://michess.mischlabs.de', + 'https://tom.mischlabs.de' +]; + +function json(res, status, payload) { + res.writeHead(status, { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'no-store' + }); + res.end(JSON.stringify(payload)); +} + +function dockerRequest(endpoint) { + return new Promise((resolve, reject) => { + const req = http.request({ + socketPath: DOCKER_SOCKET, + path: endpoint, + method: 'GET' + }, (res) => { + let body = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => { body += chunk; }); + res.on('end', () => { + if (res.statusCode < 200 || res.statusCode >= 300) { + reject(new Error(`Docker API ${endpoint} returned ${res.statusCode}`)); + return; + } + + try { + resolve(body ? JSON.parse(body) : null); + } catch (error) { + reject(error); + } + }); + }); + + req.on('error', reject); + req.end(); + }); +} + +function dockerLogs(container, tail = 160) { + return new Promise((resolve, reject) => { + const endpoint = `/containers/${encodeURIComponent(container)}/logs?stdout=1&stderr=1&tail=${tail}`; + const req = http.request({ + socketPath: DOCKER_SOCKET, + path: endpoint, + method: 'GET' + }, (res) => { + const chunks = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => { + if (res.statusCode < 200 || res.statusCode >= 300) { + reject(new Error(`Docker logs returned ${res.statusCode}`)); + return; + } + + const raw = Buffer.concat(chunks); + const lines = []; + let offset = 0; + + while (offset + 8 <= raw.length) { + const size = raw.readUInt32BE(offset + 4); + const start = offset + 8; + const end = start + size; + if (end > raw.length) break; + lines.push(raw.slice(start, end).toString('utf8')); + offset = end; + } + + resolve((lines.length ? lines.join('') : raw.toString('utf8')).trim().split(/\r?\n/).filter(Boolean)); + }); + }); + + req.on('error', reject); + req.end(); + }); +} + +async function getContainers() { + const containers = await dockerRequest('/containers/json?all=0'); + return containers.map((container) => ({ + id: container.Id.slice(0, 12), + name: (container.Names?.[0] || '').replace(/^\//, ''), + image: container.Image, + status: container.Status, + state: container.State, + ports: (container.Ports || []).map((port) => ({ + privatePort: port.PrivatePort, + publicPort: port.PublicPort, + type: port.Type, + ip: port.IP + })) + })).sort((a, b) => a.name.localeCompare(b.name)); +} + +async function getDisks() { + const disks = []; + + for (const target of DISK_TARGETS) { + try { + const stats = await fsp.statfs(target.path); + const total = Number(stats.blocks) * Number(stats.bsize); + const available = Number(stats.bavail) * Number(stats.bsize); + const used = total - available; + disks.push({ + ...target, + total, + used, + available, + usedPercent: total ? Math.round((used / total) * 100) : null + }); + } catch (error) { + disks.push({ ...target, error: error.message }); + } + } + + return disks; +} + +function parseWatchtower(lines) { + const sessions = []; + let current = null; + + for (const line of lines) { + const timeMatch = line.match(/time="([^"]+)"/); + const time = timeMatch?.[1] || null; + + if (line.includes('Running a one time update.') || line.includes('Checking all containers')) { + current = current || { startedAt: time, found: [], warnings: [] }; + } + + if (line.includes('Found new')) { + current = current || { startedAt: time, found: [], warnings: [] }; + current.found.push(line.replace(/^.*msg="/, '').replace(/"$/, '')); + } + + if (line.includes('level=warning')) { + current = current || { startedAt: time, found: [], warnings: [] }; + current.warnings.push(line.replace(/^.*msg="/, '').replace(/"$/, '')); + } + + const done = line.match(/Session done" Failed=(\d+) Scanned=(\d+) Updated=(\d+)/); + if (done) { + current = current || { startedAt: time, found: [], warnings: [] }; + current.finishedAt = time; + current.failed = Number(done[1]); + current.scanned = Number(done[2]); + current.updated = Number(done[3]); + sessions.push(current); + current = null; + } + } + + return sessions.at(-1) || null; +} + +function probeUrl(target) { + return new Promise((resolve) => { + const started = Date.now(); + const url = new URL(target); + const client = url.protocol === 'http:' ? http : https; + const req = client.request(url, { + method: 'HEAD', + timeout: 8000, + headers: { 'User-Agent': 'MischLabs-Status/1.0' } + }, (res) => { + res.resume(); + resolve({ + url: target, + ok: res.statusCode >= 200 && res.statusCode < 500, + statusCode: res.statusCode, + location: res.headers.location || null, + contentType: res.headers['content-type'] || null, + ms: Date.now() - started + }); + }); + + req.on('timeout', () => { + req.destroy(new Error('timeout')); + }); + req.on('error', (error) => { + resolve({ + url: target, + ok: false, + error: error.message, + ms: Date.now() - started + }); + }); + req.end(); + }); +} + +async function getServices() { + return Promise.all(serviceUrls.map(probeUrl)); +} + +async function statusPayload() { + const [containers, disks, logs, services] = await Promise.all([ + getContainers().catch((error) => ({ error: error.message })), + getDisks(), + dockerLogs(WATCHTOWER_CONTAINER).catch((error) => [`watchtower logs unavailable: ${error.message}`]), + getServices() + ]); + + return { + generatedAt: new Date().toISOString(), + containers, + disks, + watchtower: { + container: WATCHTOWER_CONTAINER, + lastSession: Array.isArray(logs) ? parseWatchtower(logs) : null, + tail: Array.isArray(logs) ? logs.slice(-20) : logs + }, + services + }; +} + +async function serveStatic(req, res) { + const requestUrl = new URL(req.url, `http://${req.headers.host}`); + let pathname = decodeURIComponent(requestUrl.pathname); + if (pathname === '/') pathname = '/index.html'; + + const filePath = path.normalize(path.join(PUBLIC_DIR, pathname)); + if (!filePath.startsWith(PUBLIC_DIR)) { + res.writeHead(403); + res.end('Forbidden'); + return; + } + + try { + const stat = await fsp.stat(filePath); + const target = stat.isDirectory() ? path.join(filePath, 'index.html') : filePath; + const ext = path.extname(target); + const headers = { + 'Content-Type': MIME_TYPES[ext] || 'application/octet-stream', + 'X-Content-Type-Options': 'nosniff', + 'Referrer-Policy': 'strict-origin-when-cross-origin', + 'X-Frame-Options': 'SAMEORIGIN' + }; + + if (path.basename(target) === 'sw.js') { + headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0'; + } else if (path.basename(target) === 'services.json') { + headers['Cache-Control'] = 'no-cache'; + } else if (['.css', '.js', '.png', '.svg'].includes(ext)) { + headers['Cache-Control'] = 'public, max-age=604800, immutable'; + } + + res.writeHead(200, headers); + fs.createReadStream(target).pipe(res); + } catch { + res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('Not found'); + } +} + +const server = http.createServer(async (req, res) => { + if (req.url?.startsWith('/api/status')) { + try { + json(res, 200, await statusPayload()); + } catch (error) { + json(res, 500, { error: error.message }); + } + return; + } + + await serveStatic(req, res); +}); + +server.listen(PORT, () => { + console.log(`MischLabs dashboard listening on ${PORT}`); +}); diff --git a/style.css b/style.css index c20f4d0..ec3d5e9 100644 --- a/style.css +++ b/style.css @@ -500,6 +500,83 @@ main { margin-top: 14px; } +.ops-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + margin-top: 14px; +} + +.ops-card { + padding: 14px; + background: rgba(7, 9, 20, 0.42); + border: 1px solid var(--border); + border-radius: var(--radius); +} + +.ops-card-wide { + grid-column: 1 / -1; +} + +.ops-card-head, +.ops-line { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.ops-card-head { + margin-bottom: 10px; + color: #fff; + font-weight: 700; +} + +.ghost-button.mini { + min-height: 30px; + padding: 0 10px; + font-size: 0.78rem; +} + +.ops-list { + display: grid; + gap: 8px; + color: var(--muted); + font-size: 0.84rem; +} + +.ops-line strong { + color: var(--text); + font-weight: 650; +} + +.ops-line span { + color: var(--muted); + text-align: right; +} + +.usage-bar { + height: 7px; + overflow: hidden; + background: rgba(255, 255, 255, 0.08); + border-radius: 999px; +} + +.usage-bar span { + display: block; + height: 100%; + background: var(--ok); + border-radius: inherit; +} + +.usage-bar[data-level="warn"] span { + background: var(--warn); +} + +.usage-bar[data-level="danger"] span { + background: var(--down); +} + .admin-row { display: grid; grid-template-columns: minmax(170px, 1.1fr) minmax(150px, 0.7fr) minmax(220px, 1.2fr) auto; @@ -582,6 +659,10 @@ main { grid-template-columns: 1fr; } + .ops-grid { + grid-template-columns: 1fr; + } + .hero-meta { width: 100%; } diff --git a/sw.js b/sw.js index e6f3a6b..bd97fd7 100644 --- a/sw.js +++ b/sw.js @@ -1,9 +1,9 @@ -const CACHE_NAME = 'mischlabs-pwa-v5'; +const CACHE_NAME = 'mischlabs-pwa-v6'; const APP_SHELL = [ '/', '/index.html', '/offline.html', - '/style.css?v=11', + '/style.css?v=12', '/app.js?v=3', '/services.json?v=1', '/manifest.webmanifest?v=3',