feat: service categories end-to-end + monitoring widgets on main dashboard
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
This commit is contained in:
@@ -372,7 +372,7 @@ module.exports = function({
|
|||||||
// Add a new service
|
// Add a new service
|
||||||
router.post('/services', asyncHandler(async (req, res) => {
|
router.post('/services', asyncHandler(async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id, name, logo } = req.body;
|
const { id, name, logo, category, containerId, port, ip, tailscaleOnly } = req.body;
|
||||||
|
|
||||||
if (!id || !name) {
|
if (!id || !name) {
|
||||||
throw new ValidationError('id and name are required');
|
throw new ValidationError('id and name are required');
|
||||||
@@ -391,7 +391,14 @@ module.exports = function({
|
|||||||
throw new ConflictError(`Service "${id}" already exists`, id);
|
throw new ConflictError(`Service "${id}" already exists`, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
services.push({ id, name, logo: logo || `/assets/${id}.png` });
|
const newService = { id, name, logo: logo || `/assets/${id}.png` };
|
||||||
|
// Persist optional metadata fields if provided
|
||||||
|
if (category) newService.category = category;
|
||||||
|
if (containerId) newService.containerId = containerId;
|
||||||
|
if (port) newService.port = port;
|
||||||
|
if (ip) newService.ip = ip;
|
||||||
|
if (typeof tailscaleOnly === 'boolean') newService.tailscaleOnly = tailscaleOnly;
|
||||||
|
services.push(newService);
|
||||||
return services;
|
return services;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -542,6 +549,8 @@ module.exports = function({
|
|||||||
};
|
};
|
||||||
if (name) services[serviceIndex].name = name;
|
if (name) services[serviceIndex].name = name;
|
||||||
if (logo) services[serviceIndex].logo = logo;
|
if (logo) services[serviceIndex].logo = logo;
|
||||||
|
// Allow category update via update endpoint too (optional body field)
|
||||||
|
if (req.body.category !== undefined) services[serviceIndex].category = req.body.category || undefined;
|
||||||
results.services = 'updated';
|
results.services = 'updated';
|
||||||
} else {
|
} else {
|
||||||
results.services = 'not found';
|
results.services = 'not found';
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ const bundles = {
|
|||||||
],
|
],
|
||||||
'init.js': [
|
'init.js': [
|
||||||
JS('core', 'init.js'),
|
JS('core', 'init.js'),
|
||||||
|
JS('monitoring-widgets.js'),
|
||||||
JS('keyboard-shortcuts.js'),
|
JS('keyboard-shortcuts.js'),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
Vendored
+114
-87
File diff suppressed because one or more lines are too long
Vendored
+307
-232
File diff suppressed because one or more lines are too long
Vendored
+129
-18
File diff suppressed because one or more lines are too long
@@ -256,6 +256,9 @@
|
|||||||
<option value="on">🟢 Online</option>
|
<option value="on">🟢 Online</option>
|
||||||
<option value="off">🔴 Offline</option>
|
<option value="off">🔴 Offline</option>
|
||||||
</select>
|
</select>
|
||||||
|
<select id="service-filter-category" style="padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 0.9rem;">
|
||||||
|
<option value="all">All Categories</option>
|
||||||
|
</select>
|
||||||
<button id="batch-operations-btn" class="btn-sm" style="padding: 8px 12px;">☰ Batch Operations</button>
|
<button id="batch-operations-btn" class="btn-sm" style="padding: 8px 12px;">☰ Batch Operations</button>
|
||||||
<span id="service-filter-count" style="color: var(--muted); font-size: 0.85rem; white-space: nowrap;"></span>
|
<span id="service-filter-count" style="color: var(--muted); font-size: 0.85rem; white-space: nowrap;"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -95,6 +95,8 @@
|
|||||||
const card = el('div', 'card');
|
const card = el('div', 'card');
|
||||||
card.setAttribute('data-app', s.id);
|
card.setAttribute('data-app', s.id);
|
||||||
card.setAttribute('data-status', 'off'); // Initial status
|
card.setAttribute('data-status', 'off'); // Initial status
|
||||||
|
if (s.containerId) card.setAttribute('data-container-id', s.containerId);
|
||||||
|
if (s.category) card.setAttribute('data-category', s.category);
|
||||||
if (s.recipeId) card.setAttribute('data-recipe-id', s.recipeId);
|
if (s.recipeId) card.setAttribute('data-recipe-id', s.recipeId);
|
||||||
|
|
||||||
const dot = el('span', 'dot bad at-bl'); dot.id = 'dot-' + s.id + '-grid'; card.appendChild(dot);
|
const dot = el('span', 'dot bad at-bl'); dot.id = 'dot-' + s.id + '-grid'; card.appendChild(dot);
|
||||||
@@ -156,6 +158,16 @@
|
|||||||
nameSpan.appendChild(tsBadge);
|
nameSpan.appendChild(tsBadge);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add Category badge if service has one (colored pill with icon)
|
||||||
|
if (s.category) {
|
||||||
|
const cats = (typeof DC !== 'undefined' && DC.CATEGORIES) || window.DC_CATEGORIES || {};
|
||||||
|
const catInfo = cats[s.category] || {};
|
||||||
|
const catBadge = el('span', 'cat-badge', `${catInfo.icon || ''} ${s.category}`.trim());
|
||||||
|
catBadge.title = `Category: ${s.category}`;
|
||||||
|
catBadge.style.cssText = `margin-left: 6px; font-size: 0.65rem; padding: 1px 6px; border-radius: 999px; background: color-mix(in srgb, ${catInfo.color || '#7f8c8d'} 25%, transparent); color: ${catInfo.color || '#7f8c8d'}; border: 1px solid color-mix(in srgb, ${catInfo.color || '#7f8c8d'} 50%, transparent); white-space: nowrap; font-weight: 500;`;
|
||||||
|
nameSpan.appendChild(catBadge);
|
||||||
|
}
|
||||||
|
|
||||||
row.appendChild(el('span', 'spacer'));
|
row.appendChild(el('span', 'spacer'));
|
||||||
|
|
||||||
const pill = el('span', 'badge off', 'OFF'); pill.id = 'badge-' + s.id; row.appendChild(pill);
|
const pill = el('span', 'badge off', 'OFF'); pill.id = 'badge-' + s.id; row.appendChild(pill);
|
||||||
@@ -282,6 +294,9 @@
|
|||||||
|
|
||||||
// Group recipe cards visually after grid is built
|
// Group recipe cards visually after grid is built
|
||||||
if (window.groupRecipeCards) requestAnimationFrame(() => window.groupRecipeCards());
|
if (window.groupRecipeCards) requestAnimationFrame(() => window.groupRecipeCards());
|
||||||
|
|
||||||
|
// Refresh the service filter so the category dropdown reflects new services
|
||||||
|
if (window.refreshServiceFilter) window.refreshServiceFilter();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setBadge(id, up, responseTime = null) {
|
function setBadge(id, up, responseTime = null) {
|
||||||
|
|||||||
@@ -59,11 +59,13 @@
|
|||||||
}
|
}
|
||||||
_dashboardInitialized = true;
|
_dashboardInitialized = true;
|
||||||
await window.loadServices();
|
await window.loadServices();
|
||||||
|
await loadTemplateCategories();
|
||||||
window.buildGrid();
|
window.buildGrid();
|
||||||
animateTopCards();
|
animateTopCards();
|
||||||
window.refreshAll();
|
window.refreshAll();
|
||||||
setInterval(window.refreshAll, DC.POLL.DASHBOARD);
|
setInterval(window.refreshAll, DC.POLL.DASHBOARD);
|
||||||
if (typeof window.refreshCredsButtons === 'function') window.refreshCredsButtons();
|
if (typeof window.refreshCredsButtons === 'function') window.refreshCredsButtons();
|
||||||
|
if (typeof window.refreshMonitoringWidgets === 'function') window.refreshMonitoringWidgets();
|
||||||
// Update auth card (may have already been updated by the auto-load IIFE but ensure it's correct)
|
// Update auth card (may have already been updated by the auto-load IIFE but ensure it's correct)
|
||||||
if (typeof window._updateAuthCard === 'function') {
|
if (typeof window._updateAuthCard === 'function') {
|
||||||
try {
|
try {
|
||||||
@@ -200,6 +202,55 @@
|
|||||||
window.loadCustomServices = loadCustomServices;
|
window.loadCustomServices = loadCustomServices;
|
||||||
registerServiceWorker();
|
registerServiceWorker();
|
||||||
|
|
||||||
|
// ===== TEMPLATE CATEGORIES =====
|
||||||
|
// Cached template categories from /api/v1/templates for use across the UI
|
||||||
|
// (service create/edit, filter dropdown, category badges, etc.)
|
||||||
|
async function loadTemplateCategories() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/v1/templates', { cache: 'no-store' });
|
||||||
|
if (!r.ok) return;
|
||||||
|
const data = await r.json();
|
||||||
|
if (data && data.categories) {
|
||||||
|
window.DC_CATEGORIES = data.categories;
|
||||||
|
// Also expose via globals.js constant for convenience
|
||||||
|
if (typeof DC !== 'undefined') DC.CATEGORIES = data.categories;
|
||||||
|
// Populate any category <select> that's already in the DOM
|
||||||
|
populateCategorySelects();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[init] Failed to load template categories:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateCategorySelects() {
|
||||||
|
const cats = window.DC_CATEGORIES || (typeof DC !== 'undefined' && DC.CATEGORIES);
|
||||||
|
if (!cats) return;
|
||||||
|
document.querySelectorAll('select[data-role="service-category"]').forEach(select => {
|
||||||
|
const current = select.dataset.current || '';
|
||||||
|
// Clear options but keep the first (placeholder)
|
||||||
|
const placeholder = select.querySelector('option[value=""]');
|
||||||
|
select.innerHTML = '';
|
||||||
|
if (placeholder) select.appendChild(placeholder);
|
||||||
|
else {
|
||||||
|
const ph = document.createElement('option');
|
||||||
|
ph.value = '';
|
||||||
|
ph.textContent = '— Select category —';
|
||||||
|
select.appendChild(ph);
|
||||||
|
}
|
||||||
|
Object.entries(cats).forEach(([name, info]) => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = name;
|
||||||
|
opt.textContent = `${info.icon || ''} ${name}`.trim();
|
||||||
|
if (name === current) opt.selected = true;
|
||||||
|
select.appendChild(opt);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow other modules to re-run population after they (re)inject selects
|
||||||
|
window.populateCategorySelects = populateCategorySelects;
|
||||||
|
window.loadTemplateCategories = loadTemplateCategories;
|
||||||
|
|
||||||
// TOTP-gated initialization
|
// TOTP-gated initialization
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -262,6 +262,7 @@
|
|||||||
const proxyIp = document.getElementById('external-proxy-ip').value.trim() || SITE.dnsIp || 'localhost';
|
const proxyIp = document.getElementById('external-proxy-ip').value.trim() || SITE.dnsIp || 'localhost';
|
||||||
const preserveHost = document.getElementById('external-preserve-host').checked;
|
const preserveHost = document.getElementById('external-preserve-host').checked;
|
||||||
const followRedirects = document.getElementById('external-follow-redirects').checked;
|
const followRedirects = document.getElementById('external-follow-redirects').checked;
|
||||||
|
const category = document.getElementById('external-service-category')?.value || '';
|
||||||
|
|
||||||
if (!name || !externalUrl) {
|
if (!name || !externalUrl) {
|
||||||
showNotification('Please fill in Name and External URL', 'warning');
|
showNotification('Please fill in Name and External URL', 'warning');
|
||||||
@@ -341,6 +342,8 @@
|
|||||||
isExternal: true,
|
isExternal: true,
|
||||||
isCustom: true
|
isCustom: true
|
||||||
};
|
};
|
||||||
|
// Only attach category if user actually picked one
|
||||||
|
if (category) newService.category = category;
|
||||||
|
|
||||||
window.APPS.push(newService);
|
window.APPS.push(newService);
|
||||||
results.dashboard = true;
|
results.dashboard = true;
|
||||||
@@ -457,6 +460,13 @@
|
|||||||
const healthCheck = document.getElementById('health-check-input')?.value || '';
|
const healthCheck = document.getElementById('health-check-input')?.value || '';
|
||||||
const timeout = document.getElementById('timeout-input')?.value || 30;
|
const timeout = document.getElementById('timeout-input')?.value || 30;
|
||||||
|
|
||||||
|
// Category is optional — pulled from either local or external select by the
|
||||||
|
// openAddServiceModal reset. If user doesn't choose one, it stays undefined
|
||||||
|
// and we don't send it (so the backend keeps the existing behavior).
|
||||||
|
const categoryEl = document.getElementById('service-category-input')
|
||||||
|
|| document.getElementById('external-service-category');
|
||||||
|
const category = categoryEl?.value || '';
|
||||||
|
|
||||||
const dnsToken = window.getToken(getPrimaryDnsId(), 'admin');
|
const dnsToken = window.getToken(getPrimaryDnsId(), 'admin');
|
||||||
|
|
||||||
if (!name || !port || !ip) {
|
if (!name || !port || !ip) {
|
||||||
@@ -525,6 +535,8 @@
|
|||||||
logo: logo || `/assets/${subdomain}.png`,
|
logo: logo || `/assets/${subdomain}.png`,
|
||||||
tailscaleOnly: tailscaleOnly || false
|
tailscaleOnly: tailscaleOnly || false
|
||||||
};
|
};
|
||||||
|
// Only include category if user actually picked one
|
||||||
|
if (category) serviceConfig.category = category;
|
||||||
|
|
||||||
await window.addServiceToConfig(serviceConfig);
|
await window.addServiceToConfig(serviceConfig);
|
||||||
results.dashboard = true;
|
results.dashboard = true;
|
||||||
|
|||||||
@@ -19,6 +19,16 @@
|
|||||||
document.getElementById('edit-tailscale-only').checked = service.tailscaleOnly || false;
|
document.getElementById('edit-tailscale-only').checked = service.tailscaleOnly || false;
|
||||||
document.getElementById('edit-logo-url').value = service.logo || '';
|
document.getElementById('edit-logo-url').value = service.logo || '';
|
||||||
|
|
||||||
|
// Populate the category select for this service, then set the current value.
|
||||||
|
// populateCategorySelects() uses data-current so we set it first, then call.
|
||||||
|
const categorySelect = document.getElementById('edit-service-category');
|
||||||
|
if (categorySelect) {
|
||||||
|
categorySelect.dataset.current = service.category || '';
|
||||||
|
if (typeof window.populateCategorySelects === 'function') {
|
||||||
|
window.populateCategorySelects();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
modal.classList.add('show');
|
modal.classList.add('show');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +46,7 @@
|
|||||||
const newIp = document.getElementById('edit-ip').value.trim() || 'localhost';
|
const newIp = document.getElementById('edit-ip').value.trim() || 'localhost';
|
||||||
const tailscaleOnly = document.getElementById('edit-tailscale-only').checked;
|
const tailscaleOnly = document.getElementById('edit-tailscale-only').checked;
|
||||||
const newLogo = document.getElementById('edit-logo-url').value.trim();
|
const newLogo = document.getElementById('edit-logo-url').value.trim();
|
||||||
|
const newCategory = document.getElementById('edit-service-category')?.value || '';
|
||||||
|
|
||||||
if (!newSubdomain) {
|
if (!newSubdomain) {
|
||||||
showNotification('Subdomain is required', 'warning');
|
showNotification('Subdomain is required', 'warning');
|
||||||
@@ -51,6 +62,7 @@
|
|||||||
if (newIp !== currentEditService.ip) changes.push('ip');
|
if (newIp !== currentEditService.ip) changes.push('ip');
|
||||||
if (tailscaleOnly !== (currentEditService.tailscaleOnly || false)) changes.push('tailscale');
|
if (tailscaleOnly !== (currentEditService.tailscaleOnly || false)) changes.push('tailscale');
|
||||||
if (newLogo && newLogo !== currentEditService.logo) changes.push('logo');
|
if (newLogo && newLogo !== currentEditService.logo) changes.push('logo');
|
||||||
|
if (newCategory !== (currentEditService.category || '')) changes.push('category');
|
||||||
|
|
||||||
if (changes.length === 0) {
|
if (changes.length === 0) {
|
||||||
closeServiceEditModal();
|
closeServiceEditModal();
|
||||||
@@ -72,7 +84,8 @@
|
|||||||
port: newPort || currentEditService.port,
|
port: newPort || currentEditService.port,
|
||||||
ip: newIp,
|
ip: newIp,
|
||||||
tailscaleOnly,
|
tailscaleOnly,
|
||||||
logo: newLogo || undefined
|
logo: newLogo || undefined,
|
||||||
|
category: newCategory
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -91,7 +104,8 @@
|
|||||||
port: newPort || window.APPS[appIndex].port,
|
port: newPort || window.APPS[appIndex].port,
|
||||||
ip: newIp,
|
ip: newIp,
|
||||||
tailscaleOnly,
|
tailscaleOnly,
|
||||||
logo: newLogo || window.APPS[appIndex].logo
|
logo: newLogo || window.APPS[appIndex].logo,
|
||||||
|
category: newCategory || undefined
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -187,6 +187,9 @@
|
|||||||
name: serviceConfig.name,
|
name: serviceConfig.name,
|
||||||
logo: serviceConfig.logo || `/assets/${serviceConfig.subdomain}.png`
|
logo: serviceConfig.logo || `/assets/${serviceConfig.subdomain}.png`
|
||||||
};
|
};
|
||||||
|
// Forward optional metadata fields if provided
|
||||||
|
if (serviceConfig.category) newService.category = serviceConfig.category;
|
||||||
|
if (serviceConfig.containerId) newService.containerId = serviceConfig.containerId;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await secureFetch('/api/v1/services', {
|
const response = await secureFetch('/api/v1/services', {
|
||||||
|
|||||||
@@ -82,6 +82,16 @@
|
|||||||
Enter a URL or upload an image file (PNG, JPG, SVG)
|
Enter a URL or upload an image file (PNG, JPG, SVG)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Category -->
|
||||||
|
<div>
|
||||||
|
<label for="edit-service-category" class="form-label-accent-sm">
|
||||||
|
Category
|
||||||
|
</label>
|
||||||
|
<select id="edit-service-category" data-role="service-category" class="form-input-md">
|
||||||
|
<option value="">— No category —</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="weather-modal-buttons" style="margin-top: 24px;">
|
<div class="weather-modal-buttons" style="margin-top: 24px;">
|
||||||
@@ -239,6 +249,15 @@
|
|||||||
Reload Caddy after adding
|
Reload Caddy after adding
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<!-- Category -->
|
||||||
|
<div>
|
||||||
|
<label for="service-category-input" style="font-size: 0.8rem; color: var(--muted); margin-bottom: 4px; display: block;">Category</label>
|
||||||
|
<select id="service-category-input" data-role="service-category" style="width: 100%;">
|
||||||
|
<option value="">— No category —</option>
|
||||||
|
</select>
|
||||||
|
<div style="font-size: 0.7rem; color: var(--muted); margin-top: 3px;">Group services on the dashboard by purpose (Media, Productivity, etc.)</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<hr style="border: none; border-top: 1px solid var(--border); margin: 4px 0;" />
|
<hr style="border: none; border-top: 1px solid var(--border); margin: 4px 0;" />
|
||||||
|
|
||||||
<div class="grid-2col">
|
<div class="grid-2col">
|
||||||
@@ -326,6 +345,14 @@
|
|||||||
Follow Redirects
|
Follow Redirects
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<!-- Category (external) -->
|
||||||
|
<div>
|
||||||
|
<label for="external-service-category" style="font-size: 0.8rem; color: var(--muted); margin-bottom: 4px; display: block;">Category</label>
|
||||||
|
<select id="external-service-category" data-role="service-category" style="width: 100%;">
|
||||||
|
<option value="">— No category —</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,304 @@
|
|||||||
|
// ========== 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);
|
||||||
|
|
||||||
|
})();
|
||||||
@@ -2,11 +2,50 @@
|
|||||||
(function() {
|
(function() {
|
||||||
const searchInput = document.getElementById('service-filter-search');
|
const searchInput = document.getElementById('service-filter-search');
|
||||||
const statusSelect = document.getElementById('service-filter-status');
|
const statusSelect = document.getElementById('service-filter-status');
|
||||||
|
const categorySelect = document.getElementById('service-filter-category');
|
||||||
const countSpan = document.getElementById('service-filter-count');
|
const countSpan = document.getElementById('service-filter-count');
|
||||||
|
|
||||||
|
// Build a single category list from both the API categories and any
|
||||||
|
// categories present on the actual rendered cards (covers custom services
|
||||||
|
// whose category isn't in TEMPLATE_CATEGORIES).
|
||||||
|
function getCategoryList() {
|
||||||
|
const seen = new Set();
|
||||||
|
const fromCards = new Set();
|
||||||
|
document.querySelectorAll('#cards .card[data-category]').forEach(c => {
|
||||||
|
const cat = c.dataset.category.trim();
|
||||||
|
if (cat) fromCards.add(cat);
|
||||||
|
});
|
||||||
|
const apiCats = (window.DC_CATEGORIES || (typeof DC !== 'undefined' && DC.CATEGORIES)) || {};
|
||||||
|
const all = Object.keys(apiCats).concat([...fromCards].filter(c => !apiCats[c]));
|
||||||
|
all.forEach(c => seen.add(c));
|
||||||
|
return { list: [...seen], apiCats };
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshCategoryDropdown() {
|
||||||
|
if (!categorySelect) return;
|
||||||
|
const { list, apiCats } = getCategoryList();
|
||||||
|
const current = categorySelect.value;
|
||||||
|
categorySelect.innerHTML = '<option value="all">All Categories</option>';
|
||||||
|
list.sort().forEach(name => {
|
||||||
|
const info = apiCats[name];
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = name;
|
||||||
|
opt.textContent = info ? `${info.icon || ''} ${name}`.trim() : name;
|
||||||
|
categorySelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
// Restore selection if it still exists
|
||||||
|
if (current && [...categorySelect.options].some(o => o.value === current)) {
|
||||||
|
categorySelect.value = current;
|
||||||
|
} else {
|
||||||
|
categorySelect.value = 'all';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function updateFilter() {
|
function updateFilter() {
|
||||||
|
refreshCategoryDropdown();
|
||||||
const query = searchInput.value.toLowerCase().trim();
|
const query = searchInput.value.toLowerCase().trim();
|
||||||
const statusFilter = statusSelect.value; // 'all', 'on', or 'off'
|
const statusFilter = statusSelect.value; // 'all', 'on', or 'off'
|
||||||
|
const categoryFilter = categorySelect ? categorySelect.value : 'all';
|
||||||
|
|
||||||
const cards = document.querySelectorAll('#cards .card');
|
const cards = document.querySelectorAll('#cards .card');
|
||||||
let visibleCount = 0;
|
let visibleCount = 0;
|
||||||
@@ -15,11 +54,13 @@
|
|||||||
const name = card.querySelector('.name')?.textContent?.toLowerCase() || '';
|
const name = card.querySelector('.name')?.textContent?.toLowerCase() || '';
|
||||||
const app = card.dataset.app?.toLowerCase() || '';
|
const app = card.dataset.app?.toLowerCase() || '';
|
||||||
const status = card.dataset.status || 'off'; // 'on' or 'off'
|
const status = card.dataset.status || 'off'; // 'on' or 'off'
|
||||||
|
const category = card.dataset.category || '';
|
||||||
|
|
||||||
const matchesSearch = !query || name.includes(query) || app.includes(query);
|
const matchesSearch = !query || name.includes(query) || app.includes(query);
|
||||||
const matchesStatus = statusFilter === 'all' || status === statusFilter;
|
const matchesStatus = statusFilter === 'all' || status === statusFilter;
|
||||||
|
const matchesCategory = categoryFilter === 'all' || category === categoryFilter;
|
||||||
|
|
||||||
if (matchesSearch && matchesStatus) {
|
if (matchesSearch && matchesStatus && matchesCategory) {
|
||||||
card.style.display = '';
|
card.style.display = '';
|
||||||
visibleCount++;
|
visibleCount++;
|
||||||
} else {
|
} else {
|
||||||
@@ -44,6 +85,7 @@
|
|||||||
|
|
||||||
searchInput?.addEventListener('input', debounce(updateFilter, 200));
|
searchInput?.addEventListener('input', debounce(updateFilter, 200));
|
||||||
statusSelect?.addEventListener('change', updateFilter);
|
statusSelect?.addEventListener('change', updateFilter);
|
||||||
|
categorySelect?.addEventListener('change', updateFilter);
|
||||||
|
|
||||||
// Initial count on page load
|
// Initial count on page load
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
@@ -52,6 +94,7 @@
|
|||||||
setTimeout(updateFilter, 500);
|
setTimeout(updateFilter, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expose for external triggers
|
// Expose for external triggers (called after buildGrid to repopulate categories)
|
||||||
window.refreshServiceFilter = updateFilter;
|
window.refreshServiceFilter = updateFilter;
|
||||||
|
window.refreshCategoryDropdown = refreshCategoryDropdown;
|
||||||
})();
|
})();
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'dashcaddy-shell-8ef9c82616';
|
const CACHE = 'dashcaddy-shell-43a872cc40';
|
||||||
const PRECACHE = [
|
const PRECACHE = [
|
||||||
'/',
|
'/',
|
||||||
'/index.html',
|
'/index.html',
|
||||||
|
|||||||
Reference in New Issue
Block a user