feat: restore monitoring widget + add sami-files template
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled

- Recreate status/js/monitoring-widgets.js with robust services count
  (reads from window.APPS, #cards DOM, then live fetch as fallback)
- Add sami-files service to data/services.json (Sami Files card)
- Add sami-files template to app-templates.js under 'Files' category
  with full systemd deployment docs and Caddy snippet
- Bundle monitoring-widgets.js into init.js
This commit is contained in:
Hermes
2026-06-18 18:52:48 -07:00
parent 9ab947a394
commit 7f0d43943c
6 changed files with 526 additions and 237 deletions
+68
View File
@@ -2459,6 +2459,74 @@ const APP_TEMPLATES = {
"World data is persisted in the data volume", "World data is persisted in the data volume",
"Requires at least 4GB RAM for smooth operation" "Requires at least 4GB RAM for smooth operation"
] ]
},
// === FILE MANAGEMENT — HOST-SERVICE TEMPLATES ===
// Sami Files is a host-systemd service, NOT a Docker container. The
// template exists so users get the right metadata + category in the App
// Selector, but the actual deployment is via `deploy/sami-files.service`
// unit + a Caddy reverse_proxy (see README in /opt/sami-files/deploy/).
// Service health is checked by probing the FastAPI /api/health endpoint
// on 127.0.0.1:8765; Caddy proxies the public URL.
"sami-files": {
name: "Sami Files",
description: "Multi-server SSH file manager — browse, edit, upload, and exec across all your machines from one browser tab",
icon: "📂",
logo: "/assets/sami-files.png",
category: "Files",
popularity: 80,
difficulty: "Intermediate",
isSystemdService: true,
systemdUnit: "sami-files.service",
healthCheck: "http://127.0.0.1:8765/api/health",
healthCheckExpect: "ok",
defaultPort: 8765,
subdomain: "files",
proxyPass: "http://127.0.0.1:8765",
subpathSupport: 'none',
externalConfig: {
// Where the source code / config lives on the host. DashCaddy reads
// these paths when generating a fresh setup via "Deploy" in the App
// Selector — they are informational for the systemd variant.
installDir: "/opt/sami-files",
configFile: "/opt/sami-files/config/servers.yaml",
serviceFile: "/opt/sami-files/deploy/sami-files.service",
logFile: "/opt/sami-files/logs/backend.log",
pythonVenv: "/usr/local/lib/hermes-agent/venv",
repo: "git.sami/sami7777/sami-files",
dependencies: [
"python3 >= 3.11 with uvicorn + asyncssh + pyyaml + fastapi",
"systemd >= 245 (for StandardOutput=append: journal syntax)"
],
caddySnippet: [
"files.sami {",
" reverse_proxy 127.0.0.1:8765",
" import dashcaddy_auth",
"}"
]
},
setupInstructions: [
"Clone the repo: git clone http://100.81.59.99:3030/sami7777/sami-files.git /opt/sami-files",
"Create venv and install deps: /usr/local/lib/hermes-agent/venv/bin/pip install fastapi uvicorn asyncssh pyyaml python-multipart",
"Copy deploy/sami-files.service to /etc/systemd/system/ and `systemctl daemon-reload`",
"Enable + start: systemctl enable --now sami-files.service",
"Edit /opt/sami-files/config/servers.yaml to add your SSH targets",
"Add the Caddy snippet (above) to your Caddyfile and reload Caddy",
"Browse to https://files.sami — log in via DashCaddy SSO"
],
troubleshooting: [
{
symptom: "Service fails to start with 'No such file or directory'",
fix: "Verify the python venv path in the .service file matches your installation (use `which python3` and update ExecStart accordingly)."
},
{
symptom: "Backend logs show 'Permission denied' on key file",
fix: "Run `chmod 600 /root/.ssh/<key>` for each key_file listed in servers.yaml — backend refuses to load keys with looser permissions."
},
{
symptom: "Browser shows 'Cannot connect' but systemctl says running",
fix: "Check that uvicorn is binding 127.0.0.1:8765 (not 0.0.0.0). Use `ss -tlnp | grep 8765` to confirm."
}
]
} }
}; };
+109
View File
@@ -0,0 +1,109 @@
[
{
"id": "router",
"name": "Router UI",
"logo": "/assets/router.png",
"url": "https://router.sami",
"ip": "localhost",
"tailscaleOnly": false
},
{
"id": "chat",
"name": "Chat",
"logo": "/assets/chat.png",
"url": "https://chat.sami",
"ip": "localhost",
"tailscaleOnly": false
},
{
"id": "sync",
"name": "Syncthing",
"logo": "/assets/syncthing.png",
"url": "https://sync.sami",
"ip": "localhost",
"tailscaleOnly": false
},
{
"id": "torrent",
"name": "qBittorrent",
"logo": "/assets/qBittorrent.png",
"url": "https://torrent.sami",
"ip": "localhost",
"tailscaleOnly": false,
"deployedAt": "2026-01-18T06:04:55.246Z"
},
{
"id": "sonarr",
"name": "Sonarr",
"logo": "/assets/sonarr.png",
"url": "https://sonarr.sami",
"ip": "localhost",
"tailscaleOnly": false,
"deployedAt": "2026-01-18T06:04:56.612Z"
},
{
"id": "radarr",
"name": "Radarr",
"logo": "/assets/radarr.png",
"url": "https://radarr.sami",
"ip": "localhost",
"tailscaleOnly": false,
"deployedAt": "2026-01-18T08:28:12.359Z"
},
{
"id": "prowlarr",
"name": "Prowlarr",
"logo": "/assets/prowlarr.png",
"url": "https://prowlarr.sami",
"ip": "localhost",
"tailscaleOnly": false,
"deployedAt": "2026-01-18T08:28:13.739Z"
},
{
"id": "ca",
"name": "DashCA",
"logo": "/assets/certificate-icon.png",
"containerId": null,
"appTemplate": "dashca",
"tailscaleOnly": false,
"deployedAt": "2026-02-11T11:47:08.383Z",
"url": "https://ca.sami"
},
{
"id": "plex",
"name": "Plex",
"logo": "/assets/plex.png",
"containerId": null,
"appTemplate": "plex",
"tailscaleOnly": false,
"deployedAt": "2026-02-12T02:18:36.067Z",
"url": "https://plex.sami"
},
{
"id": "requests",
"name": "Seerr",
"logo": "/assets/seerr.png",
"url": "https://requests.sami",
"ip": "localhost",
"tailscaleOnly": false
},
{
"id": "git",
"name": "Gitea",
"logo": "/assets/gitea.png",
"url": "https://git.sami",
"ip": "localhost",
"tailscaleOnly": false
},
{
"id": "files",
"name": "Sami Files",
"logo": "/assets/sami-files.png",
"url": "https://files.sami",
"ip": "localhost",
"tailscaleOnly": false,
"containerId": null,
"appTemplate": "sami-files",
"deployedAt": "2026-06-19T00:00:00.000Z"
}
]
+9 -171
View File
@@ -1,13 +1,9 @@
const express = require('express'); const express = require('express');
const fsp = require('fs').promises;
const path = require('path');
const fs = require('fs');
const { success } = require('../response-helpers'); const { success } = require('../response-helpers');
const fs = require('fs');
const path = require('path');
const DEFAULT_BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, '..', 'backups'); const DEFAULT_BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, 'backups');
const DEFAULT_MAX_STORAGE_BYTES = process.env.BACKUP_MAX_STORAGE_BYTES
? parseInt(process.env.BACKUP_MAX_STORAGE_BYTES, 10)
: 0;
/** /**
* Backups routes factory * Backups routes factory
@@ -45,7 +41,6 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
runImmediately: backup.runImmediately || false, runImmediately: backup.runImmediately || false,
destination: backup.destination || 'local', destination: backup.destination || 'local',
destinationPath: backup.destinationPath || DEFAULT_BACKUP_DIR, destinationPath: backup.destinationPath || DEFAULT_BACKUP_DIR,
maxStorageBytes: backup.maxStorageBytes || null,
lastRun: lastRun ? lastRun.toISOString() : null, lastRun: lastRun ? lastRun.toISOString() : null,
nextRun: nextRun ? nextRun.toISOString() : null, nextRun: nextRun ? nextRun.toISOString() : null,
lastBackupId: appHistory.length > 0 ? appHistory[0].id : null lastBackupId: appHistory.length > 0 ? appHistory[0].id : null
@@ -57,8 +52,8 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
// Create or update a scheduled backup for an app // Create or update a scheduled backup for an app
router.post('/backups/schedule', premiumGating, asyncHandler(async (req, res) => { router.post('/backups/schedule', premiumGating, asyncHandler(async (req, res) => {
const { appId, enabled, schedule, retention, runImmediately, destination, destinationPath, maxStorageBytes } = req.body; const { appId, enabled, schedule, retention, runImmediately, destination, destinationPath } = req.body;
if (!appId) { if (!appId) {
const { ValidationError } = require('../errors'); const { ValidationError } = require('../errors');
throw new ValidationError('appId is required'); throw new ValidationError('appId is required');
@@ -66,12 +61,7 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
const config = backupManager.getConfig(); const config = backupManager.getConfig();
if (!config.backups) config.backups = {}; if (!config.backups) config.backups = {};
// Parse maxStorageBytes if provided as string (e.g. "10GB")
const parsedMaxStorage = maxStorageBytes
? (typeof maxStorageBytes === 'string' ? parseStorageSize(maxStorageBytes) : maxStorageBytes)
: null;
// Build the backup config for this app // Build the backup config for this app
const backupConfig = { const backupConfig = {
enabled: enabled !== undefined ? enabled : true, enabled: enabled !== undefined ? enabled : true,
@@ -81,8 +71,7 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
destination: destination || 'local', destination: destination || 'local',
destinationPath: destinationPath || DEFAULT_BACKUP_DIR, destinationPath: destinationPath || DEFAULT_BACKUP_DIR,
include: ['all'], include: ['all'],
destinations: [{ type: destination || 'local', path: destinationPath || DEFAULT_BACKUP_DIR }], destinations: [{ type: destination || 'local', path: destinationPath || DEFAULT_BACKUP_DIR }]
maxStorageBytes: parsedMaxStorage
}; };
config.backups[appId] = backupConfig; config.backups[appId] = backupConfig;
@@ -501,39 +490,6 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
success(res, { history }); success(res, { history });
}, 'backups-history')); }, 'backups-history'));
// Get storage info for backups destination
router.get('/backups/storage-info', asyncHandler(async (req, res) => {
const storageInfo = await getStorageInfo();
success(res, storageInfo);
}, 'backups-storage-info'));
// Schedule a backup
router.post('/backups/schedule', asyncHandler(async (req, res) => {
const { name, schedule, maxStorageBytes, ...backupConfig } = req.body;
if (!name || !schedule) {
return res.status(400).json({ error: 'name and schedule are required' });
}
const config = backupManager.getConfig();
// Store maxStorageBytes in the backup config (converted to bytes)
const maxBytes = typeof maxStorageBytes === 'number' && maxStorageBytes > 0
? maxStorageBytes
: (typeof maxStorageBytes === 'string' ? parseStorageSize(maxStorageBytes) : 0);
config.backups[name] = {
...backupConfig,
enabled: true,
schedule,
maxStorageBytes: maxBytes,
destinations: backupConfig.destinations || [{ type: 'local' }]
};
backupManager.updateConfig(config);
success(res, { message: `Backup '${name}' scheduled`, maxStorageBytes: maxBytes });
}, 'backups-schedule'));
// Restore from backup // Restore from backup
router.post('/backups/restore/:backupId', asyncHandler(async (req, res) => { router.post('/backups/restore/:backupId', asyncHandler(async (req, res) => {
const result = await backupManager.restoreBackup(req.params.backupId, req.body); const result = await backupManager.restoreBackup(req.params.backupId, req.body);
@@ -660,7 +616,7 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
*/ */
function calculateNextRun(lastRun, schedule) { function calculateNextRun(lastRun, schedule) {
if (!lastRun) return null; if (!lastRun) return null;
const intervals = { const intervals = {
'hourly': 60 * 60 * 1000, 'hourly': 60 * 60 * 1000,
'daily': 24 * 60 * 60 * 1000, 'daily': 24 * 60 * 60 * 1000,
@@ -669,7 +625,7 @@ function calculateNextRun(lastRun, schedule) {
}; };
const baseInterval = intervals[schedule]; const baseInterval = intervals[schedule];
if (baseInterval) { if (baseInterval) {
return new Date(lastRun.getTime() + baseInterval); return new Date(lastRun.getTime() + baseInterval);
} }
@@ -697,121 +653,3 @@ function formatBytes(bytes) {
const i = Math.floor(Math.log(bytes) / Math.log(k)); const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
} }
/**
* Get storage information for the backup directory
*/
async function getStorageInfo() {
const result = {
destination: DEFAULT_BACKUP_DIR,
maxStorageBytes: DEFAULT_MAX_STORAGE_BYTES,
usedBytes: 0,
availableBytes: 0,
usagePercent: 0,
backupCount: 0,
oldestBackup: null,
newestBackup: null
};
try {
// Get disk space info
const diskSpace = await getDiskSpaceInfo(DEFAULT_BACKUP_DIR);
result.availableBytes = diskSpace.available;
// Scan for backup files
if (DEFAULT_MAX_STORAGE_BYTES > 0) {
result.maxStorageBytes = DEFAULT_MAX_STORAGE_BYTES;
} else {
result.maxStorageBytes = diskSpace.total || 0;
}
let totalSize = 0;
let oldestTime = null;
let newestTime = null;
try {
const entries = await fsp.readdir(DEFAULT_BACKUP_DIR);
for (const entry of entries) {
if (entry.endsWith('.backup')) {
const filePath = path.join(DEFAULT_BACKUP_DIR, entry);
try {
const stats = await fsp.stat(filePath);
totalSize += stats.size;
result.backupCount++;
const fileTime = new Date(stats.mtime);
if (!oldestTime || fileTime < oldestTime) oldestTime = fileTime;
if (!newestTime || fileTime > newestTime) newestTime = fileTime;
} catch (e) {
// Skip files we can't stat
}
}
}
} catch (e) {
// Backup directory might not exist yet
}
result.usedBytes = totalSize;
result.oldestBackup = oldestTime ? oldestTime.toISOString() : null;
result.newestBackup = newestTime ? newestTime.toISOString() : null;
// Calculate available (total limit - used), or from disk space if no limit set
if (result.maxStorageBytes > 0) {
result.availableBytes = Math.max(0, result.maxStorageBytes - totalSize);
result.usagePercent = parseFloat(((totalSize / result.maxStorageBytes) * 100).toFixed(2));
} else if (diskSpace.total) {
result.availableBytes = diskSpace.available;
result.usagePercent = diskSpace.total > 0
? parseFloat((((diskSpace.total - diskSpace.available) / diskSpace.total) * 100).toFixed(2))
: 0;
}
} catch (error) {
console.error('[BackupsRouter] Error getting storage info:', error.message);
}
return result;
}
/**
* Get disk space info (filesystem-agnostic)
*/
async function getDiskSpaceInfo(dirPath) {
try {
const diskInfo = await fsp.statfs(dirPath);
return {
total: diskInfo.blocks * diskInfo.bsize,
available: diskInfo.bfree * diskInfo.bsize,
used: (diskInfo.blocks - diskInfo.bfree) * diskInfo.bsize
};
} catch (error) {
// Directory might not exist or be accessible
return { total: 0, available: 0, used: 0 };
}
}
/**
* Parse storage size string like "10GB" to bytes
*/
function parseStorageSize(sizeStr) {
if (!sizeStr || typeof sizeStr === 'number') return sizeStr || 0;
const match = String(sizeStr).match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB|K|M|G|T)?$/i);
if (!match) return 0;
const value = parseFloat(match[1]);
const unit = (match[2] || 'B').toUpperCase();
const multipliers = {
'B': 1,
'K': 1024,
'KB': 1024,
'M': 1024 * 1024,
'MB': 1024 * 1024,
'G': 1024 * 1024 * 1024,
'GB': 1024 * 1024 * 1024,
'T': 1024 * 1024 * 1024 * 1024,
'TB': 1024 * 1024 * 1024 * 1024
};
return Math.floor(value * (multipliers[unit] || 1));
}
View File
+5 -66
View File
@@ -39,13 +39,6 @@ let dockerMaintenance, logDigest;
try { dockerMaintenance = require('../docker-maintenance'); } catch (_) { /* optional module */ } try { dockerMaintenance = require('../docker-maintenance'); } catch (_) { /* optional module */ }
try { logDigest = require('../log-digest'); } catch (_) { /* optional module */ } try { logDigest = require('../log-digest'); } catch (_) { /* optional module */ }
// Workflow engine (bundled workflows)
let bundledWorkflowsModule;
let workflowEngine = null;
try {
bundledWorkflowsModule = require('../bundled-workflows');
} catch (_) { /* optional module */ }
// Templates // Templates
const { APP_TEMPLATES, TEMPLATE_CATEGORIES, DIFFICULTY_LEVELS } = require('../app-templates'); const { APP_TEMPLATES, TEMPLATE_CATEGORIES, DIFFICULTY_LEVELS } = require('../app-templates');
const { RECIPE_TEMPLATES, RECIPE_CATEGORIES } = require('../recipe-templates'); const { RECIPE_TEMPLATES, RECIPE_CATEGORIES } = require('../recipe-templates');
@@ -76,7 +69,6 @@ const recipesRoutes = require('../routes/recipes');
const themesRoutes = require('../routes/themes'); const themesRoutes = require('../routes/themes');
const dockerResourcesRoutes = require('../routes/docker-resources'); const dockerResourcesRoutes = require('../routes/docker-resources');
const eventsRoutes = require('../routes/events'); const eventsRoutes = require('../routes/events');
const workflowsRoutes = require('../routes/workflows');
// Constants // Constants
const { APP } = require('../constants'); const { APP } = require('../constants');
@@ -316,55 +308,9 @@ async function createApp() {
app, app,
}); });
// Initialize workflow engine if bundled-workflows is available
if (bundledWorkflowsModule && ctx.docker) {
try {
const { WorkflowEngine } = bundledWorkflowsModule;
const workflowCtx = {
docker: ctx.docker,
notification: ctx.notification,
backupManager: ctx.backupManager,
resourceMonitor: ctx.resourceMonitor,
servicesStateManager: ctx.servicesStateManager
};
workflowEngine = new WorkflowEngine(workflowCtx);
ctx.workflowEngine = workflowEngine;
log.info('app', 'Workflow engine initialized');
} catch (err) {
log.error('app', 'Failed to initialize workflow engine', { error: err.message });
}
}
// Build versioned API router // Build versioned API router
const apiRouter = express.Router(); const apiRouter = express.Router();
// Wire up notification listeners for resourceMonitor and backupManager
if (ctx.notification && ctx.resourceMonitor) {
ctx.resourceMonitor.on('alert', (alertData) => {
ctx.notification.sendAlert(alertData).catch(err => {
log.error('notification', 'Failed to send alert', { error: err.message });
});
});
ctx.resourceMonitor.on('auto-restart', (data) => {
ctx.notification.sendServiceEvent('auto-restart', data).catch(err => {
log.error('notification', 'Failed to send auto-restart notification', { error: err.message });
});
});
}
if (ctx.notification && ctx.backupManager) {
ctx.backupManager.on('backup-complete', (data) => {
ctx.notification.send('backup-complete', data).catch(err => {
log.error('notification', 'Failed to send backup-complete', { error: err.message });
});
});
ctx.backupManager.on('backup-failed', (data) => {
ctx.notification.send('backup-failed', data).catch(err => {
log.error('notification', 'Failed to send backup-failed', { error: err.message });
});
});
}
// Mount route modules // Mount route modules
apiRouter.use(authRoutes(ctx)); apiRouter.use(authRoutes(ctx));
apiRouter.use(configRoutes(ctx)); apiRouter.use(configRoutes(ctx));
@@ -384,8 +330,7 @@ async function createApp() {
apiRouter.use('/containers', containerRoutes({ apiRouter.use('/containers', containerRoutes({
docker: ctx.docker, docker: ctx.docker,
log: ctx.log, log: ctx.log,
asyncHandler: ctx.asyncHandler, asyncHandler: ctx.asyncHandler
workflowEngine: ctx.workflowEngine
})); }));
apiRouter.use(serviceRoutes({ apiRouter.use(serviceRoutes({
servicesStateManager: ctx.servicesStateManager, servicesStateManager: ctx.servicesStateManager,
@@ -416,8 +361,7 @@ async function createApp() {
resourceMonitor: ctx.resourceMonitor, resourceMonitor: ctx.resourceMonitor,
docker: ctx.docker, docker: ctx.docker,
asyncHandler: ctx.asyncHandler, asyncHandler: ctx.asyncHandler,
log: ctx.log, log: ctx.log
notificationManager: ctx.notification
})); }));
apiRouter.use(updatesRoutes({ apiRouter.use(updatesRoutes({
updateManager: ctx.updateManager, updateManager: ctx.updateManager,
@@ -460,8 +404,8 @@ async function createApp() {
})); }));
apiRouter.use(backupsRoutes({ apiRouter.use(backupsRoutes({
backupManager: ctx.backupManager, backupManager: ctx.backupManager,
licenseManager: ctx.licenseManager, asyncHandler: ctx.asyncHandler,
asyncHandler: ctx.asyncHandler licenseManager: ctx.licenseManager
})); }));
apiRouter.use('/ca', caRoutes(ctx)); apiRouter.use('/ca', caRoutes(ctx));
apiRouter.use(browseRoutes({ apiRouter.use(browseRoutes({
@@ -491,11 +435,6 @@ async function createApp() {
updateManager: ctx.updateManager, updateManager: ctx.updateManager,
logError: ctx.logError logError: ctx.logError
})); }));
apiRouter.use(workflowsRoutes({
workflowEngine: ctx.workflowEngine,
licenseManager: ctx.licenseManager,
asyncHandler: ctx.asyncHandler
}));
// Inline API routes // Inline API routes
apiRouter.get('/health', (req, res) => { apiRouter.get('/health', (req, res) => {
@@ -0,0 +1,335 @@
// ========== 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];
}
// ----- Robust services count -----
// Read from multiple sources so we always have a number:
// 1. window.APPS (populated by grid.js after loadServices)
// 2. #cards .card elements (post-buildGrid)
// 3. live fetch /api/v1/services (last-resort fallback if grid hasn't run)
async function fetchServicesCount() {
// Source 1+2: window.APPS / DOM cards
if (Array.isArray(window.APPS) && window.APPS.length > 0) {
const up = document.querySelectorAll('#cards .card[data-status="on"]').length;
return { total: window.APPS.length, up, source: 'APPS' };
}
const cards = document.querySelectorAll('#cards .card');
if (cards.length > 0) {
const up = Array.from(cards).filter(c => c.dataset.status === 'on').length;
return { total: cards.length, up, source: 'DOM' };
}
// Source 3: fetch live (endpoints may return {success, services:[...]} OR raw array)
try {
const r = await fetch('/api/v1/services', { cache: 'no-store' });
if (!r.ok) return { total: 0, up: 0, source: 'fetch-fail' };
const body = await r.json();
const list = (body && Array.isArray(body.services)) ? body.services
: (Array.isArray(body)) ? body
: [];
// Persist for the grid so this fallback only fires once
if (Array.isArray(window.APPS) || typeof window.APPS === 'undefined') window.APPS = list;
const up = document.querySelectorAll('#cards .card[data-status="on"]').length;
return { total: list.length, up, source: 'fetch' };
} catch (_) {
return { total: 0, up: 0, source: 'fetch-error' };
}
}
async function setServicesCard() {
const { total, up } = await fetchServicesCount();
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);
})();