Service categories (described in README roadmap, never wired): - Backend: POST /services now persists category/containerId/port/ip/tailscaleOnly - Backend: POST /services/update accepts category for in-place changes - Frontend: category <select> in add-service modal (local + external) - Frontend: category <select> in edit-service modal with current value - Frontend: All Categories dropdown in service filter bar (auto-populated from both API categories and any categories present on rendered cards) - Frontend: colored category badge (icon + name) on service cards - Frontend: filter auto-refreshes after buildGrid Monitoring on main dashboard (replaces orphaned monitoring-dashboard.html): - New monitoring-widgets.js embeds a 5-card System Overview panel above the filter bar: Services, Containers Up, Avg CPU, Avg Memory, Health - Pulls /api/v1/monitoring/stats + /api/v1/health-checks/status - Auto-refreshes on DC.POLL.STATS (5s), color-coded bars (warn >=65%, bad >=85%) Build: - Added monitoring-widgets.js to init.js bundle in build.js - Rebuilt dist/ bundles (core.js, features.js, init.js) - sw.js cache version bumped automatically - CSP hash regenerated
305 lines
10 KiB
JavaScript
305 lines
10 KiB
JavaScript
// ========== MONITORING WIDGETS ==========
|
|
// Embeds a compact system-resource + health summary panel directly on the
|
|
// main dashboard. Replaces the need for a separate monitoring-dashboard.html
|
|
// page — quick at-a-glance stats where you already are.
|
|
(function () {
|
|
|
|
// ----- Style injection (scoped to .dc-monitor so it doesn't leak) -----
|
|
const styleEl = document.createElement('style');
|
|
styleEl.textContent = `
|
|
.dc-monitor {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
gap: 12px;
|
|
margin-bottom: 16px;
|
|
padding: 12px 16px;
|
|
background: var(--card-base);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
}
|
|
.dc-monitor-card {
|
|
padding: 10px 12px;
|
|
background: var(--card-bg, rgba(255,255,255,0.04));
|
|
border-radius: 8px;
|
|
border: 1px solid var(--border);
|
|
}
|
|
.dc-monitor-label {
|
|
font-size: 0.7rem;
|
|
color: var(--muted);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
margin-bottom: 4px;
|
|
}
|
|
.dc-monitor-value {
|
|
font-size: 1.4rem;
|
|
font-weight: 600;
|
|
color: var(--fg);
|
|
}
|
|
.dc-monitor-sub {
|
|
font-size: 0.7rem;
|
|
color: var(--muted);
|
|
margin-top: 4px;
|
|
}
|
|
.dc-monitor-bar {
|
|
margin-top: 6px;
|
|
width: 100%;
|
|
height: 4px;
|
|
background: color-mix(in srgb, var(--muted) 20%, transparent);
|
|
border-radius: 2px;
|
|
overflow: hidden;
|
|
}
|
|
.dc-monitor-bar-fill {
|
|
height: 100%;
|
|
width: 0%;
|
|
background: var(--ok-fg, #27ae60);
|
|
transition: width 0.3s ease, background 0.3s ease;
|
|
}
|
|
.dc-monitor-bar-fill.warn { background: #f39c12; }
|
|
.dc-monitor-bar-fill.bad { background: #e74c3c; }
|
|
.dc-monitor-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin-bottom: 8px;
|
|
}
|
|
.dc-monitor-title {
|
|
font-size: 0.85rem;
|
|
font-weight: 500;
|
|
color: var(--muted);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
.dc-monitor-pill {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
padding: 2px 8px;
|
|
border-radius: 999px;
|
|
font-size: 0.7rem;
|
|
font-weight: 500;
|
|
}
|
|
.dc-monitor-pill.ok { background: color-mix(in srgb, #27ae60 20%, transparent); color: #27ae60; }
|
|
.dc-monitor-pill.warn { background: color-mix(in srgb, #f39c12 20%, transparent); color: #f39c12; }
|
|
.dc-monitor-pill.bad { background: color-mix(in srgb, #e74c3c 20%, transparent); color: #e74c3c; }
|
|
.dc-monitor-refresh {
|
|
font-size: 0.7rem;
|
|
color: var(--muted);
|
|
opacity: 0.7;
|
|
}
|
|
`;
|
|
document.head.appendChild(styleEl);
|
|
|
|
// ----- Container element (inserted above service-filter-bar) -----
|
|
const filterBar = document.getElementById('service-filter-bar');
|
|
if (!filterBar) return;
|
|
|
|
const panel = document.createElement('div');
|
|
panel.className = 'dc-monitor';
|
|
panel.id = 'dc-monitor-panel';
|
|
panel.innerHTML = `
|
|
<div class="dc-monitor-header" style="grid-column: 1 / -1;">
|
|
<div class="dc-monitor-title">📊 System Overview</div>
|
|
<span class="dc-monitor-refresh" id="dc-monitor-refresh-stamp">—</span>
|
|
</div>
|
|
<div class="dc-monitor-card">
|
|
<div class="dc-monitor-label">Services</div>
|
|
<div class="dc-monitor-value" id="dc-monitor-services">—</div>
|
|
<div class="dc-monitor-sub" id="dc-monitor-services-sub">loading…</div>
|
|
</div>
|
|
<div class="dc-monitor-card">
|
|
<div class="dc-monitor-label">Containers Up</div>
|
|
<div class="dc-monitor-value" id="dc-monitor-containers">—</div>
|
|
<div class="dc-monitor-sub" id="dc-monitor-containers-sub">loading…</div>
|
|
</div>
|
|
<div class="dc-monitor-card">
|
|
<div class="dc-monitor-label">Avg CPU</div>
|
|
<div class="dc-monitor-value" id="dc-monitor-cpu">—</div>
|
|
<div class="dc-monitor-bar"><div class="dc-monitor-bar-fill" id="dc-monitor-cpu-bar"></div></div>
|
|
</div>
|
|
<div class="dc-monitor-card">
|
|
<div class="dc-monitor-label">Avg Memory</div>
|
|
<div class="dc-monitor-value" id="dc-monitor-mem">—</div>
|
|
<div class="dc-monitor-bar"><div class="dc-monitor-bar-fill" id="dc-monitor-mem-bar"></div></div>
|
|
</div>
|
|
<div class="dc-monitor-card">
|
|
<div class="dc-monitor-label">Health</div>
|
|
<div class="dc-monitor-value" id="dc-monitor-health">—</div>
|
|
<div class="dc-monitor-sub" id="dc-monitor-health-sub">—</div>
|
|
</div>
|
|
`;
|
|
// Insert ABOVE the filter bar
|
|
filterBar.parentNode.insertBefore(panel, filterBar);
|
|
|
|
// ----- Helpers -----
|
|
function setBar(id, pct) {
|
|
const el = document.getElementById(id);
|
|
if (!el) return;
|
|
const p = Math.max(0, Math.min(100, Number(pct) || 0));
|
|
el.style.width = p + '%';
|
|
el.classList.remove('warn', 'bad');
|
|
if (p >= 85) el.classList.add('bad');
|
|
else if (p >= 65) el.classList.add('warn');
|
|
}
|
|
|
|
function fmtPct(v) {
|
|
if (v == null || isNaN(v)) return '—';
|
|
return (Math.round(v * 10) / 10) + '%';
|
|
}
|
|
|
|
function fmtBytes(b) {
|
|
if (b == null || isNaN(b)) return '—';
|
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
let i = 0;
|
|
while (b >= 1024 && i < units.length - 1) { b /= 1024; i++; }
|
|
return b.toFixed(1) + ' ' + units[i];
|
|
}
|
|
|
|
function setServicesCard() {
|
|
const total = (window.APPS || []).length;
|
|
let up = 0;
|
|
document.querySelectorAll('#cards .card').forEach(c => {
|
|
if (c.dataset.status === 'on') up++;
|
|
});
|
|
const el = document.getElementById('dc-monitor-services');
|
|
const sub = document.getElementById('dc-monitor-services-sub');
|
|
if (el) el.textContent = `${up} / ${total}`;
|
|
if (sub) sub.textContent = total === 0 ? 'no services yet' : `${up} online · ${total - up} offline`;
|
|
}
|
|
|
|
function applyHealthSummary(data) {
|
|
const el = document.getElementById('dc-monitor-health');
|
|
const sub = document.getElementById('dc-monitor-health-sub');
|
|
if (!el) return;
|
|
if (!data || data.summary == null) {
|
|
el.textContent = '—';
|
|
if (sub) sub.textContent = 'no data';
|
|
return;
|
|
}
|
|
const s = data.summary;
|
|
const healthy = s.healthy ?? s.up ?? 0;
|
|
const unhealthy = s.unhealthy ?? s.down ?? 0;
|
|
const total = s.total ?? (healthy + unhealthy);
|
|
el.textContent = `${healthy}/${total}`;
|
|
if (sub) {
|
|
if (unhealthy === 0) {
|
|
sub.innerHTML = '<span class="dc-monitor-pill ok">● all healthy</span>';
|
|
} else if (unhealthy <= 2) {
|
|
sub.innerHTML = `<span class="dc-monitor-pill warn">● ${unhealthy} degraded</span>`;
|
|
} else {
|
|
sub.innerHTML = `<span class="dc-monitor-pill bad">● ${unhealthy} down</span>`;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ----- Data fetches -----
|
|
async function fetchStats() {
|
|
try {
|
|
const r = await fetch('/api/v1/monitoring/stats', { cache: 'no-store' });
|
|
if (!r.ok) return null;
|
|
const data = await r.json();
|
|
return (data && data.stats) ? data.stats : null;
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function fetchHealth() {
|
|
try {
|
|
const r = await fetch('/api/v1/health-checks/status', { cache: 'no-store' });
|
|
if (!r.ok) return null;
|
|
return await r.json();
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function applyStats(stats) {
|
|
const containers = document.getElementById('dc-monitor-containers');
|
|
const containersSub = document.getElementById('dc-monitor-containers-sub');
|
|
const cpuEl = document.getElementById('dc-monitor-cpu');
|
|
const memEl = document.getElementById('dc-monitor-mem');
|
|
|
|
if (!stats) {
|
|
if (containers) containers.textContent = '—';
|
|
if (cpuEl) cpuEl.textContent = '—';
|
|
if (memEl) memEl.textContent = '—';
|
|
return;
|
|
}
|
|
|
|
const entries = Object.values(stats);
|
|
if (entries.length === 0) {
|
|
if (containers) containers.textContent = '0';
|
|
if (containersSub) containersSub.textContent = 'no containers reporting';
|
|
if (cpuEl) cpuEl.textContent = '0%';
|
|
if (memEl) memEl.textContent = '0%';
|
|
setBar('dc-monitor-cpu-bar', 0);
|
|
setBar('dc-monitor-mem-bar', 0);
|
|
return;
|
|
}
|
|
|
|
let cpuSum = 0, memSum = 0, memBytes = 0, cpuCount = 0, memCount = 0;
|
|
entries.forEach(s => {
|
|
// CPU may be percentage (0-100) or fraction (0-1) — handle both
|
|
if (s.cpu != null) {
|
|
const cpu = Number(s.cpu);
|
|
if (!isNaN(cpu)) {
|
|
cpuSum += cpu > 1 ? cpu : cpu * 100;
|
|
cpuCount++;
|
|
}
|
|
}
|
|
if (s.memory != null) {
|
|
const mem = Number(s.memory);
|
|
if (!isNaN(mem)) {
|
|
memSum += mem;
|
|
memBytes += Number(s.memoryUsage || 0);
|
|
memCount++;
|
|
}
|
|
}
|
|
});
|
|
|
|
const avgCpu = cpuCount ? cpuSum / cpuCount : 0;
|
|
const avgMem = memCount ? memSum / memCount : 0;
|
|
|
|
if (containers) containers.textContent = String(entries.length);
|
|
if (containersSub) {
|
|
const memTxt = memBytes ? ` · ${fmtBytes(memBytes)} RAM` : '';
|
|
containersSub.textContent = `running${memTxt}`;
|
|
}
|
|
if (cpuEl) cpuEl.textContent = fmtPct(avgCpu);
|
|
if (memEl) memEl.textContent = fmtPct(avgMem);
|
|
setBar('dc-monitor-cpu-bar', avgCpu);
|
|
setBar('dc-monitor-mem-bar', avgMem);
|
|
}
|
|
|
|
// ----- Public refresh function -----
|
|
let inFlight = false;
|
|
async function refresh() {
|
|
if (inFlight) return;
|
|
inFlight = true;
|
|
try {
|
|
setServicesCard();
|
|
const [stats, health] = await Promise.all([fetchStats(), fetchHealth()]);
|
|
applyStats(stats);
|
|
applyHealthSummary(health);
|
|
const stamp = document.getElementById('dc-monitor-refresh-stamp');
|
|
if (stamp) {
|
|
const now = new Date();
|
|
stamp.textContent = `updated ${now.toLocaleTimeString()}`;
|
|
}
|
|
} finally {
|
|
inFlight = false;
|
|
}
|
|
}
|
|
|
|
// Expose for init.js to call once and re-call after each refreshAll cycle
|
|
window.refreshMonitoringWidgets = refresh;
|
|
|
|
// Auto-refresh on the STATS interval (separate from full DASHBOARD refresh)
|
|
setInterval(refresh, (typeof DC !== 'undefined' && DC.POLL && DC.POLL.STATS) || 5000);
|
|
|
|
// Refresh once on first script load (init.js also calls this; double-call is harmless)
|
|
setTimeout(refresh, 200);
|
|
|
|
})();
|