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.
|
||||
- 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
|
||||
|
||||
31
Dockerfile
31
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"]
|
||||
|
||||
27
admin.html
27
admin.html
@@ -6,7 +6,7 @@
|
||||
<title>MischLabs Admin</title>
|
||||
<meta name="description" content="MischLabs dashboard administration.">
|
||||
<meta name="theme-color" content="#0f172a">
|
||||
<link rel="stylesheet" href="/style.css?v=11">
|
||||
<link rel="stylesheet" href="/style.css?v=12">
|
||||
</head>
|
||||
<body>
|
||||
<header class="shell hero admin-hero">
|
||||
@@ -53,10 +53,33 @@
|
||||
</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>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/admin.js?v=2" defer></script>
|
||||
<script src="/admin.js?v=3" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
104
admin.js
104
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 `<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() {
|
||||
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}`;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<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="512x512" href="/icons/icon-512.png">
|
||||
<link rel="stylesheet" href="style.css?v=11">
|
||||
<link rel="stylesheet" href="style.css?v=12">
|
||||
</head>
|
||||
<body>
|
||||
<header class="shell hero">
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MischLabs - Offline</title>
|
||||
<meta name="theme-color" content="#0f172a">
|
||||
<link rel="stylesheet" href="/style.css?v=11">
|
||||
<link rel="stylesheet" href="/style.css?v=12">
|
||||
</head>
|
||||
<body>
|
||||
<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;
|
||||
}
|
||||
|
||||
.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%;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user