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
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 11s
This commit is contained in:
15
admin.html
15
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=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
112
admin.js
@@ -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
141
style.css
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user