Add NAS status proxy
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 33s

This commit is contained in:
Kroonk
2026-05-21 16:22:34 +02:00
parent f0674cda37
commit 29b3b623eb
10 changed files with 570 additions and 18 deletions

View File

@@ -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. - 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`. - 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. - 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

View File

@@ -1,15 +1,22 @@
FROM nginx:alpine FROM node:22-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf WORKDIR /app
COPY index.html /usr/share/nginx/html/
COPY admin.html /usr/share/nginx/html/ COPY server.js /app/server.js
COPY offline.html /usr/share/nginx/html/ COPY index.html /app/public/
COPY style.css /usr/share/nginx/html/ COPY admin.html /app/public/
COPY app.js /usr/share/nginx/html/ COPY offline.html /app/public/
COPY admin.js /usr/share/nginx/html/ COPY style.css /app/public/
COPY services.json /usr/share/nginx/html/ COPY app.js /app/public/
COPY manifest.webmanifest /usr/share/nginx/html/ COPY admin.js /app/public/
COPY sw.js /usr/share/nginx/html/ COPY services.json /app/public/
COPY icons/ /usr/share/nginx/html/icons/ COPY manifest.webmanifest /app/public/
COPY sw.js /app/public/
COPY icons/ /app/public/icons/
ENV NODE_ENV=production
ENV PORT=80
EXPOSE 80 EXPOSE 80
CMD ["node", "server.js"]

View File

@@ -6,7 +6,7 @@
<title>MischLabs Admin</title> <title>MischLabs Admin</title>
<meta name="description" content="MischLabs dashboard administration."> <meta name="description" content="MischLabs dashboard administration.">
<meta name="theme-color" content="#0f172a"> <meta name="theme-color" content="#0f172a">
<link rel="stylesheet" href="/style.css?v=11"> <link rel="stylesheet" href="/style.css?v=12">
</head> </head>
<body> <body>
<header class="shell hero admin-hero"> <header class="shell hero admin-hero">
@@ -53,10 +53,33 @@
</div> </div>
<div class="admin-user" id="adminUser"></div> <div class="admin-user" id="adminUser"></div>
<div class="ops-grid" id="opsGrid">
<section class="ops-card">
<div class="ops-card-head">
<span>NAS Speicher</span>
<button class="ghost-button mini" id="refreshOpsButton" type="button">Aktualisieren</button>
</div>
<div id="diskStatus" class="ops-list">Noch nicht geladen.</div>
</section>
<section class="ops-card">
<div class="ops-card-head">
<span>Watchtower</span>
</div>
<div id="watchtowerStatus" class="ops-list">Noch nicht geladen.</div>
</section>
<section class="ops-card ops-card-wide">
<div class="ops-card-head">
<span>Container</span>
</div>
<div id="containerStatus" class="ops-list">Noch nicht geladen.</div>
</section>
</div>
<div class="admin-table" id="serviceEditor"></div> <div class="admin-table" id="serviceEditor"></div>
</section> </section>
</main> </main>
<script src="/admin.js?v=2" defer></script> <script src="/admin.js?v=3" defer></script>
</body> </body>
</html> </html>

104
admin.js
View File

@@ -18,6 +18,10 @@ const checkAllButton = document.querySelector('#checkAllButton');
const saveLocalButton = document.querySelector('#saveLocalButton'); const saveLocalButton = document.querySelector('#saveLocalButton');
const resetLocalButton = document.querySelector('#resetLocalButton'); const resetLocalButton = document.querySelector('#resetLocalButton');
const downloadConfigButton = document.querySelector('#downloadConfigButton'); 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 config = null;
let tokenSet = null; let tokenSet = null;
@@ -271,6 +275,104 @@ function downloadConfig() {
URL.revokeObjectURL(url); 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 `<div class="ops-line"><strong>${disk.label}</strong><span>${disk.error}</span></div>`;
}
const level = disk.usedPercent >= 90 ? 'danger' : disk.usedPercent >= 80 ? 'warn' : 'ok';
return `
<div class="ops-line">
<strong>${disk.label}</strong>
<span>${formatBytes(disk.used)} / ${formatBytes(disk.total)} (${disk.usedPercent}%)</span>
</div>
<div class="usage-bar" data-level="${level}">
<span style="width:${Math.min(disk.usedPercent, 100)}%"></span>
</div>
`;
}).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 = `
<div class="ops-line"><strong>Letzter Lauf</strong><span>${finished}</span></div>
<div class="ops-line"><strong>Geprueft</strong><span>${session.scanned ?? '--'}</span></div>
<div class="ops-line"><strong>Aktualisiert</strong><span>${session.updated ?? '--'}</span></div>
<div class="ops-line"><strong>Fehler</strong><span>${session.failed ?? '--'}</span></div>
`;
}
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) => `
<div class="ops-line">
<strong>${container.name}</strong>
<span>${container.status}</span>
</div>
`).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() { async function boot() {
await handleCallback(); await handleCallback();
tokenSet = getStoredTokens(); tokenSet = getStoredTokens();
@@ -293,6 +395,7 @@ async function boot() {
logoutButton.hidden = false; logoutButton.hidden = false;
adminUser.textContent = `Angemeldet als ${claims.preferred_username || claims.email}`; adminUser.textContent = `Angemeldet als ${claims.preferred_username || claims.email}`;
renderEditor(); renderEditor();
refreshOps();
} }
loginButton.addEventListener('click', login); loginButton.addEventListener('click', login);
@@ -322,6 +425,7 @@ resetLocalButton.addEventListener('click', () => {
}); });
downloadConfigButton.addEventListener('click', downloadConfig); downloadConfigButton.addEventListener('click', downloadConfig);
refreshOpsButton.addEventListener('click', refreshOps);
boot().catch((error) => { boot().catch((error) => {
loginMessage.textContent = `Adminseite konnte nicht starten: ${error.message}`; loginMessage.textContent = `Adminseite konnte nicht starten: ${error.message}`;

View File

@@ -5,3 +5,7 @@ services:
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8085:80" - "8085:80"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /volume1:/host/volume1:ro
- /volume2:/host/volume2:ro

View File

@@ -13,7 +13,7 @@
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-v2.png"> <link rel="apple-touch-icon" href="/icons/apple-touch-icon-v2.png">
<link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png"> <link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png">
<link rel="icon" type="image/png" sizes="512x512" href="/icons/icon-512.png"> <link rel="icon" type="image/png" sizes="512x512" href="/icons/icon-512.png">
<link rel="stylesheet" href="style.css?v=11"> <link rel="stylesheet" href="style.css?v=12">
</head> </head>
<body> <body>
<header class="shell hero"> <header class="shell hero">

View File

@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MischLabs - Offline</title> <title>MischLabs - Offline</title>
<meta name="theme-color" content="#0f172a"> <meta name="theme-color" content="#0f172a">
<link rel="stylesheet" href="/style.css?v=11"> <link rel="stylesheet" href="/style.css?v=12">
</head> </head>
<body> <body>
<main class="shell hero"> <main class="shell hero">

311
server.js Normal file
View File

@@ -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}`);
});

View File

@@ -500,6 +500,83 @@ main {
margin-top: 14px; 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 { .admin-row {
display: grid; display: grid;
grid-template-columns: minmax(170px, 1.1fr) minmax(150px, 0.7fr) minmax(220px, 1.2fr) auto; 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; grid-template-columns: 1fr;
} }
.ops-grid {
grid-template-columns: 1fr;
}
.hero-meta { .hero-meta {
width: 100%; width: 100%;
} }

4
sw.js
View File

@@ -1,9 +1,9 @@
const CACHE_NAME = 'mischlabs-pwa-v5'; const CACHE_NAME = 'mischlabs-pwa-v6';
const APP_SHELL = [ const APP_SHELL = [
'/', '/',
'/index.html', '/index.html',
'/offline.html', '/offline.html',
'/style.css?v=11', '/style.css?v=12',
'/app.js?v=3', '/app.js?v=3',
'/services.json?v=1', '/services.json?v=1',
'/manifest.webmanifest?v=3', '/manifest.webmanifest?v=3',