feat: Add premium server-side Dienste-Status (Realtime) Board to admin panel
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 11s

This commit is contained in:
Kroonk
2026-05-21 18:04:53 +02:00
parent d4f6e56ff0
commit e603bdc3be
3 changed files with 266 additions and 2 deletions

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=17"> <link rel="stylesheet" href="/style.css?v=20">
</head> </head>
<body> <body>
<header class="shell hero admin-hero"> <header class="shell hero admin-hero">
@@ -54,6 +54,17 @@
<div class="admin-user" id="adminUser"></div> <div class="admin-user" id="adminUser"></div>
<div class="ops-grid" id="opsGrid"> <div class="ops-grid" id="opsGrid">
<section class="ops-card ops-card-wide">
<div class="ops-card-head">
<div style="display: flex; align-items: center; gap: 8px;">
<span>Dienste-Status (Live-Proxy)</span>
<span id="servicesStatusSummary" class="section-count" style="display: inline; margin-left: 8px; font-weight: 500;"></span>
</div>
<button class="ghost-button mini" id="refreshServicesButton" type="button">Aktualisieren</button>
</div>
<div id="servicesStatusBoard" class="services-status-board">Lade Status...</div>
</section>
<section class="ops-card"> <section class="ops-card">
<div class="ops-card-head"> <div class="ops-card-head">
<span>NAS Speicher</span> <span>NAS Speicher</span>
@@ -83,6 +94,6 @@
</section> </section>
</main> </main>
<script src="/admin.js?v=11" defer></script> <script src="/admin.js?v=12" defer></script>
</body> </body>
</html> </html>

112
admin.js
View File

