diff --git a/public/index.html b/public/index.html index bdfdeb0..09e5490 100644 --- a/public/index.html +++ b/public/index.html @@ -378,7 +378,7 @@

About

Environment - Node.js (Systemd) + Node.js (Systemd)
Server Timezone @@ -451,4 +451,4 @@

About

- \ No newline at end of file + diff --git a/public/js/dashboard.js b/public/js/dashboard.js index 2223904..4a1ea37 100644 --- a/public/js/dashboard.js +++ b/public/js/dashboard.js @@ -34,6 +34,23 @@ return res; } + function isUnsupportedMetric(data, key) { + return Array.isArray(data.unsupportedMonitoring) && data.unsupportedMonitoring.includes(key); + } + + async function readErrorMessage(res, fallbackMessage) { + try { + const data = await res.json(); + if (data && typeof data.error === 'string' && data.error.trim()) return data.error; + if (data && typeof data.message === 'string' && data.message.trim()) return data.message; + } catch (e) { } + return fallbackMessage; + } + + function setMetricValue(id, value, fallback = 'N/A') { + document.getElementById(id).innerText = value == null ? fallback : value; + } + // --- TAB MANAGEMENT --- let currentTab = 'home'; let cronInterval = null; @@ -169,17 +186,22 @@ const res = await fetchAuth(API + '/status'); const data = await res.json(); - document.getElementById('cpu-val').innerText = data.cpu + '%'; - document.getElementById('mem-val').innerText = data.mem + '%'; - if (data.disk) document.getElementById('disk-val').innerText = data.disk; + setMetricValue('cpu-val', isUnsupportedMetric(data, 'cpu') ? 'N/A' : (data.cpu == null ? '--%' : data.cpu + '%')); + setMetricValue('mem-val', isUnsupportedMetric(data, 'mem') ? 'N/A' : (data.mem == null ? '--%' : data.mem + '%')); + setMetricValue('disk-val', isUnsupportedMetric(data, 'disk') ? 'N/A' : (data.disk == null ? '--%' : data.disk)); if (data.timezone) document.getElementById('server-tz').innerText = data.timezone; + if (data.environment && typeof data.environment.isDocker === 'boolean') { + document.getElementById('runtime-env').innerText = data.environment.isDocker ? 'Docker Mode' : 'Node.js (Systemd)'; + } if (data.versions) { document.getElementById('ver-core').innerText = data.versions.core; document.getElementById('ver-num').innerText = 'v' + data.versions.dashboard; } // Update PID - if (data.gatewayPid) { + if (isUnsupportedMetric(data, 'gatewayPid')) { + document.getElementById('gateway-pid').innerText = 'N/A in Docker Mode'; + } else if (data.gatewayPid) { document.getElementById('gateway-pid').innerText = data.gatewayPid; } else { document.getElementById('gateway-pid').innerText = 'Stopped / Not Found'; @@ -187,7 +209,9 @@ // Update Scripts List const scriptList = document.getElementById('running-scripts-list'); - if (data.scripts && data.scripts.length > 0) { + if (isUnsupportedMetric(data, 'scripts')) { + scriptList.innerHTML = '
Unavailable in Docker Mode
'; + } else if (data.scripts && data.scripts.length > 0) { const items = data.scripts.map(s => `
${s.name} @@ -339,12 +363,18 @@ async function fetchJobs() { try { const res = await fetchAuth(API + '/cron'); - const jobs = await res.json(); + const data = await res.json(); + const jobs = Array.isArray(data) ? data : (Array.isArray(data.jobs) ? data.jobs : []); jobs.sort((a, b) => (b.state?.lastRunAtMs || 0) - (a.state?.lastRunAtMs || 0)); const container = document.getElementById('job-list'); container.innerHTML = ''; + if (data && data.dockerMode === true) { + container.innerHTML = '
Unavailable in Docker Mode
'; + return; + } + if (jobs.length === 0) { container.innerHTML = '
No jobs found
'; return; @@ -406,18 +436,28 @@ async function runJob(id) { if (!confirm('Execute task?')) return; - await fetchAuth(API + '/run/' + id, { method: 'POST' }); + const res = await fetchAuth(API + '/run/' + id, { method: 'POST' }); + if (!res.ok) { + alert(await readErrorMessage(res, 'Failed to run job.')); + return; + } setTimeout(fetchJobs, 2000); } async function killAll() { if (!confirm('⚠️ STOP ALL SCRIPTS?')) return; - await fetchAuth(API + '/kill', { method: 'POST' }); + const res = await fetchAuth(API + '/kill', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ confirm: true }) }); + if (!res.ok) { + alert(await readErrorMessage(res, 'Failed to stop scripts.')); + } } async function restartGateway() { if (!confirm('♻️ RESTART GATEWAY?')) return; - await fetchAuth(API + '/gateway/restart', { method: 'POST' }); + const res = await fetchAuth(API + '/gateway/restart', { method: 'POST' }); + if (!res.ok) { + alert(await readErrorMessage(res, 'Failed to restart gateway.')); + } } async function refreshTokenStats() { @@ -674,4 +714,3 @@ } return 0; } - diff --git a/src/config.js b/src/config.js index 9b4f5db..7523dfb 100644 --- a/src/config.js +++ b/src/config.js @@ -25,7 +25,8 @@ const TOKEN_FILE = path.join(APP_DIR, 'data/token_stats/latest.json'); const ID_FILE = path.join(APP_DIR, 'data/last_id.txt'); const ANALYZE_SCRIPT = path.join(APP_DIR, 'scripts/analyze.js'); -// --- Constants --- +// --- Constants & Environment Flags --- +const IS_DOCKER = fs.existsSync('/.dockerenv') || ['true', '1', 'yes'].includes((process.env.IS_DOCKER || '').toLowerCase()); const CACHE_TTL_MS = 60000; // 60s cache module.exports = { @@ -40,4 +41,5 @@ module.exports = { ID_FILE, ANALYZE_SCRIPT, CACHE_TTL_MS, + IS_DOCKER, }; diff --git a/src/routes/cron.js b/src/routes/cron.js index 46e580c..2229fe1 100644 --- a/src/routes/cron.js +++ b/src/routes/cron.js @@ -5,7 +5,7 @@ const router = require('express').Router(); const { exec, execFile } = require('child_process'); const path = require('path'); const fs = require('fs'); -const { HOME_DIR, STATE_DIR } = require('../config'); +const { HOME_DIR, STATE_DIR, IS_DOCKER } = require('../config'); const { getOpenClawCommand } = require('../services/openclaw'); router.get('/api/cron', (req, res) => { @@ -25,9 +25,11 @@ router.get('/api/cron', (req, res) => { // Silent fail, fallthrough to CLI } - // 2. SLOW PATH: CLI Fallback + // 2. SLOW PATH: CLI Fallback (n/a in Docker) + if (IS_DOCKER) return res.json({ dockerMode: true, jobs: [] }); + const cmd = `${getOpenClawCommand()} cron list --json`; - exec(cmd, { maxBuffer: 1024 * 1024 * 5 }, (err, stdout, stderr) => { + exec(cmd, { maxBuffer: 1024 * 1024 * 5 }, (err, stdout) => { try { const data = JSON.parse(stdout); if (data.jobs) return res.json(data.jobs); @@ -40,6 +42,9 @@ router.get('/api/cron', (req, res) => { router.post('/api/run/:id', (req, res) => { const id = req.params.id; + if (IS_DOCKER) { + return res.status(403).json({ error: 'Running cron jobs is not supported in Docker Mode. Please interact with the host CLI directly.' }); + } if (!/^[a-zA-Z0-9_-]+$/.test(id)) { return res.status(400).json({ error: 'Invalid job ID format' }); } diff --git a/src/routes/process.js b/src/routes/process.js index e47b3aa..65028a6 100644 --- a/src/routes/process.js +++ b/src/routes/process.js @@ -6,8 +6,12 @@ const { exec } = require('child_process'); const path = require('path'); const { rateLimit } = require('../utils/rateLimit'); const { getOpenClawCommand, WORKSPACE_DIR } = require('../services/openclaw'); +const { IS_DOCKER } = require('../config'); router.post('/api/kill', (req, res) => { + if (IS_DOCKER) { + return res.status(403).json({ error: 'Process termination is not supported in Docker Container Mode. Please restart the container or run host commands directly.' }); + } if (req.body?.confirm !== true) { return res.status(400).json({ error: 'Confirmation required. Send { "confirm": true } in request body.' }); } @@ -62,6 +66,9 @@ router.post('/api/kill', (req, res) => { }); router.post('/api/gateway/restart', (req, res) => { + if (IS_DOCKER) { + return res.status(403).json({ error: 'Gateway process restarts are not supported inside Docker containers. To restart the gateway, please restart the Docker container.' }); + } if (!rateLimit('gateway_restart', 10000)) { return res.status(429).json({ error: 'Please wait at least 10 seconds before retrying.' }); } diff --git a/src/services/monitor.js b/src/services/monitor.js index e65f100..bd1a3af 100644 --- a/src/services/monitor.js +++ b/src/services/monitor.js @@ -5,7 +5,7 @@ const { exec, execSync } = require('child_process'); const path = require('path'); const fs = require('fs'); const os = require('os'); -const { ID_FILE, APP_DIR } = require('../config'); +const { ID_FILE, APP_DIR, IS_DOCKER } = require('../config'); const { getOpenClawCommand } = require('./openclaw'); const { getActiveContext } = require('./context'); const { logActivity, checkFileChanges } = require('./activity'); @@ -27,6 +27,7 @@ try { let cachedVersions = null; let cachedVersionsTs = 0; const VERSION_CACHE_TTL = 5 * 60 * 1000; +const DOCKER_UNSUPPORTED_MONITORING = ['cpu', 'mem', 'disk', 'gatewayPid', 'scripts']; function getVersions() { if (cachedVersions && Date.now() - cachedVersionsTs < VERSION_CACHE_TTL) { @@ -43,20 +44,25 @@ function getVersions() { console.warn('[Versions] Failed to read dashboard package.json:', e.message); } - try { - const cmd = `${getOpenClawCommand()} --version`; - core = execSync(cmd, { timeout: 5000 }).toString().trim(); - } catch (e) { + if (IS_DOCKER) { + core = 'Managed by Docker'; + } else { try { - const nodeBinDir = path.dirname(process.execPath); - const globalModulesPath = path.join(nodeBinDir, '../lib/node_modules/openclaw/package.json'); - const pkg = JSON.parse(fs.readFileSync(globalModulesPath, 'utf8')); - core = `v${pkg.version}`; - } catch (e2) { - console.warn('[Versions] OpenClaw version detection failed:', e2.message); - core = 'Unknown'; + const cmd = `${getOpenClawCommand()} --version`; + core = execSync(cmd, { timeout: 5000 }).toString().trim(); + } catch (e) { + try { + const nodeBinDir = path.dirname(process.execPath); + const globalModulesPath = path.join(nodeBinDir, '../lib/node_modules/openclaw/package.json'); + const pkg = JSON.parse(fs.readFileSync(globalModulesPath, 'utf8')); + core = `v${pkg.version}`; + } catch (e2) { + console.warn('[Versions] OpenClaw version detection failed:', e2.message); + core = 'Unknown'; + } } } + cachedVersions = { dashboard, core }; cachedVersionsTs = Date.now(); return cachedVersions; @@ -65,122 +71,126 @@ function getVersions() { function checkSystemStatus(callback) { checkFileChanges(); - const osType = os.type(); - let mergedCmd = ''; - - if (osType === 'Darwin') { - mergedCmd = `echo "===DISK==="; df -h / | awk 'NR==2 {print $5}'; echo "===GWPID==="; pgrep -f '[o]penclaw.*gateway' | head -n 1 || true; echo "===PS==="; ps -Ao pid,pcpu,comm,args -r | head -n 21`; - } else { - mergedCmd = `echo "===DISK==="; df -h / | awk 'NR==2 {print $5}'; echo "===GWPID==="; pgrep -f '[o]penclaw.*gateway' | head -n 1 || true; echo "===PS==="; ps -eo pid,pcpu,comm,args --sort=-pcpu | head -n 20`; - } - - exec(mergedCmd, (err, stdout) => { - const sections = stdout ? stdout.split(/===\w+===\n?/) : []; - const diskUsage = sections[1] ? sections[1].trim() || '--%' : '--%'; - const gatewayPid = sections[2] ? sections[2].trim() || null : null; - const psOutput = sections[3] || ''; - { - { - if (err && !psOutput) return callback({ status: 'error', task: 'Monitor Error' }); - - const lines = psOutput.trim().split('\n').slice(1); - let activities = []; - let runningScripts = []; - let totalCpu = 0; - let topProc = null; - - lines.forEach((line, index) => { - const parts = line.trim().split(/\s+/); - const pid = parts[0]; - const cpu = parseFloat(parts[1]); - const comm = parts[2]; - const args = parts.slice(3).join(' '); - - if (!isNaN(cpu)) totalCpu += cpu; - if (index === 0) topProc = `${comm} (${Math.round(cpu)}%)`; - - if (comm === 'node' && args.includes('scripts/')) { - const script = args.match(/scripts\/([a-zA-Z0-9_.-]+)/)?.[1] || 'Script'; - activities.push(`📜 Running Script: ${script}`); - runningScripts.push({ pid, name: script }); - } - - if (['grep', 'find', 'curl', 'wget', 'git', 'tar', 'python', 'python3'].includes(comm)) { - let detail = args.split(' ').pop(); - if (comm === 'grep') detail = args.match(/"([^"]+)"/)?.[1] || detail; - if (detail && detail.length > 5000) detail = detail.substring(0, 5000) + '...'; - activities.push(`🔧 ${comm} ${detail}`); - } - }); - - activities = [...new Set(activities)]; - const ctx = getActiveContext(); - - let status = 'idle'; - let taskText = 'System Idle'; - - // 1. Agent Activity (Highest Priority) - if (ctx) { - status = 'busy'; - const currentId = String(ctx.id).trim(); - const lastId = String(lastProcessedId).trim(); - - if (currentId !== lastId) { - ctx.events.forEach(evt => logActivity(evt, currentId)); - lastProcessedId = currentId; - try { - fs.writeFileSync(ID_FILE, currentId, 'utf8'); - } catch (e) { - console.error('ID Save Failed:', e); - } - } - if (ctx.events && ctx.events.length > 0) { - taskText = ctx.events[ctx.events.length - 1]; - } - } + function finalizeStatus({ diskUsage = '--%', gatewayPid = null, psOutput = '', err = null }) { + if (err && !psOutput) return callback({ status: 'error', task: 'Monitor Error' }); + + const lines = psOutput.trim().split('\n').slice(1); + let activities = []; + let runningScripts = []; + let totalCpu = 0; + let topProc = null; + + lines.forEach((line, index) => { + const parts = line.trim().split(/\s+/); + const pid = parts[0]; + const cpu = parseFloat(parts[1]); + const comm = parts[2]; + const args = parts.slice(3).join(' '); + + if (!isNaN(cpu)) totalCpu += cpu; + if (index === 0) topProc = `${comm} (${Math.round(cpu)}%)`; + + if (comm === 'node' && args.includes('scripts/')) { + const script = args.match(/scripts\/([a-zA-Z0-9_.-]+)/)?.[1] || 'Script'; + activities.push(`📜 Running Script: ${script}`); + runningScripts.push({ pid, name: script }); + } - // 2. Background Scripts (Medium Priority) - if (activities.length > 0) { - status = 'busy'; - const activityText = activities.join(', '); - if (taskText === 'System Idle') taskText = activityText; - if (global.lastLoggedActivity !== activityText) { - logActivity(activityText); - global.lastLoggedActivity = activityText; - } - } else { - global.lastLoggedActivity = null; + if (['grep', 'find', 'curl', 'wget', 'git', 'tar', 'python', 'python3'].includes(comm)) { + let detail = args.split(' ').pop(); + if (comm === 'grep') detail = args.match(/"([^"]+)"/)?.[1] || detail; + if (detail && detail.length > 5000) detail = detail.substring(0, 5000) + '...'; + activities.push(`🔧 ${comm} ${detail}`); + } + }); + + activities = [...new Set(activities)]; + const ctx = getActiveContext(); + + let status = 'idle'; + let taskText = 'System Idle'; + + if (ctx) { + status = 'busy'; + const currentId = String(ctx.id).trim(); + const lastId = String(lastProcessedId).trim(); + + if (currentId !== lastId) { + ctx.events.forEach(evt => logActivity(evt, currentId)); + lastProcessedId = currentId; + try { + fs.writeFileSync(ID_FILE, currentId, 'utf8'); + } catch (e) { + console.error('ID Save Failed:', e); } + } + if (ctx.events && ctx.events.length > 0) { + taskText = ctx.events[ctx.events.length - 1]; + } + } - // 3. High CPU (Low Priority) - if (totalCpu > 70.0) { - status = 'busy'; - const cpuText = `⚡ High CPU: ${topProc || 'Unknown'}`; - if (taskText === 'System Idle') taskText = cpuText; - if (global.lastLoggedCpu !== cpuText) { - logActivity(cpuText); - global.lastLoggedCpu = cpuText; - } - } else { - global.lastLoggedCpu = null; - } + if (activities.length > 0) { + status = 'busy'; + const activityText = activities.join(', '); + if (taskText === 'System Idle') taskText = activityText; + if (global.lastLoggedActivity !== activityText) { + logActivity(activityText); + global.lastLoggedActivity = activityText; + } + } else { + global.lastLoggedActivity = null; + } - const versions = getVersions(); - - callback({ - status: status, - task: taskText, - cpu: Math.round(totalCpu), - mem: Math.round((1 - os.freemem() / os.totalmem()) * 100), - disk: diskUsage, - timezone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC', - lastHeartbeat: new Date().toISOString(), - versions: versions, - gatewayPid: gatewayPid, - scripts: runningScripts, - }); + if (totalCpu > 70.0) { + status = 'busy'; + const cpuText = `⚡ High CPU: ${topProc || 'Unknown'}`; + if (taskText === 'System Idle') taskText = cpuText; + if (global.lastLoggedCpu !== cpuText) { + logActivity(cpuText); + global.lastLoggedCpu = cpuText; } + } else { + global.lastLoggedCpu = null; } + + const versions = getVersions(); + const unsupportedMonitoring = IS_DOCKER ? [...DOCKER_UNSUPPORTED_MONITORING] : []; + + callback({ + status: status, + task: taskText, + cpu: IS_DOCKER ? null : Math.round(totalCpu), + mem: IS_DOCKER ? null : Math.round((1 - os.freemem() / os.totalmem()) * 100), + disk: IS_DOCKER ? null : diskUsage, + timezone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC', + lastHeartbeat: new Date().toISOString(), + versions: versions, + gatewayPid: IS_DOCKER ? null : gatewayPid, + scripts: IS_DOCKER ? null : runningScripts, + environment: { + isDocker: IS_DOCKER, + }, + unsupportedMonitoring, + }); + } + + if (IS_DOCKER) { + return finalizeStatus({}); + } + + const osType = os.type(); + const mergedCmd = osType === 'Darwin' + ? `echo "===DISK==="; df -h / | awk 'NR==2 {print $5}'; echo "===GWPID==="; pgrep -f '[o]penclaw.*gateway' | head -n 1 || true; echo "===PS==="; ps -Ao pid,pcpu,comm,args -r | head -n 21` + : `echo "===DISK==="; df -h / | awk 'NR==2 {print $5}'; echo "===GWPID==="; pgrep -f '[o]penclaw.*gateway' | head -n 1 || true; echo "===PS==="; ps -eo pid,pcpu,comm,args --sort=-pcpu | head -n 20`; + + exec(mergedCmd, (err, stdout) => { + const sections = stdout ? stdout.split(/===\w+===\n?/) : []; + finalizeStatus({ + err, + diskUsage: sections[1] ? sections[1].trim() || '--%' : '--%', + gatewayPid: sections[2] ? sections[2].trim() || null : null, + psOutput: sections[3] || '', + }); }); } diff --git a/tests/routes.docker-mode.test.js b/tests/routes.docker-mode.test.js new file mode 100644 index 0000000..583a280 --- /dev/null +++ b/tests/routes.docker-mode.test.js @@ -0,0 +1,144 @@ +/* eslint-env jest */ +'use strict'; + +function invokeRouteHandler(router, method, routePath, reqOverrides = {}) { + const layer = router.stack.find(entry => entry.route && entry.route.path === routePath); + if (!layer) throw new Error(`Route not found: ${routePath}`); + + const routeLayer = layer.route.stack.find(entry => entry.method === method); + if (!routeLayer) throw new Error(`Method not found: ${method.toUpperCase()} ${routePath}`); + + const req = { + body: {}, + params: {}, + ip: '127.0.0.1', + ...reqOverrides, + }; + const res = { + statusCode: 200, + body: undefined, + status(code) { + this.statusCode = code; + return this; + }, + json(payload) { + this.body = payload; + return this; + }, + }; + + routeLayer.handle(req, res); + return res; +} + +describe('Docker-mode route restrictions', () => { + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + }); + + test('GET /api/cron returns a Docker-mode marker without CLI fallback', () => { + const exec = jest.fn(); + + jest.doMock('../src/config', () => ({ + HOME_DIR: '/tmp', + STATE_DIR: '/tmp/.openclaw', + IS_DOCKER: true, + })); + jest.doMock('fs', () => ({ + existsSync: jest.fn(() => false), + readFileSync: jest.fn(), + })); + jest.doMock('child_process', () => ({ + exec, + execFile: jest.fn(), + })); + jest.doMock('../src/services/openclaw', () => ({ + getOpenClawCommand: jest.fn(() => 'openclaw'), + })); + + const router = require('../src/routes/cron'); + const res = invokeRouteHandler(router, 'get', '/api/cron'); + + expect(res.statusCode).toBe(200); + expect(res.body).toEqual({ dockerMode: true, jobs: [] }); + expect(exec).not.toHaveBeenCalled(); + }); + + test('POST /api/run/:id is blocked in Docker mode before ID validation', () => { + jest.doMock('../src/config', () => ({ + HOME_DIR: '/tmp', + STATE_DIR: '/tmp/.openclaw', + IS_DOCKER: true, + })); + jest.doMock('fs', () => ({ + existsSync: jest.fn(() => false), + readFileSync: jest.fn(), + })); + jest.doMock('child_process', () => ({ + exec: jest.fn(), + execFile: jest.fn(), + })); + jest.doMock('../src/services/openclaw', () => ({ + getOpenClawCommand: jest.fn(() => 'openclaw'), + })); + + const router = require('../src/routes/cron'); + const res = invokeRouteHandler(router, 'post', '/api/run/:id', { + params: { id: 'bad!!id' }, + }); + + expect(res.statusCode).toBe(403); + expect(res.body).toEqual({ + error: 'Running cron jobs is not supported in Docker Mode. Please interact with the host CLI directly.', + }); + }); + + test('POST /api/kill is blocked in Docker mode before confirmation validation', () => { + jest.doMock('../src/config', () => ({ + IS_DOCKER: true, + })); + jest.doMock('../src/utils/rateLimit', () => ({ + rateLimit: jest.fn(() => true), + })); + jest.doMock('../src/services/openclaw', () => ({ + getOpenClawCommand: jest.fn(() => 'openclaw'), + WORKSPACE_DIR: '/tmp/workspace', + })); + jest.doMock('child_process', () => ({ + exec: jest.fn(), + })); + + const router = require('../src/routes/process'); + const res = invokeRouteHandler(router, 'post', '/api/kill'); + + expect(res.statusCode).toBe(403); + expect(res.body).toEqual({ + error: 'Process termination is not supported in Docker Container Mode. Please restart the container or run host commands directly.', + }); + }); + + test('POST /api/gateway/restart is blocked in Docker mode', () => { + jest.doMock('../src/config', () => ({ + IS_DOCKER: true, + })); + jest.doMock('../src/utils/rateLimit', () => ({ + rateLimit: jest.fn(() => true), + })); + jest.doMock('../src/services/openclaw', () => ({ + getOpenClawCommand: jest.fn(() => 'openclaw'), + WORKSPACE_DIR: '/tmp/workspace', + })); + jest.doMock('child_process', () => ({ + exec: jest.fn(), + })); + + const router = require('../src/routes/process'); + const res = invokeRouteHandler(router, 'post', '/api/gateway/restart'); + + expect(res.statusCode).toBe(403); + expect(res.body).toEqual({ + error: 'Gateway process restarts are not supported inside Docker containers. To restart the gateway, please restart the Docker container.', + }); + }); +}); diff --git a/tests/services.monitor.test.js b/tests/services.monitor.test.js new file mode 100644 index 0000000..77d210f --- /dev/null +++ b/tests/services.monitor.test.js @@ -0,0 +1,92 @@ +/* eslint-env jest */ +'use strict'; + +describe('monitor service in Docker mode', () => { + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + }); + + test('getVersions reports Docker-managed core without invoking openclaw CLI', () => { + const execSync = jest.fn(); + + jest.doMock('child_process', () => ({ + exec: jest.fn(), + execSync, + })); + jest.doMock('../src/config', () => ({ + ID_FILE: '/tmp/last_id.txt', + APP_DIR: '/app', + IS_DOCKER: true, + })); + jest.doMock('fs', () => ({ + existsSync: jest.fn(() => false), + readFileSync: jest.fn((targetPath) => { + if (targetPath === '/app/package.json') return JSON.stringify({ version: '1.2.3' }); + throw new Error(`Unexpected read: ${targetPath}`); + }), + writeFileSync: jest.fn(), + })); + jest.doMock('../src/services/openclaw', () => ({ + getOpenClawCommand: jest.fn(() => 'openclaw'), + })); + jest.doMock('../src/services/context', () => ({ + getActiveContext: jest.fn(() => null), + })); + jest.doMock('../src/services/activity', () => ({ + logActivity: jest.fn(), + checkFileChanges: jest.fn(), + })); + + const { getVersions } = require('../src/services/monitor'); + + expect(getVersions()).toEqual({ dashboard: '1.2.3', core: 'Managed by Docker' }); + expect(execSync).not.toHaveBeenCalled(); + }); + + test('checkSystemStatus marks host-only metrics as unsupported in Docker mode', done => { + const exec = jest.fn(); + + jest.doMock('child_process', () => ({ + exec, + execSync: jest.fn(), + })); + jest.doMock('../src/config', () => ({ + ID_FILE: '/tmp/last_id.txt', + APP_DIR: '/app', + IS_DOCKER: true, + })); + jest.doMock('fs', () => ({ + existsSync: jest.fn(() => false), + readFileSync: jest.fn((targetPath) => { + if (targetPath === '/app/package.json') return JSON.stringify({ version: '1.2.3' }); + throw new Error(`Unexpected read: ${targetPath}`); + }), + writeFileSync: jest.fn(), + })); + jest.doMock('../src/services/openclaw', () => ({ + getOpenClawCommand: jest.fn(() => 'openclaw'), + })); + jest.doMock('../src/services/context', () => ({ + getActiveContext: jest.fn(() => null), + })); + jest.doMock('../src/services/activity', () => ({ + logActivity: jest.fn(), + checkFileChanges: jest.fn(), + })); + + const { checkSystemStatus } = require('../src/services/monitor'); + + checkSystemStatus(data => { + expect(exec).not.toHaveBeenCalled(); + expect(data.disk).toBeNull(); + expect(data.cpu).toBeNull(); + expect(data.mem).toBeNull(); + expect(data.gatewayPid).toBeNull(); + expect(data.scripts).toBeNull(); + expect(data.environment).toEqual({ isDocker: true }); + expect(data.unsupportedMonitoring).toEqual(['cpu', 'mem', 'disk', 'gatewayPid', 'scripts']); + done(); + }); + }); +});