From c0f0084e5600db0746ad04ccda82af0b37c8a8a6 Mon Sep 17 00:00:00 2001 From: Girish Ramakrishnan Date: Wed, 21 May 2025 17:15:04 +0200 Subject: [PATCH] metrics: add stream api for system info --- dashboard/src/components/CpuUsage.vue | 42 ++++++++++++--------------- dashboard/src/models/SystemModel.js | 3 ++ src/apps.js | 3 +- src/metrics.js | 36 +++++++++++++++++++++-- src/routes/system.js | 25 ++++++++++++++++ src/server.js | 1 + 6 files changed, 83 insertions(+), 27 deletions(-) diff --git a/dashboard/src/components/CpuUsage.vue b/dashboard/src/components/CpuUsage.vue index 7e7decf45..41389cf77 100644 --- a/dashboard/src/components/CpuUsage.vue +++ b/dashboard/src/components/CpuUsage.vue @@ -38,33 +38,25 @@ const period = ref(6); const busy = ref(true); let gGraph = null; - -let gLiveDataPoints = {}; +let gMetricStream = null; async function liveRefresh() { - // stop and clear - if (period.value !== 0) return gLiveDataPoints = {}; + gMetricStream = await systemModel.getMetricStream(); + gMetricStream.onerror = (error) => console.log('event stream error:', error); + gMetricStream.onmessage = (message) => { + const data = JSON.parse(message.data); + if (data.cpu[0]) return; // value can be null if no previous value + gGraph.data.labels.push(moment(data.cpu[1]*1000).format('hh:mm')); + gGraph.data.datasets[0].data.push(data.cpu[0]); - const [error, result] = await systemModel.getMetrics({ fromSecs: 60, intervalSecs: 300 }); - if (error) return console.error(error); + // Limit the number of data points to keep the chart readable + if (gGraph.data.datasets[0].data.length > 40) { + gGraph.data.labels.shift(); + gGraph.data.datasets[0].data.shift(); + } - for (const v of result.cpu) { - if (gLiveDataPoints[v[1]]) continue; - - gLiveDataPoints[v[1]] = v[0]; - gGraph.data.labels.push(moment(v[1]*1000).format('hh:mm')); - gGraph.data.datasets[0].data.push(v[0]); - } - - // Limit the number of data points to keep the chart readable - if (gGraph.data.datasets[0].data.length > 40) { - gGraph.data.labels.shift(); - gGraph.data.datasets[0].data.shift(); - } - - gGraph.update(); - - setTimeout(liveRefresh, 2000); + gGraph.update(); + }; } async function refresh() { @@ -79,6 +71,10 @@ async function refresh() { const data = result.cpu.map(v => v[0]); // already scaled to cpu*100 + if (gMetricStream) { + gMetricStream.close(); + gMetricStream = null; + } if (gGraph) gGraph.destroy(); gGraph = new Chart(graph.value, { type: 'line', diff --git a/dashboard/src/models/SystemModel.js b/dashboard/src/models/SystemModel.js index 60ee63f4b..356fe0420 100644 --- a/dashboard/src/models/SystemModel.js +++ b/dashboard/src/models/SystemModel.js @@ -94,6 +94,9 @@ function create() { if (error || result.status !== 200) return [error || result]; return [null, result.body]; }, + async getMetricStream() { + return new EventSource(`${API_ORIGIN}/api/v1/system/metricstream?access_token=${accessToken}`); + } }; } diff --git a/src/apps.js b/src/apps.js index 25e60bfe6..1e444be7d 100644 --- a/src/apps.js +++ b/src/apps.js @@ -150,8 +150,7 @@ exports = module.exports = { _clear: clear }; -const appstore = require('./appstore.js'), - appTaskManager = require('./apptaskmanager.js'), +const appTaskManager = require('./apptaskmanager.js'), archives = require('./archives.js'), assert = require('assert'), backups = require('./backups.js'), diff --git a/src/metrics.js b/src/metrics.js index f78764945..c3677337a 100644 --- a/src/metrics.js +++ b/src/metrics.js @@ -2,6 +2,8 @@ exports = module.exports = { getSystem, + getSystemStream, + getContainers, sendToGraphite @@ -16,6 +18,7 @@ const apps = require('./apps.js'), execSync = require('child_process').execSync, net = require('net'), os = require('os'), + { Readable } = require('stream'), safe = require('safetydance'), services = require('./services.js'), superagent = require('./superagent.js'); @@ -226,7 +229,7 @@ async function getContainers(name, options) { }; } -async function getSystemStats(options) { +async function readSystemFromGraphite(options) { assert.strictEqual(typeof options, 'object'); const { fromSecs, intervalSecs, noNullPoints } = options; @@ -269,7 +272,7 @@ async function getSystemStats(options) { async function getSystem(options) { assert.strictEqual(typeof options, 'object'); - const systemStats = await getSystemStats(options); + const systemStats = await readSystemFromGraphite(options); const appStats = {}; for (const app of await apps.list()) { @@ -289,3 +292,32 @@ async function getSystem(options) { cpuCount: os.cpus().length }; } + +async function getSystemStream() { + const INTERVAL_SECS = 2; + let intervalId = null, oldCpuMetrics = null; + + const metricsStream = new Readable({ + read(/*size*/) { /* ignored, we push via interval */ }, + destroy(error, callback) { + clearInterval(intervalId); + callback(error); + } + }); + + intervalId = setInterval(async () => { + const memoryMetrics = await getMemoryMetrics(); + const cpuMetrics = await getCpuMetrics(); + + const cpuPercent = oldCpuMetrics ? (cpuMetrics.userMsecs + cpuMetrics.sysMsecs - oldCpuMetrics.userMsecs - oldCpuMetrics.sysMsecs) * 0.1 / INTERVAL_SECS : null; + oldCpuMetrics = cpuMetrics; + + const now = Date.now(); + metricsStream.push(JSON.stringify({ + cpu: [ cpuPercent, now ], + memory: [ memoryMetrics.used, now ] + })); + }, INTERVAL_SECS*1000); + + return metricsStream; +} diff --git a/src/routes/system.js b/src/routes/system.js index b7bf70825..4079b43c0 100644 --- a/src/routes/system.js +++ b/src/routes/system.js @@ -9,6 +9,7 @@ exports = module.exports = { getLogs, getLogStream, getMetrics, + getMetricStream, getBlockDevices, getCpus, }; @@ -130,6 +131,30 @@ async function getMetrics(req, res, next) { next(new HttpSuccess(200, result)); } +async function getMetricStream(req, res, next) { + if (req.headers.accept !== 'text/event-stream') return next(new HttpError(400, 'This API call requires EventStream')); + + const [error, metricStream] = await safe(metrics.getSystemStream()); + if (error) return next(BoxError.toHttpError(error)); + + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no', // disable nginx buffering + 'Access-Control-Allow-Origin': '*' + }); + res.write('retry: 3000\n'); + res.on('close', () => metricStream.destroy()); + metricStream.on('data', function (data) { + const obj = JSON.parse(data); + const sse = `data: ${JSON.stringify(obj)}\n\n`; + res.write(sse); + }); + metricStream.on('end', res.end.bind(res)); + metricStream.on('error', res.end.bind(res, null)); +} + async function getBlockDevices(req, res, next) { const [error, devices] = await safe(system.getBlockDevices()); if (error) return next(new HttpError(500, error)); diff --git a/src/server.js b/src/server.js index bf40da66b..0e38e4126 100644 --- a/src/server.js +++ b/src/server.js @@ -114,6 +114,7 @@ async function initializeExpressSync() { router.get ('/api/v1/system/info', token, authorizeAdmin, routes.system.getInfo); // vendor, product name etc router.post('/api/v1/system/reboot', json, token, authorizeAdmin, routes.system.reboot); router.get ('/api/v1/system/metrics', token, authorizeAdmin, routes.system.getMetrics); + router.get ('/api/v1/system/metricstream', token, authorizeAdmin, routes.system.getMetricStream); router.get ('/api/v1/system/disk_usage', token, authorizeAdmin, routes.system.getDiskUsage); router.post('/api/v1/system/disk_usage', token, authorizeAdmin, routes.system.updateDiskUsage); router.get ('/api/v1/system/block_devices', token, authorizeAdmin, routes.system.getBlockDevices);