diff --git a/dashcaddy-api/app-templates.js b/dashcaddy-api/app-templates.js index e5b13b8..4597047 100644 --- a/dashcaddy-api/app-templates.js +++ b/dashcaddy-api/app-templates.js @@ -2459,6 +2459,74 @@ const APP_TEMPLATES = { "World data is persisted in the data volume", "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/` 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." + } + ] } }; diff --git a/dashcaddy-api/data/services.json b/dashcaddy-api/data/services.json new file mode 100644 index 0000000..3526823 --- /dev/null +++ b/dashcaddy-api/data/services.json @@ -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" + } +] \ No newline at end of file diff --git a/dashcaddy-api/routes/backups.js b/dashcaddy-api/routes/backups.js index d89d59c..a2e7a00 100644 --- a/dashcaddy-api/routes/backups.js +++ b/dashcaddy-api/routes/backups.js @@ -1,13 +1,9 @@ const express = require('express'); -const fsp = require('fs').promises; -const path = require('path'); -const fs = require('fs'); 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_MAX_STORAGE_BYTES = process.env.BACKUP_MAX_STORAGE_BYTES - ? parseInt(process.env.BACKUP_MAX_STORAGE_BYTES, 10) - : 0; +const DEFAULT_BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, 'backups'); /** * Backups routes factory @@ -45,7 +41,6 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) { runImmediately: backup.runImmediately || false, destination: backup.destination || 'local', destinationPath: backup.destinationPath || DEFAULT_BACKUP_DIR, - maxStorageBytes: backup.maxStorageBytes || null, lastRun: lastRun ? lastRun.toISOString() : null, nextRun: nextRun ? nextRun.toISOString() : 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 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) { const { ValidationError } = require('../errors'); throw new ValidationError('appId is required'); @@ -66,12 +61,7 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) { const config = backupManager.getConfig(); 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 const backupConfig = { enabled: enabled !== undefined ? enabled : true, @@ -81,8 +71,7 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) { destination: destination || 'local', destinationPath: destinationPath || DEFAULT_BACKUP_DIR, include: ['all'], - destinations: [{ type: destination || 'local', path: destinationPath || DEFAULT_BACKUP_DIR }], - maxStorageBytes: parsedMaxStorage + destinations: [{ type: destination || 'local', path: destinationPath || DEFAULT_BACKUP_DIR }] }; config.backups[appId] = backupConfig; @@ -501,39 +490,6 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) { success(res, { 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 router.post('/backups/restore/:backupId', asyncHandler(async (req, res) => { const result = await backupManager.restoreBackup(req.params.backupId, req.body); @@ -660,7 +616,7 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) { */ function calculateNextRun(lastRun, schedule) { if (!lastRun) return null; - + const intervals = { 'hourly': 60 * 60 * 1000, 'daily': 24 * 60 * 60 * 1000, @@ -669,7 +625,7 @@ function calculateNextRun(lastRun, schedule) { }; const baseInterval = intervals[schedule]; - + if (baseInterval) { return new Date(lastRun.getTime() + baseInterval); } @@ -697,121 +653,3 @@ function formatBytes(bytes) { const i = Math.floor(Math.log(bytes) / Math.log(k)); 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)); -} diff --git a/dashcaddy-api/scripts/dashcaddy-update.sh b/dashcaddy-api/scripts/dashcaddy-update.sh old mode 100644 new mode 100755 diff --git a/dashcaddy-api/src/app.js b/dashcaddy-api/src/app.js index 82ecf7e..93dc650 100644 --- a/dashcaddy-api/src/app.js +++ b/dashcaddy-api/src/app.js @@ -39,13 +39,6 @@ let dockerMaintenance, logDigest; try { dockerMaintenance = require('../docker-maintenance'); } 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 const { APP_TEMPLATES, TEMPLATE_CATEGORIES, DIFFICULTY_LEVELS } = require('../app-templates'); const { RECIPE_TEMPLATES, RECIPE_CATEGORIES } = require('../recipe-templates'); @@ -76,7 +69,6 @@ const recipesRoutes = require('../routes/recipes'); const themesRoutes = require('../routes/themes'); const dockerResourcesRoutes = require('../routes/docker-resources'); const eventsRoutes = require('../routes/events'); -const workflowsRoutes = require('../routes/workflows'); // Constants const { APP } = require('../constants'); @@ -316,55 +308,9 @@ async function createApp() { 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 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 apiRouter.use(authRoutes(ctx)); apiRouter.use(configRoutes(ctx)); @@ -384,8 +330,7 @@ async function createApp() { apiRouter.use('/containers', containerRoutes({ docker: ctx.docker, log: ctx.log, - asyncHandler: ctx.asyncHandler, - workflowEngine: ctx.workflowEngine + asyncHandler: ctx.asyncHandler })); apiRouter.use(serviceRoutes({ servicesStateManager: ctx.servicesStateManager, @@ -416,8 +361,7 @@ async function createApp() { resourceMonitor: ctx.resourceMonitor, docker: ctx.docker, asyncHandler: ctx.asyncHandler, - log: ctx.log, - notificationManager: ctx.notification + log: ctx.log })); apiRouter.use(updatesRoutes({ updateManager: ctx.updateManager, @@ -460,8 +404,8 @@ async function createApp() { })); apiRouter.use(backupsRoutes({ backupManager: ctx.backupManager, - licenseManager: ctx.licenseManager, - asyncHandler: ctx.asyncHandler + asyncHandler: ctx.asyncHandler, + licenseManager: ctx.licenseManager })); apiRouter.use('/ca', caRoutes(ctx)); apiRouter.use(browseRoutes({ @@ -491,11 +435,6 @@ async function createApp() { updateManager: ctx.updateManager, logError: ctx.logError })); - apiRouter.use(workflowsRoutes({ - workflowEngine: ctx.workflowEngine, - licenseManager: ctx.licenseManager, - asyncHandler: ctx.asyncHandler - })); // Inline API routes apiRouter.get('/health', (req, res) => { diff --git a/dashcaddy-api/status/js/monitoring-widgets.js b/dashcaddy-api/status/js/monitoring-widgets.js new file mode 100644 index 0000000..60fc646 --- /dev/null +++ b/dashcaddy-api/status/js/monitoring-widgets.js @@ -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 = ` +
+
πŸ“Š System Overview
+ β€” +
+
+
Services
+
β€”
+
loading…
+
+
+
Containers Up
+
β€”
+
loading…
+
+
+
Avg CPU
+
β€”
+
+
+
+
Avg Memory
+
β€”
+
+
+
+
Health
+
β€”
+
β€”
+
+ `; + // 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 = '● all healthy'; + } else if (unhealthy <= 2) { + sub.innerHTML = `● ${unhealthy} degraded`; + } else { + sub.innerHTML = `● ${unhealthy} down`; + } + } + } + + // ----- 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); + +})();