Add NAS status proxy
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 33s
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 33s
This commit is contained in:
22
Brain.md
22
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.
|
- 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
|
||||||
|
|||||||
31
Dockerfile
31
Dockerfile
@@ -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"]
|
||||||
|
|||||||
27
admin.html
27
admin.html
@@ -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
104
admin.js
@@ -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}`;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
311
server.js
Normal 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}`);
|
||||||
|
});
|
||||||
81
style.css
81
style.css
@@ -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
4
sw.js
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user