feat: restore monitoring widget + add sami-files template
- 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:
@@ -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."
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
|
|||||||
Regular → Executable
@@ -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);
|
||||||
|
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user