feat: add consolidated Übersicht (Overview) dashboard tab with real-time mini-cards
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 13s

This commit is contained in:
Kroonk
2026-05-21 18:41:11 +02:00
parent 32bed2d65e
commit 6ff401ad74
3 changed files with 292 additions and 3 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=23"> <link rel="stylesheet" href="/style.css?v=24">
</head> </head>
<body> <body>
<header class="shell hero admin-hero"> <header class="shell hero admin-hero">
@@ -48,6 +48,41 @@
</div> </div>
<div id="adminUser" style="display: none;"></div> <div id="adminUser" style="display: none;"></div>
<!-- Dashboard Overview Tab -->
<div class="admin-tab-content" id="card-overview">
<div class="overview-grid">
<div class="overview-mini-card" data-target-tab="services">
<div class="mini-card-head">
<span class="mini-card-icon">🌐</span>
<h4>Dienste-Status</h4>
</div>
<div class="mini-card-body" id="mini-services-status">Lade...</div>
</div>
<div class="overview-mini-card" data-target-tab="disks">
<div class="mini-card-head">
<span class="mini-card-icon">💾</span>
<h4>NAS Speicher</h4>
</div>
<div class="mini-card-body" id="mini-disks-status">Lade...</div>
</div>
<div class="overview-mini-card" data-target-tab="watchtower">
<div class="mini-card-head">
<span class="mini-card-icon">🐳</span>
<h4>Watchtower</h4>
</div>
<div class="mini-card-body" id="mini-watchtower-status">Lade...</div>
</div>
<div class="overview-mini-card" data-target-tab="containers">
<div class="mini-card-head">
<span class="mini-card-icon">📦</span>
<h4>Container</h4>
</div>
<div class="mini-card-body" id="mini-containers-status">Lade...</div>
</div>
</div>
</div>
<div class="ops-grid" id="opsGrid"> <div class="ops-grid" id="opsGrid">
<section class="ops-card ops-card-wide admin-tab-content" id="card-services"> <section class="ops-card ops-card-wide admin-tab-content" id="card-services">
<div class="ops-card-head"> <div class="ops-card-head">
@@ -109,6 +144,10 @@
<button class="drawer-close-btn" id="drawerCloseBtn" aria-label="Menü schließen" type="button">&times;</button> <button class="drawer-close-btn" id="drawerCloseBtn" aria-label="Menü schließen" type="button">&times;</button>
</div> </div>
<nav class="drawer-nav"> <nav class="drawer-nav">
<button class="drawer-nav-item" data-tab="overview" type="button">
<span class="drawer-nav-icon">📊</span>
<span class="drawer-nav-text">Übersicht</span>
</button>
<button class="drawer-nav-item" data-tab="services" type="button"> <button class="drawer-nav-item" data-tab="services" type="button">
<span class="drawer-nav-icon">🌐</span> <span class="drawer-nav-icon">🌐</span>
<span class="drawer-nav-text">Dienste-Status</span> <span class="drawer-nav-text">Dienste-Status</span>
@@ -143,6 +182,6 @@
</nav> </nav>
</aside> </aside>
<script src="/admin.js?v=19" defer></script> <script src="/admin.js?v=20" defer></script>
</body> </body>
</html> </html>

View File