@@ -25,6 +25,8 @@ const dangerState = document.querySelector('#dangerState');
const diskStatus = document.querySelector('#diskStatus'); const diskStatus = document.querySelector('#diskStatus');
const watchtowerStatus = document.querySelector('#watchtowerStatus'); const watchtowerStatus = document.querySelector('#watchtowerStatus');
const containerStatus = document.querySelector('#containerStatus'); const containerStatus = document.querySelector('#containerStatus');
const servicesStatusBoard = document.querySelector('#servicesStatusBoard');
const refreshServicesButton = document.querySelector('#refreshServicesButton');
let config = null; let config = null;
let tokenSet = null; let tokenSet = null;
@@ -298,6 +300,106 @@ function formatBytes(bytes) {
return `${value.toFixed(value >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`; return `${value.toFixed(value >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`;
} }
function renderServicesStatus(services = []) {
if (!servicesStatusBoard) return;
if (!Array.isArray(services) || services.length === 0) {
servicesStatusBoard.innerHTML = '<div class="ops-line">Keine Dienst-Statusdaten vom Server erhalten.</div>';
return;
}
// Create a map of status by URL for easy lookup
const statusMap = new Map();
services.forEach((s) => {
if (s && s.url) {
statusMap.set(s.url, s);
}
});
// We want to render:
// 1. The main Landing Page (mischlabs.de)
// 2. All services defined in the config (from config.services)
const itemsToRender = [];
// Main landing page check
const mainSiteUrl = 'https://mischlabs.de';
const mainSiteStatus = statusMap.get(mainSiteUrl);
if (mainSiteStatus) {
itemsToRender.push({
name: 'MischLabs Landing Page',
domain: 'mischlabs.de',
url: mainSiteUrl,
status: mainSiteStatus
});
}
// Services from editor config
if (config && Array.isArray(config.services)) {
config.services.forEach((service) => {
const serviceStatus = statusMap.get(service.url);
if (serviceStatus) {
itemsToRender.push({
name: service.name,
domain: service.domain,
url: service.url,
status: serviceStatus
});
} else {
// Fallback if URL didn't match exactly but we can find a substring/partial match,
// or just show it as unknown
const fallbackStatus = services.find(s => s && s.url && s.url.includes(service.domain));
itemsToRender.push({
name: service.name,
domain: service.domain,
url: service.url,
status: fallbackStatus || { url: service.url, ok: false, error: 'Nicht geprueft' }
});
}
});
}
const offlineCount = itemsToRender.filter(item => !item.status.ok).length;
const totalCount = itemsToRender.length;
const summaryEl = document.querySelector('#servicesStatusSummary');
if (summaryEl) {
if (offlineCount > 0) {
summaryEl.innerHTML = `<span style="color: var(--down); font-weight: 700;">${offlineCount} offline</span> / ${totalCount} gesamt`;
} else {
summaryEl.innerHTML = `<span style="color: var(--ok); font-weight: 700;">Alle online</span> (${totalCount})`;
}
}
servicesStatusBoard.innerHTML = itemsToRender.map((item) => {
const s = item.status;
let statusClass = 'is-online';
let statusLabel = `HTTP ${s.statusCode || '200'}`;
let latencyText = `${s.ms ?? '--'} ms`;
if (s.error || s.ok === false) {
statusClass = 'is-offline';
statusLabel = s.statusCode ? `HTTP ${s.statusCode}` : 'Offline';
latencyText = s.error || 'Verbindung fehlgeschlagen';
} else if (s.ms > 1000) {
statusClass = 'is-warning';
statusLabel = 'Traege';
}
return `
<div class="status-board-item ${statusClass}">
<div class="status-board-item-header">
<h4>${item.name}</h4>
<span class="status-board-item-latency">${latencyText}</span>
</div>
<div class="status-board-item-body">
<span class="status-board-item-domain">${item.domain}</span>
<div class="status-board-item-info">
<span class="status-board-item-status">${statusLabel}</span>
</div>
</div>
</div>
`;
}).join('');
}
function renderDisks(disks = []) { function renderDisks(disks = []) {
diskStatus.innerHTML = disks.map((disk) => { diskStatus.innerHTML = disks.map((disk) => {
if (disk.error) { if (disk.error) {
@@ -565,6 +667,9 @@ async function refreshOps(options = {}) {
diskStatus.textContent = 'Lade...'; diskStatus.textContent = 'Lade...';
watchtowerStatus.textContent = 'Lade...'; watchtowerStatus.textContent = 'Lade...';
containerStatus.textContent = 'Lade...'; containerStatus.textContent = 'Lade...';
if (servicesStatusBoard) {
servicesStatusBoard.textContent = 'Lade...';
}
} }
try { try {
@@ -574,10 +679,14 @@ async function refreshOps(options = {}) {
renderDisks(status.disks); renderDisks(status.disks);
renderWatchtower(status.watchtower); renderWatchtower(status.watchtower);
renderContainers(status.containers); renderContainers(status.containers);
renderServicesStatus(status.services);
} catch (error) { } catch (error) {
diskStatus.textContent = `Statusproxy nicht erreichbar: ${error.message}`; diskStatus.textContent = `Statusproxy nicht erreichbar: ${error.message}`;
watchtowerStatus.textContent = 'Nicht verfuegbar.'; watchtowerStatus.textContent = 'Nicht verfuegbar.';
containerStatus.textContent = 'Nicht verfuegbar.'; containerStatus.textContent = 'Nicht verfuegbar.';
if (servicesStatusBoard) {
servicesStatusBoard.textContent = `Nicht verfuegbar: ${error.message}`;
}
} }
} }
@@ -644,6 +753,9 @@ resetLocalButton.addEventListener('click', () => {
downloadConfigButton.addEventListener('click', downloadConfig); downloadConfigButton.addEventListener('click', downloadConfig);
refreshOpsButton.addEventListener('click', refreshOps); refreshOpsButton.addEventListener('click', refreshOps);
if (refreshServicesButton) {
refreshServicesButton.addEventListener('click', () => refreshOps());
}
runWatchtowerButton.addEventListener('click', runWatchtowerOnce); runWatchtowerButton.addEventListener('click', runWatchtowerOnce);
dangerModeButton.addEventListener('click', unlockDangerMode); dangerModeButton.addEventListener('click', unlockDangerMode);

141
style.css
View File

@@ -909,3 +909,144 @@ main {
bottom: -10px; bottom: -10px;
} }
} }
/* ==========================================================================
Dienste Status Board (Live-Proxy)
========================================================================== */
.services-status-board {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
margin-top: 6px;
}
.status-board-item {
display: flex;
flex-direction: column;
padding: 13px 14px;
background: rgba(14, 19, 31, 0.45);
border: 1px solid var(--border);
border-radius: var(--radius);
transition: all 0.22s cubic-bezier(0.165, 0.84, 0.44, 1);
position: relative;
overflow: hidden;
}
.status-board-item:hover {
background: rgba(23, 29, 45, 0.65);
border-color: var(--border-strong);
transform: translateY(-2px);
}
.status-board-item-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 5px;
}
.status-board-item-header h4 {
margin: 0;
font-size: 0.92rem;
font-weight: 650;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-board-item-body {
display: flex;
flex-direction: column;
gap: 4px;
}
.status-board-item-domain {
font-size: 0.76rem;
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-board-item-info {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 6px;
font-size: 0.78rem;
}
.status-board-item-status {
font-weight: 650;
font-size: 0.78rem;
}
.status-board-item-latency {
font-size: 0.74rem;
color: var(--dim);
}
/* Status styles with glowing left bars */
.status-board-item.is-online {
border-color: rgba(52, 211, 153, 0.16);
background: rgba(16, 185, 129, 0.02);
}
.status-board-item.is-online .status-board-item-status {
color: var(--ok);
}
.status-board-item.is-online::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 3px;
height: 100%;
background: var(--ok);
box-shadow: 0 0 8px var(--ok);
}
.status-board-item.is-offline {
border-color: rgba(239, 68, 68, 0.3);
background: rgba(239, 68, 68, 0.05);
}
.status-board-item.is-offline .status-board-item-status {
color: var(--down);
}
.status-board-item.is-offline::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 3px;
height: 100%;
background: var(--down);
box-shadow: 0 0 8px var(--down);
}
.status-board-item.is-warning {
border-color: rgba(245, 158, 11, 0.25);
background: rgba(245, 158, 11, 0.04);
}
.status-board-item.is-warning .status-board-item-status {
color: var(--warn);
}
.status-board-item.is-warning::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 3px;
height: 100%;
background: var(--warn);
box-shadow: 0 0 8px var(--warn);
}
@media (max-width: 620px) {
.services-status-board {
grid-template-columns: 1fr;
gap: 8px;
}
}