@@ -394,6 +394,21 @@ function renderServicesStatus(services = []) {
</a> </a>
`; `;
}).join(''); }).join('');
const miniServices = document.getElementById('mini-services-status');
if (miniServices) {
let ledHtml = itemsToRender.map(item => {
const ok = item.status.ok;
return `<span class="mini-led ${ok ? 'mini-led-ok' : 'mini-led-down'}" title="${item.name}: ${ok ? 'Online' : 'Offline'}"></span>`;
}).join('');
miniServices.innerHTML = `
<div class="mini-status-text">
${offlineCount > 0 ? `<span style="color: var(--down); font-weight: 700;">${offlineCount} offline</span>` : `<span style="color: var(--ok); font-weight: 700;">Alle online</span>`}
<span class="mini-status-total">(${totalCount} Dienste)</span>
</div>
<div class="mini-led-grid">${ledHtml}</div>
`;
}
} }
function renderDisks(disks = []) { function renderDisks(disks = []) {
@@ -416,6 +431,21 @@ function renderDisks(disks = []) {
</div> </div>
`; `;
}).join(''); }).join('');
const miniDisks = document.getElementById('mini-disks-status');
if (miniDisks) {
miniDisks.innerHTML = disks.map((disk) => {
if (disk.error) return `<div class="mini-disk-row is-error"><strong>${disk.label}</strong>: Fehler</div>`;
return `
<div class="mini-disk-row">
<div class="mini-disk-meta"><strong>${disk.label}</strong><span>${disk.usedPercent}%</span></div>
<div class="usage-bar mini-bar" data-level="${disk.usedPercent >= 90 ? 'danger' : disk.usedPercent >= 80 ? 'warn' : 'ok'}">
<span style="width: ${disk.usedPercent}%"></span>
</div>
</div>
`;
}).join('');
}
} }
function renderWatchtower(watchtower) { function renderWatchtower(watchtower) {
@@ -437,6 +467,23 @@ function renderWatchtower(watchtower) {
<div class="ops-line"><strong>Status aktualisiert</strong><span>${statusGenerated}</span></div> <div class="ops-line"><strong>Status aktualisiert</strong><span>${statusGenerated}</span></div>
`; `;
const miniWatchtower = document.getElementById('mini-watchtower-status');
if (miniWatchtower) {
let statusText = 'Keine Daten';
let subText = '';
if (lastManualWatchtowerRun) {
statusText = lastManualWatchtowerRun.running ? 'Läuft manuell...' : 'Manuell beendet';
subText = lastManualWatchtowerRun.startedAt ? new Date(lastManualWatchtowerRun.startedAt).toLocaleTimeString('de-DE') : '';
} else if (officialSession) {
statusText = 'Up-to-date';
subText = `Lauf: ${officialSession.finishedAt ? new Date(officialSession.finishedAt).toLocaleDateString('de-DE') : '--'}`;
}
miniWatchtower.innerHTML = `
<div class="mini-status-text"><span style="color: #67e8f9; font-weight: 700;">${statusText}</span></div>
<div class="mini-status-sub">${subText}</div>
`;
}
if (lastManualWatchtowerRun) { if (lastManualWatchtowerRun) {
const run = lastManualWatchtowerRun; const run = lastManualWatchtowerRun;
const session = run.session; const session = run.session;
@@ -498,6 +545,32 @@ function renderContainers(containers) {
containerStatus.querySelectorAll('.restart-container').forEach((button) => { containerStatus.querySelectorAll('.restart-container').forEach((button) => {
button.addEventListener('click', () => restartContainer(button.closest('.container-line'))); button.addEventListener('click', () => restartContainer(button.closest('.container-line')));
}); });
const miniContainers = document.getElementById('mini-containers-status');
if (miniContainers && Array.isArray(containers)) {
const importantList = [
'mischlabs',
'watchtower',
'gitea',
'gitea-runner',
'auth',
'nextcloud_app',
'vaultwarden',
'photography-website',
'michess',
'calibre-web',
'audiobookshelf'
];
const activeImportant = containers.filter(c => importantList.includes(c.name) && c.state === 'running').length;
const totalImportant = containers.filter(c => importantList.includes(c.name)).length;
miniContainers.innerHTML = `
<div class="mini-status-text">
<span style="color: var(--ok); font-weight: 700;">${activeImportant} / ${totalImportant}</span>
<span class="mini-status-total">online</span>
</div>
<div class="mini-status-sub">${containers.length} Container insgesamt</div>
`;
}
} }
async function restartContainer(row) { async function restartContainer(row) {
@@ -746,13 +819,14 @@ function initCollapsibleCards() {
}); });
} }
let activeTab = localStorage.getItem('active_admin_tab') || 'services'; let activeTab = localStorage.getItem('active_admin_tab') || 'overview';
function switchTab(tabId) { function switchTab(tabId) {
activeTab = tabId; activeTab = tabId;
localStorage.setItem('active_admin_tab', tabId); localStorage.setItem('active_admin_tab', tabId);
const tabCards = { const tabCards = {
overview: document.getElementById('card-overview'),
services: document.getElementById('card-services'), services: document.getElementById('card-services'),
disks: document.getElementById('card-disks'), disks: document.getElementById('card-disks'),
watchtower: document.getElementById('card-watchtower'), watchtower: document.getElementById('card-watchtower'),
@@ -807,6 +881,13 @@ function initTabNavigation() {
}); });
}); });
// Setup click handlers for mini cards inside the Overview tab
document.querySelectorAll('.overview-mini-card[data-target-tab]').forEach((card) => {
card.addEventListener('click', () => {
switchTab(card.dataset.targetTab);
});
});
switchTab(activeTab); switchTab(activeTab);
} }

169
style.css
View File

@@ -1268,3 +1268,172 @@ main {
transform: translateY(0); transform: translateY(0);
} }
} }
/* ==========================================================================
Dashboard Overview Cards & Grid
========================================================================== */
.overview-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
margin-bottom: 24px;
}
@media (max-width: 620px) {
.overview-grid {
grid-template-columns: 1fr;
}
}
.overview-mini-card {
background: rgba(17, 22, 36, 0.45);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
display: flex;
flex-direction: column;
gap: 14px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
.overview-mini-card:hover {
background: rgba(23, 29, 45, 0.65);
border-color: rgba(103, 232, 249, 0.3);
transform: translateY(-3px);
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.35), 0 0 15px rgba(103, 232, 249, 0.08);
}
.mini-card-head {
display: flex;
align-items: center;
gap: 10px;
}
.mini-card-icon {
font-size: 1.3rem;
}
.mini-card-head h4 {
margin: 0;
font-size: 1.05rem;
font-weight: 700;
color: #fff;
letter-spacing: -0.01em;
}
.mini-card-body {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 0.92rem;
color: var(--text);
}
.mini-status-text {
display: flex;
align-items: baseline;
gap: 6px;
}
.mini-status-total {
color: var(--dim);
font-size: 0.8rem;
}
.mini-status-sub {
font-size: 0.8rem;
color: var(--dim);
margin-top: 2px;
}
/* LEDs for Services */
.mini-led-grid {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 6px;
}
.mini-led {
width: 9px;
height: 9px;
border-radius: 50%;
display: inline-block;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.mini-led:hover {
transform: scale(1.35);
}
.mini-led-ok {
background-color: var(--ok);
box-shadow: 0 0 6px rgba(52, 211, 153, 0.4);
}
.mini-led-down {
background-color: var(--down);
box-shadow: 0 0 6px rgba(251, 113, 133, 0.4);
}
/* Disks Mini Rows */
.mini-disk-row {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 2px;
}
.mini-disk-row:last-child {
margin-bottom: 0;
}
.mini-disk-row.is-error {
color: var(--down);
font-weight: 500;
}
.mini-disk-meta {
display: flex;
justify-content: space-between;
font-size: 0.82rem;
}
.mini-disk-meta strong {
color: #fff;
font-weight: 600;
}
/* Slim progress bars in Mini overview */
.usage-bar.mini-bar {
height: 5px;
background: rgba(255, 255, 255, 0.05);
border-radius: 99px;
overflow: hidden;
position: relative;
width: 100%;
}
.usage-bar.mini-bar span {
display: block;
height: 100%;
border-radius: 99px;
transition: width 0.4s ease;
}
.usage-bar.mini-bar[data-level="ok"] span {
background-color: var(--ok);
box-shadow: 0 0 4px rgba(52, 211, 153, 0.3);
}
.usage-bar.mini-bar[data-level="warn"] span {
background-color: var(--warn);
box-shadow: 0 0 4px rgba(251, 191, 36, 0.3);
}
.usage-bar.mini-bar[data-level="danger"] span {
background-color: var(--down);
box-shadow: 0 0 4px rgba(251, 113, 133, 0.3);
}