Migrate codebase from CommonJS to ES Modules
- Convert all require()/module.exports to import/export across 260+ files
- Add "type": "module" to package.json to enable ESM by default
- Add migrations/package.json with "type": "commonjs" to keep db-migrate compatible
- Convert eslint.config.js to ESM with sourceType: "module"
- Replace __dirname/__filename with import.meta.dirname/import.meta.filename
- Replace require.main === module with process.argv[1] === import.meta.filename
- Remove 'use strict' directives (implicit in ESM)
- Convert dynamic require() in switch statements to static import lookup maps
(dns.js, domains.js, backupformats.js, backupsites.js, network.js)
- Extract self-referencing exports.CONSTANT patterns into standalone const
declarations (apps.js, services.js, locks.js, users.js, mail.js, etc.)
- Lazify SERVICES object in services.js to avoid circular dependency TDZ issues
- Add clearMailQueue() to mailer.js for ESM-safe queue clearing in tests
- Add _setMockApp() to ldapserver.js for ESM-safe test mocking
- Add _setMockResolve() wrapper to dig.js for ESM-safe DNS mocking in tests
- Convert backupupload.js to use dynamic imports so --check exits before
loading the module graph (which requires BOX_ENV)
- Update check-install to use ESM import for infra_version.js
- Convert scripts/ (hotfix, release, remote_hotfix.js, find-unused-translations)
- All 1315 tests passing
Migration stats (AI-assisted using Cursor with Claude):
- Wall clock time: ~3-4 hours
- Assistant completions: ~80-100
- Estimated token usage: ~1-2M tokens
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 09:53:14 +01:00
import apps from './apps.js' ;
import assert from 'node:assert' ;
import BoxError from './boxerror.js' ;
import constants from './constants.js' ;
2026-03-12 22:55:28 +05:30
import logger from './logger.js' ;
2026-02-14 15:43:24 +01:00
import docker from './docker.js' ;
Migrate codebase from CommonJS to ES Modules
- Convert all require()/module.exports to import/export across 260+ files
- Add "type": "module" to package.json to enable ESM by default
- Add migrations/package.json with "type": "commonjs" to keep db-migrate compatible
- Convert eslint.config.js to ESM with sourceType: "module"
- Replace __dirname/__filename with import.meta.dirname/import.meta.filename
- Replace require.main === module with process.argv[1] === import.meta.filename
- Remove 'use strict' directives (implicit in ESM)
- Convert dynamic require() in switch statements to static import lookup maps
(dns.js, domains.js, backupformats.js, backupsites.js, network.js)
- Extract self-referencing exports.CONSTANT patterns into standalone const
declarations (apps.js, services.js, locks.js, users.js, mail.js, etc.)
- Lazify SERVICES object in services.js to avoid circular dependency TDZ issues
- Add clearMailQueue() to mailer.js for ESM-safe queue clearing in tests
- Add _setMockApp() to ldapserver.js for ESM-safe test mocking
- Add _setMockResolve() wrapper to dig.js for ESM-safe DNS mocking in tests
- Convert backupupload.js to use dynamic imports so --check exits before
loading the module graph (which requires BOX_ENV)
- Update check-install to use ESM import for infra_version.js
- Convert scripts/ (hotfix, release, remote_hotfix.js, find-unused-translations)
- All 1315 tests passing
Migration stats (AI-assisted using Cursor with Claude):
- Wall clock time: ~3-4 hours
- Assistant completions: ~80-100
- Estimated token usage: ~1-2M tokens
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 09:53:14 +01:00
import fs from 'node:fs' ;
import net from 'node:net' ;
2026-02-14 15:43:24 +01:00
import network from './network.js' ;
Migrate codebase from CommonJS to ES Modules
- Convert all require()/module.exports to import/export across 260+ files
- Add "type": "module" to package.json to enable ESM by default
- Add migrations/package.json with "type": "commonjs" to keep db-migrate compatible
- Convert eslint.config.js to ESM with sourceType: "module"
- Replace __dirname/__filename with import.meta.dirname/import.meta.filename
- Replace require.main === module with process.argv[1] === import.meta.filename
- Remove 'use strict' directives (implicit in ESM)
- Convert dynamic require() in switch statements to static import lookup maps
(dns.js, domains.js, backupformats.js, backupsites.js, network.js)
- Extract self-referencing exports.CONSTANT patterns into standalone const
declarations (apps.js, services.js, locks.js, users.js, mail.js, etc.)
- Lazify SERVICES object in services.js to avoid circular dependency TDZ issues
- Add clearMailQueue() to mailer.js for ESM-safe queue clearing in tests
- Add _setMockApp() to ldapserver.js for ESM-safe test mocking
- Add _setMockResolve() wrapper to dig.js for ESM-safe DNS mocking in tests
- Convert backupupload.js to use dynamic imports so --check exits before
loading the module graph (which requires BOX_ENV)
- Update check-install to use ESM import for infra_version.js
- Convert scripts/ (hotfix, release, remote_hotfix.js, find-unused-translations)
- All 1315 tests passing
Migration stats (AI-assisted using Cursor with Claude):
- Wall clock time: ~3-4 hours
- Assistant completions: ~80-100
- Estimated token usage: ~1-2M tokens
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 09:53:14 +01:00
import os from 'node:os' ;
import { Readable } from 'node:stream' ;
import safe from 'safetydance' ;
2026-02-15 19:37:30 +01:00
import services from './services.js' ;
Migrate codebase from CommonJS to ES Modules
- Convert all require()/module.exports to import/export across 260+ files
- Add "type": "module" to package.json to enable ESM by default
- Add migrations/package.json with "type": "commonjs" to keep db-migrate compatible
- Convert eslint.config.js to ESM with sourceType: "module"
- Replace __dirname/__filename with import.meta.dirname/import.meta.filename
- Replace require.main === module with process.argv[1] === import.meta.filename
- Remove 'use strict' directives (implicit in ESM)
- Convert dynamic require() in switch statements to static import lookup maps
(dns.js, domains.js, backupformats.js, backupsites.js, network.js)
- Extract self-referencing exports.CONSTANT patterns into standalone const
declarations (apps.js, services.js, locks.js, users.js, mail.js, etc.)
- Lazify SERVICES object in services.js to avoid circular dependency TDZ issues
- Add clearMailQueue() to mailer.js for ESM-safe queue clearing in tests
- Add _setMockApp() to ldapserver.js for ESM-safe test mocking
- Add _setMockResolve() wrapper to dig.js for ESM-safe DNS mocking in tests
- Convert backupupload.js to use dynamic imports so --check exits before
loading the module graph (which requires BOX_ENV)
- Update check-install to use ESM import for infra_version.js
- Convert scripts/ (hotfix, release, remote_hotfix.js, find-unused-translations)
- All 1315 tests passing
Migration stats (AI-assisted using Cursor with Claude):
- Wall clock time: ~3-4 hours
- Assistant completions: ~80-100
- Estimated token usage: ~1-2M tokens
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 09:53:14 +01:00
import shellModule from './shell.js' ;
import superagent from '@cloudron/superagent' ;
2026-02-14 15:43:24 +01:00
import _ from './underscore.js' ;
Migrate codebase from CommonJS to ES Modules
- Convert all require()/module.exports to import/export across 260+ files
- Add "type": "module" to package.json to enable ESM by default
- Add migrations/package.json with "type": "commonjs" to keep db-migrate compatible
- Convert eslint.config.js to ESM with sourceType: "module"
- Replace __dirname/__filename with import.meta.dirname/import.meta.filename
- Replace require.main === module with process.argv[1] === import.meta.filename
- Remove 'use strict' directives (implicit in ESM)
- Convert dynamic require() in switch statements to static import lookup maps
(dns.js, domains.js, backupformats.js, backupsites.js, network.js)
- Extract self-referencing exports.CONSTANT patterns into standalone const
declarations (apps.js, services.js, locks.js, users.js, mail.js, etc.)
- Lazify SERVICES object in services.js to avoid circular dependency TDZ issues
- Add clearMailQueue() to mailer.js for ESM-safe queue clearing in tests
- Add _setMockApp() to ldapserver.js for ESM-safe test mocking
- Add _setMockResolve() wrapper to dig.js for ESM-safe DNS mocking in tests
- Convert backupupload.js to use dynamic imports so --check exits before
loading the module graph (which requires BOX_ENV)
- Update check-install to use ESM import for infra_version.js
- Convert scripts/ (hotfix, release, remote_hotfix.js, find-unused-translations)
- All 1315 tests passing
Migration stats (AI-assisted using Cursor with Claude):
- Wall clock time: ~3-4 hours
- Assistant completions: ~80-100
- Estimated token usage: ~1-2M tokens
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 09:53:14 +01:00
2026-03-12 22:55:28 +05:30
const { log , trace } = logger ( 'metrics' ) ;
Migrate codebase from CommonJS to ES Modules
- Convert all require()/module.exports to import/export across 260+ files
- Add "type": "module" to package.json to enable ESM by default
- Add migrations/package.json with "type": "commonjs" to keep db-migrate compatible
- Convert eslint.config.js to ESM with sourceType: "module"
- Replace __dirname/__filename with import.meta.dirname/import.meta.filename
- Replace require.main === module with process.argv[1] === import.meta.filename
- Remove 'use strict' directives (implicit in ESM)
- Convert dynamic require() in switch statements to static import lookup maps
(dns.js, domains.js, backupformats.js, backupsites.js, network.js)
- Extract self-referencing exports.CONSTANT patterns into standalone const
declarations (apps.js, services.js, locks.js, users.js, mail.js, etc.)
- Lazify SERVICES object in services.js to avoid circular dependency TDZ issues
- Add clearMailQueue() to mailer.js for ESM-safe queue clearing in tests
- Add _setMockApp() to ldapserver.js for ESM-safe test mocking
- Add _setMockResolve() wrapper to dig.js for ESM-safe DNS mocking in tests
- Convert backupupload.js to use dynamic imports so --check exits before
loading the module graph (which requires BOX_ENV)
- Update check-install to use ESM import for infra_version.js
- Convert scripts/ (hotfix, release, remote_hotfix.js, find-unused-translations)
- All 1315 tests passing
Migration stats (AI-assisted using Cursor with Claude):
- Wall clock time: ~3-4 hours
- Assistant completions: ~80-100
- Estimated token usage: ~1-2M tokens
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 09:53:14 +01:00
const shell = shellModule ( 'metrics' ) ;
2022-09-14 13:03:14 +02:00
2025-07-03 19:01:40 +02:00
function translateContainerStatsSync ( stats ) {
assert . strictEqual ( typeof stats , 'object' ) ;
2025-07-01 11:49:37 +02:00
2025-07-04 09:35:28 +02:00
// the container is missing or stopped. better not to inspect and check State since a race is possible
2025-07-18 19:32:04 +02:00
if ( Object . keys ( stats . pids _stats || { } ) === 0 || Object . keys ( stats . memory _stats || { } ) . length === 0 || stats . blkio _stats . io _service _bytes _recursive === null ) return null ;
2025-07-04 09:35:28 +02:00
2025-07-01 11:49:37 +02:00
const networkRead = stats . networks ? stats . networks . eth0 . rx _bytes : 0 ; // in host mode (turn), networks is missing
const networkWrite = stats . networks ? stats . networks . eth0 . tx _bytes : 0 ; // in host mode (turn), networks is missing
const memoryUsed = stats . memory _stats . usage ;
const memoryMax = stats . memory _stats . limit ;
const blkioStats = stats . blkio _stats . io _service _bytes _recursive ;
const blockRead = blkioStats . filter ( entry => entry . op === 'read' ) . reduce ( ( sum , entry ) => sum + entry . value , 0 ) ;
const blockWrite = blkioStats . filter ( entry => entry . op === 'write' ) . reduce ( ( sum , entry ) => sum + entry . value , 0 ) ;
2025-07-03 18:43:42 +02:00
const cpuUsageMsecs = stats . cpu _stats . cpu _usage . total _usage / 1e6 ; // convert from nano to msecs (to match system metrics)
2025-07-03 19:01:40 +02:00
const systemUsageMsecs = stats . cpu _stats . system _cpu _usage / 1e6 ;
2025-07-01 11:49:37 +02:00
2025-07-04 09:35:28 +02:00
const pidCount = stats . pids _stats . current ;
return { ts : new Date ( stats . read ) , pidCount , networkRead , networkWrite , blockRead , blockWrite , memoryUsed , memoryMax , cpuUsageMsecs , systemUsageMsecs } ;
2025-07-01 11:49:37 +02:00
}
async function readContainerMetrics ( ) {
2025-07-01 10:48:51 +02:00
const allAddons = [ 'turn' , 'mail' , 'mongodb' , 'mysql' , 'postgresql' ] ;
2025-05-21 16:32:52 +02:00
2025-07-01 10:48:51 +02:00
const containerNames = allAddons ;
for ( const app of await apps . list ( ) ) {
2025-07-04 10:05:07 +02:00
if ( app . containerId ) containerNames . push ( app . id ) ; // containerId can be null if app is installing. metrics must be stored by appId since container id changes over time
2025-07-01 10:48:51 +02:00
if ( app . manifest . addons ? . redis && app . enableRedis ) containerNames . push ( ` redis- ${ app . id } ` ) ;
2025-05-21 16:32:52 +02:00
}
2025-05-21 16:45:37 +02:00
const metrics = { } ;
2025-07-01 10:48:51 +02:00
for ( const containerName of containerNames ) {
2025-07-03 19:01:40 +02:00
const [ error , stats ] = await safe ( docker . getStats ( containerName , { stream : false } ) ) ;
2025-07-04 09:35:28 +02:00
if ( error ) continue ;
2025-07-03 19:01:40 +02:00
2025-07-04 09:35:28 +02:00
const translated = translateContainerStatsSync ( stats ) ;
if ( translated ) metrics [ containerName ] = translated ;
2025-05-21 16:32:52 +02:00
}
return metrics ;
}
2025-07-01 11:49:37 +02:00
async function readMemoryMetrics ( ) {
2025-05-23 16:11:48 +02:00
const output = await fs . promises . readFile ( '/proc/meminfo' , { encoding : 'utf8' } ) ;
2025-05-21 16:32:52 +02:00
2025-05-23 16:11:48 +02:00
const totalMemoryMatch = output . match ( /^MemTotal:\s+(\d+)/m ) ;
const freeMemoryMatch = output . match ( /^MemFree:\s+(\d+)/m ) ;
const buffersMatch = output . match ( /^Buffers:\s+(\d+)/m ) ;
const cachedMatch = output . match ( /^Cached:\s+(\d+)/m ) ;
2025-05-21 16:32:52 +02:00
2025-05-23 16:11:48 +02:00
if ( ! totalMemoryMatch || ! freeMemoryMatch || ! buffersMatch || ! cachedMatch ) throw new BoxError ( BoxError . EXTERNAL _ERROR , 'Could not find memory used' ) ;
const memoryUsed = parseInt ( totalMemoryMatch [ 1 ] ) * 1024 - parseInt ( freeMemoryMatch [ 1 ] ) * 1024 - parseInt ( buffersMatch [ 1 ] ) * 1024 - parseInt ( cachedMatch [ 1 ] ) * 1024 ;
const swapTotalMatch = output . match ( /^SwapTotal:\s+(\d+)/m ) ;
const swapFreeMatch = output . match ( /^SwapFree:\s+(\d+)/m ) ;
if ( ! swapTotalMatch || ! swapFreeMatch ) throw new BoxError ( BoxError . EXTERNAL _ERROR , 'Could not find swap used' ) ;
const swapUsed = parseInt ( swapTotalMatch [ 1 ] ) * 1024 - parseInt ( swapFreeMatch [ 1 ] ) * 1024 ;
2025-05-22 10:21:21 +02:00
return {
2025-05-23 16:11:48 +02:00
memoryUsed ,
swapUsed
2025-05-22 10:21:21 +02:00
} ;
2025-05-21 16:32:52 +02:00
}
2025-07-01 11:49:37 +02:00
async function readCpuMetrics ( ) {
2025-05-21 16:32:52 +02:00
const cpus = os . cpus ( ) ;
2025-05-21 16:45:37 +02:00
const userMsecs = cpus . map ( c => c . times . user ) . reduce ( ( p , c ) => p + c ) ;
const sysMsecs = cpus . map ( c => c . times . sys ) . reduce ( ( p , c ) => p + c ) ;
return { userMsecs , sysMsecs } ; // these values are the times spent since system start
2025-05-21 16:32:52 +02:00
}
2025-10-15 11:24:07 +02:00
let gRootDiskName = null ;
async function getRootDiskName ( ) {
if ( gRootDiskName ) return gRootDiskName ;
2025-06-19 10:17:29 +02:00
2025-10-15 11:24:07 +02:00
const mounts = await fs . promises . readFile ( '/proc/mounts' , { encoding : 'utf8' } ) ;
2025-10-17 16:26:39 +02:00
const rootfsLine = mounts . split ( '\n' ) . find ( line => line . split ( /\s+/ ) [ 1 ] === '/' ) ;
2025-10-15 11:24:07 +02:00
if ( ! rootfsLine ) throw new BoxError ( BoxError . EXTERNAL _ERROR , 'Root mount not found' ) ;
2025-10-17 16:26:39 +02:00
const maybeDevicePath = rootfsLine . split ( /\s+/ ) [ 0 ] ; // eg. /dev/foo . This can be a partition or a disk path
// for LVM, the canonical devicePath might be a symlink to the real disk
const devicePath = await fs . promises . realpath ( maybeDevicePath ) ;
// keep going up to find the final parent disk
let pkname = devicePath . replace ( '/dev/' , '' ) ;
while ( true ) {
// -n is no headings , -d is no holder devices or slaves , -o is output format . PKNAME is parent kernel name
const output = await shell . spawn ( 'lsblk' , [ '-ndo' , 'PKNAME' , ` /dev/ ${ pkname } ` ] , { encoding : 'utf8' } ) ;
if ( ! output . trim ( ) ) break ;
pkname = output . trim ( ) ;
}
2025-10-15 11:24:07 +02:00
2025-10-17 16:26:39 +02:00
gRootDiskName = pkname ;
2025-10-15 11:24:07 +02:00
return gRootDiskName ;
}
2025-05-21 16:45:37 +02:00
2025-10-15 11:24:07 +02:00
async function readDiskMetrics ( ) {
2025-10-17 16:26:39 +02:00
const [ rootDiskError , rootDiskName ] = await safe ( getRootDiskName ( ) ) ;
if ( rootDiskError ) throw new BoxError ( BoxError . EXTERNAL _ERROR , ` Could not detect root disk: ${ rootDiskError . message } ` ) ;
2025-07-01 15:43:49 +02:00
const diskstats = await fs . promises . readFile ( '/proc/diskstats' , { encoding : 'utf8' } ) ;
2025-10-15 11:24:07 +02:00
const statsLine = diskstats . split ( '\n' ) . find ( l => l . includes ( ` ${ rootDiskName } ` ) ) ;
if ( ! statsLine ) throw new BoxError ( BoxError . EXTERNAL _ERROR , ` Could not get disk stats of ${ rootDiskName } ` ) ;
2025-07-01 15:43:49 +02:00
const parts = statsLine . trim ( ) . split ( /\s+/ ) ;
2025-10-15 11:24:07 +02:00
const sectorsRead = parseInt ( parts [ 5 ] , 10 ) ; // field 6 . one sector is 512 bytes
2025-07-01 15:43:49 +02:00
const sectorsWrite = parseInt ( parts [ 9 ] , 10 ) ; // field 10
const blockRead = sectorsRead * 512 ;
const blockWrite = sectorsWrite * 512 ;
return { blockRead , blockWrite } ;
}
async function readNetworkMetrics ( ) {
2025-11-28 09:56:13 +01:00
const defaultIface = await network . getDefaultInterface ( ) ;
2025-07-01 15:43:49 +02:00
const [ rx , tx ] = await Promise . all ( [
fs . promises . readFile ( ` /sys/class/net/ ${ defaultIface } /statistics/rx_bytes ` , { encoding : 'utf8' } ) ,
fs . promises . readFile ( ` /sys/class/net/ ${ defaultIface } /statistics/tx_bytes ` , { encoding : 'utf8' } )
] ) ;
return {
networkRead : parseInt ( rx . trim ( ) , 10 ) ,
networkWrite : parseInt ( tx . trim ( ) , 10 )
} ;
}
async function readSystemMetrics ( ) {
const memoryMetrics = await readMemoryMetrics ( ) ;
2025-07-01 11:49:37 +02:00
const cpuMetrics = await readCpuMetrics ( ) ;
2025-07-01 15:43:49 +02:00
const diskMetrics = await readDiskMetrics ( ) ;
const networkMetrics = await readNetworkMetrics ( ) ;
// { memoryUsed, swapUsed, userMsecs, sysMsecs, blockRead, blockWrite, networkRead, networkWrite }
2025-07-08 16:45:55 +02:00
return { ts : new Date ( ) , ... memoryMetrics , ... cpuMetrics , ... diskMetrics , ... networkMetrics } ;
2025-07-01 15:43:49 +02:00
}
async function sendToGraphite ( ) {
2026-03-12 22:55:28 +05:30
// log('sendStatsToGraphite: collecting stats');
2025-07-01 15:43:49 +02:00
const result = await readSystemMetrics ( ) ;
const graphiteMetrics = [
{ path : ` cloudron.system.memory-used ` , value : result . memoryUsed } ,
{ path : ` cloudron.system.swap-used ` , value : result . swapUsed } ,
{ path : ` cloudron.system.cpu-user ` , value : result . userMsecs } ,
{ path : ` cloudron.system.cpu-sys ` , value : result . sysMsecs } ,
{ path : ` cloudron.system.blockio-read ` , value : result . blockRead } ,
{ path : ` cloudron.system.blockio-write ` , value : result . blockWrite } ,
{ path : ` cloudron.system.network-read ` , value : result . networkRead } ,
{ path : ` cloudron.system.network-write ` , value : result . networkWrite }
] ;
2025-05-21 16:45:37 +02:00
2025-07-01 11:49:37 +02:00
const dockerMetrics = await readContainerMetrics ( ) ;
2025-05-21 16:45:37 +02:00
for ( const [ name , value ] of Object . entries ( dockerMetrics ) ) {
graphiteMetrics . push (
{ path : ` cloudron.container- ${ name } .network-read ` , value : value . networkRead } ,
{ path : ` cloudron.container- ${ name } .network-write ` , value : value . networkWrite } ,
{ path : ` cloudron.container- ${ name } .blockio-read ` , value : value . blockRead } ,
{ path : ` cloudron.container- ${ name } .blockio-write ` , value : value . blockWrite } ,
2025-07-01 11:49:37 +02:00
{ path : ` cloudron.container- ${ name } .memory-used ` , value : value . memoryUsed } ,
{ path : ` cloudron.container- ${ name } .memory-max ` , value : value . memoryMax } ,
{ path : ` cloudron.container- ${ name } .cpu-usage ` , value : value . cpuUsageMsecs } ,
2025-05-21 16:45:37 +02:00
) ;
}
2025-05-21 16:32:52 +02:00
return new Promise ( ( resolve ) => {
const client = new net . Socket ( ) ;
2025-11-24 13:22:56 +01:00
client . connect ( constants . GRAPHITE _PORT , constants . GRAPHITE _SERVICE _IPv4 , ( ) => {
2025-05-21 16:32:52 +02:00
const now = Math . floor ( Date . now ( ) / 1000 ) ;
2025-05-21 16:45:37 +02:00
for ( const metric of graphiteMetrics ) {
2025-05-21 16:32:52 +02:00
client . write ( ` ${ metric . path } ${ metric . value } ${ now } \n ` ) ;
}
client . end ( ) ;
} ) ;
client . on ( 'error' , ( error ) => {
2026-03-12 22:55:28 +05:30
log ( ` Error sending data to graphite: ${ error . message } ` ) ;
2025-05-21 16:32:52 +02:00
resolve ( ) ;
} ) ;
2025-07-09 19:08:42 +02:00
client . on ( 'end' , ( ) => resolve ( ) ) ;
2025-05-21 16:32:52 +02:00
} ) ;
}
2022-10-11 12:44:37 +02:00
// for testing locally: curl 'http://${graphite-ip}:8000/graphite-web/render?format=json&from=-1min&target=absolute(collectd.localhost.du-docker.capacity-usage)'
2022-10-11 19:06:26 +02:00
// the datapoint is (value, timestamp) https://graphite.readthedocs.io/en/latest/
2022-10-11 12:44:37 +02:00
async function getGraphiteUrl ( ) {
const [ error , result ] = await safe ( docker . inspect ( 'graphite' ) ) ;
2026-03-08 09:48:41 +05:30
if ( error && error . reason === BoxError . NOT _FOUND ) return { status : services . SERVICE _STATUS _ERROR } ;
2022-10-11 12:44:37 +02:00
if ( error ) throw error ;
const ip = safe . query ( result , 'NetworkSettings.Networks.cloudron.IPAddress' , null ) ;
if ( ! ip ) throw new BoxError ( BoxError . INACTIVE , 'Error getting IP of graphite service' ) ;
return ` http:// ${ ip } :8000/graphite-web/render ` ;
}
2022-09-14 13:03:14 +02:00
2025-07-01 09:46:24 +02:00
async function getContainer ( name , options ) {
2022-10-13 20:32:36 +02:00
assert . strictEqual ( typeof name , 'string' ) ;
2025-05-20 19:09:12 +02:00
assert . strictEqual ( typeof options , 'object' ) ;
const { fromSecs , intervalSecs , noNullPoints } = options ;
2022-09-14 13:03:14 +02:00
2022-10-11 12:44:37 +02:00
const graphiteUrl = await getGraphiteUrl ( ) ;
2022-09-14 13:03:14 +02:00
2022-10-10 19:52:29 +02:00
const targets = [
2025-07-01 10:48:51 +02:00
// perSecond is nonNegativeDerivative over time . this value is the cpu usage in msecs .
// (cpu usage msecs) / (cpus * 1000) is the percent but over all cpus. times 100 is the percent.
// but the y-scale is cpus times 100. so, we only need to scale by 0.1
` scale(perSecond(cloudron.container- ${ name } .cpu-usage),0.1) ` ,
2025-07-01 11:49:37 +02:00
` summarize(cloudron.container- ${ name } .memory-used, " ${ intervalSecs } s", "avg") ` ,
// get the rate in interval window
` summarize(perSecond(cloudron.container- ${ name } .blockio-read), " ${ intervalSecs } s", "avg") ` ,
` summarize(perSecond(cloudron.container- ${ name } .blockio-write), " ${ intervalSecs } s", "avg") ` ,
` summarize(perSecond(cloudron.container- ${ name } .network-read), " ${ intervalSecs } s", "avg") ` ,
` summarize(perSecond(cloudron.container- ${ name } .network-write), " ${ intervalSecs } s", "avg") ` ,
// just get the max in interval window for absolute numbers
2025-05-20 19:09:12 +02:00
` summarize(cloudron.container- ${ name } .blockio-read, " ${ intervalSecs } s", "max") ` ,
` summarize(cloudron.container- ${ name } .blockio-write, " ${ intervalSecs } s", "max") ` ,
` summarize(cloudron.container- ${ name } .network-read, " ${ intervalSecs } s", "max") ` ,
` summarize(cloudron.container- ${ name } .network-write, " ${ intervalSecs } s", "max") ` ,
2022-10-10 19:52:29 +02:00
] ;
2022-09-14 13:03:14 +02:00
2022-10-10 19:52:29 +02:00
const results = [ ] ;
2022-09-14 13:03:14 +02:00
2022-10-10 19:52:29 +02:00
for ( const target of targets ) {
const query = {
2025-09-12 09:48:37 +02:00
target ,
2022-09-16 09:40:47 +02:00
format : 'json' ,
2025-05-20 19:09:12 +02:00
from : ` - ${ fromSecs } s ` ,
2025-07-04 21:53:13 +02:00
until : 'now+20s' , // until is exclusive. 'now' is otherwise not included
2022-10-10 19:52:29 +02:00
noNullPoints : ! ! noNullPoints
2022-09-16 09:40:47 +02:00
} ;
2022-09-14 13:03:14 +02:00
2025-05-18 16:26:33 +02:00
const [ error , response ] = await safe ( superagent . get ( graphiteUrl ) . query ( query ) . timeout ( 30 * 1000 ) . ok ( ( ) => true ) ) ;
2024-11-19 17:08:19 +05:30
if ( error ) throw new BoxError ( BoxError . NETWORK _ERROR , error ) ;
2022-10-10 19:52:29 +02:00
if ( response . status !== 200 ) throw new BoxError ( BoxError . EXTERNAL _ERROR , ` Unknown error with ${ target } : ${ response . status } ${ response . text } ` ) ;
2022-09-14 13:03:14 +02:00
2022-10-14 11:15:27 +02:00
results . push ( response . body [ 0 ] && response . body [ 0 ] . datapoints ? response . body [ 0 ] . datapoints : [ ] ) ;
2022-09-16 09:40:47 +02:00
}
2022-09-14 13:03:14 +02:00
2022-10-14 11:15:27 +02:00
// results are datapoints[[value, ts], [value, ts], ...];
2022-10-10 19:52:29 +02:00
return {
cpu : results [ 0 ] ,
memory : results [ 1 ] ,
2025-07-01 15:43:49 +02:00
blockReadRate : results [ 2 ] ,
blockWriteRate : results [ 3 ] ,
networkReadRate : results [ 4 ] ,
networkWriteRate : results [ 5 ] ,
2026-03-10 22:25:10 +05:30
blockReadTotal : results [ 6 ] . at ( - 1 ) ? . [ 0 ] ? ? 0 ,
blockWriteTotal : results [ 7 ] . at ( - 1 ) ? . [ 0 ] ? ? 0 ,
networkReadTotal : results [ 8 ] . at ( - 1 ) ? . [ 0 ] ? ? 0 ,
networkWriteTotal : results [ 9 ] . at ( - 1 ) ? . [ 0 ] ? ? 0 ,
2022-10-10 19:52:29 +02:00
} ;
2022-09-14 13:03:14 +02:00
}
2022-09-15 11:28:41 +02:00
2025-05-21 17:15:04 +02:00
async function readSystemFromGraphite ( options ) {
2025-05-20 19:09:12 +02:00
assert . strictEqual ( typeof options , 'object' ) ;
const { fromSecs , intervalSecs , noNullPoints } = options ;
2022-09-15 11:28:41 +02:00
2022-10-11 12:44:37 +02:00
const graphiteUrl = await getGraphiteUrl ( ) ;
2022-09-15 11:28:41 +02:00
2025-05-20 22:31:26 +02:00
// example: curl 'http://172.18.30.5:8000/graphite-web/render?target=cloudron.system.cpu-user&target=cloudron.system.cpu-sys&format=json&from=-1min&until=now&noNullPoints=false' | python3 -m json.tool
2025-05-18 16:26:33 +02:00
const targets = [
2025-05-20 22:31:26 +02:00
// perSecond is nonNegativeDerivative over time . this value is the cpu usage in msecs .
// (cpu usage msecs) / (cpus * 1000) is the percent but over all cpus. times 100 is the percent.
// but the y-scale is cpus times 100. so, we only need to scale by 0.1
` scale(perSecond(sumSeries(cloudron.system.cpu-user,cloudron.system.cpu-sys)),0.1) ` ,
2025-05-22 10:21:21 +02:00
` summarize(cloudron.system.memory-used, " ${ intervalSecs } s", "avg") ` ,
` summarize(cloudron.system.swap-used, " ${ intervalSecs } s", "avg") ` ,
2025-07-01 15:43:49 +02:00
// get the rate in interval window
` summarize(perSecond(cloudron.system.blockio-read), " ${ intervalSecs } s", "avg") ` ,
` summarize(perSecond(cloudron.system.blockio-write), " ${ intervalSecs } s", "avg") ` ,
` summarize(perSecond(cloudron.system.network-read), " ${ intervalSecs } s", "avg") ` ,
` summarize(perSecond(cloudron.system.network-write), " ${ intervalSecs } s", "avg") ` ,
// just get the max in interval window for absolute numbers
` summarize(cloudron.system.blockio-read, " ${ intervalSecs } s", "max") ` ,
` summarize(cloudron.system.blockio-write, " ${ intervalSecs } s", "max") ` ,
` summarize(cloudron.system.network-read, " ${ intervalSecs } s", "max") ` ,
` summarize(cloudron.system.network-write, " ${ intervalSecs } s", "max") ` ,
2025-05-18 16:26:33 +02:00
] ;
const results = [ ] ;
for ( const target of targets ) {
const query = {
2025-09-12 09:48:37 +02:00
target ,
2025-05-18 16:26:33 +02:00
format : 'json' ,
2025-05-20 19:09:12 +02:00
from : ` - ${ fromSecs } s ` ,
2025-05-18 16:26:33 +02:00
until : 'now' ,
noNullPoints : ! ! noNullPoints
} ;
2022-09-15 11:28:41 +02:00
2025-05-18 16:26:33 +02:00
const [ error , response ] = await safe ( superagent . get ( graphiteUrl ) . query ( query ) . timeout ( 30 * 1000 ) . ok ( ( ) => true ) ) ;
if ( error ) throw new BoxError ( BoxError . NETWORK _ERROR , error ) ;
if ( response . status !== 200 ) throw new BoxError ( BoxError . EXTERNAL _ERROR , ` Unknown error with ${ target } : ${ response . status } ${ response . text } ` ) ;
results . push ( response . body [ 0 ] && response . body [ 0 ] . datapoints ? response . body [ 0 ] . datapoints : [ ] ) ;
}
return {
cpu : results [ 0 ] ,
2025-05-22 10:21:21 +02:00
memory : results [ 1 ] ,
2025-07-01 15:43:49 +02:00
swap : results [ 2 ] ,
blockReadRate : results [ 3 ] ,
blockWriteRate : results [ 4 ] ,
networkReadRate : results [ 5 ] ,
networkWriteRate : results [ 6 ] ,
2026-03-10 22:25:10 +05:30
blockReadTotal : results [ 7 ] . at ( - 1 ) ? . [ 0 ] ? ? 0 ,
blockWriteTotal : results [ 8 ] . at ( - 1 ) ? . [ 0 ] ? ? 0 ,
networkReadTotal : results [ 9 ] . at ( - 1 ) ? . [ 0 ] ? ? 0 ,
networkWriteTotal : results [ 10 ] . at ( - 1 ) ? . [ 0 ] ? ? 0 ,
2022-09-15 11:28:41 +02:00
} ;
2025-05-18 16:26:33 +02:00
}
2025-07-03 17:31:49 +02:00
// CPU: stress --cpu 2 --timeout 60
2025-07-04 12:51:51 +02:00
// Memory: stress --vm 2 --vm-bytes 256M
2025-07-04 13:18:23 +02:00
// Network:
// raw stats: ip -s link show eth0
// testing: curl -o /dev/null https://ash-speed.hetzner.com/10GB.bin and then use nethogs eth0 (cycle with 'm')
2025-07-04 14:26:09 +02:00
// Disk:
// writing: fio --name=rate-test --filename=tempfile --rw=write --bs=4k --ioengine=libaio --rate=20M --size=5000M --runtime=150 --direct=1. test with iotop
// reading: fio --name=rate-test --filename=tempfile --rw=read --bs=4k --ioengine=libaio --rate=20M --size=5000M --runtime=150 --direct=1. test with iotop
2025-07-07 15:53:09 +02:00
async function get ( options ) {
2025-05-20 19:09:12 +02:00
assert . strictEqual ( typeof options , 'object' ) ;
2022-09-15 11:28:41 +02:00
2025-07-07 10:09:36 +02:00
const result = { } ;
2022-09-15 11:28:41 +02:00
2025-07-07 10:09:36 +02:00
if ( options . system ) result . system = await readSystemFromGraphite ( options ) ;
2025-07-07 20:09:09 +02:00
for ( const appId of ( options . appIds || [ ] ) ) {
2025-07-07 10:09:36 +02:00
result [ appId ] = await getContainer ( appId , options ) ;
2022-10-12 22:08:10 +02:00
}
2025-07-07 20:09:09 +02:00
for ( const serviceId of ( options . serviceIds || [ ] ) ) {
2025-07-07 10:09:36 +02:00
result [ serviceId ] = await getContainer ( serviceId , options ) ;
2022-10-13 20:32:36 +02:00
}
2025-07-07 10:09:36 +02:00
return result ;
2022-09-15 11:28:41 +02:00
}
2025-05-21 17:15:04 +02:00
2025-07-08 17:50:20 +02:00
async function pipeContainerToMap ( name , statsMap ) {
2025-07-07 15:53:09 +02:00
assert . strictEqual ( typeof name , 'string' ) ;
2025-07-08 16:45:55 +02:00
assert . ok ( statsMap instanceof Map ) ;
2025-05-22 11:17:31 +02:00
2025-07-07 15:53:09 +02:00
// we used to poll before instead of a stream. but docker caches metrics internally and rate logic has to handle dups
2025-07-08 18:08:43 +02:00
const statsStream = await docker . getStats ( name , { stream : true } ) ;
2025-07-07 15:53:09 +02:00
2026-03-12 22:55:28 +05:30
statsStream . on ( 'error' , ( error ) => log ( error ) ) ;
2025-07-07 15:53:09 +02:00
statsStream . on ( 'data' , ( data ) => {
const stats = JSON . parse ( data . toString ( 'utf8' ) ) ;
const metrics = translateContainerStatsSync ( stats ) ;
2025-10-06 23:05:49 +02:00
if ( ! metrics ) { // maybe the container stopped
statsMap . delete ( name ) ;
return ;
}
2025-07-07 15:53:09 +02:00
const { ts , networkRead , networkWrite , blockRead , blockWrite , memoryUsed , cpuUsageMsecs } = metrics ;
2025-07-08 16:45:55 +02:00
const oldMetrics = statsMap . get ( name ) ? . raw || null ;
2025-07-07 15:53:09 +02:00
const gap = oldMetrics ? ( ts - oldMetrics . ts ) : null ;
const cpuPercent = oldMetrics ? ( cpuUsageMsecs - oldMetrics . cpuUsageMsecs ) * 100 / gap : null ;
const blockReadRate = oldMetrics ? ( blockRead - oldMetrics . blockRead ) / ( gap / 1000 ) : null ;
const blockWriteRate = oldMetrics ? ( blockWrite - oldMetrics . blockWrite ) / ( gap / 1000 ) : null ;
const networkReadRate = oldMetrics ? ( networkRead - oldMetrics . networkRead ) / ( gap / 1000 ) : null ;
const networkWriteRate = oldMetrics ? ( networkWrite - oldMetrics . networkWrite ) / ( gap / 1000 ) : null ;
2025-07-08 16:45:55 +02:00
const nowSecs = ts . getTime ( ) / 1000 ; // convert to secs to match graphite return value but this is thrown away and patched during streaming time
statsMap . set ( name , {
raw : metrics ,
2025-07-07 15:53:09 +02:00
cpu : [ cpuPercent , nowSecs ] ,
memory : [ memoryUsed , nowSecs ] ,
blockReadRate : [ blockReadRate , nowSecs ] ,
blockWriteRate : [ blockWriteRate , nowSecs ] ,
blockReadTotal : metrics . blockRead ,
blockWriteTotal : metrics . blockWrite ,
networkReadRate : [ networkReadRate , nowSecs ] ,
networkWriteRate : [ networkWriteRate , nowSecs ] ,
networkReadTotal : metrics . networkRead ,
networkWriteTotal : metrics . networkWrite ,
2025-07-08 16:45:55 +02:00
} ) ;
2025-05-21 17:15:04 +02:00
} ) ;
2025-07-08 17:50:20 +02:00
return statsStream ;
2025-07-07 15:53:09 +02:00
}
2025-07-08 16:45:55 +02:00
async function pipeSystemToMap ( statsMap ) {
assert . ok ( statsMap instanceof Map ) ;
2025-07-07 15:53:09 +02:00
2025-07-08 18:08:43 +02:00
const metrics = await readSystemMetrics ( ) ;
2025-07-08 16:45:55 +02:00
const oldMetrics = statsMap . get ( 'system' ) ? . raw || null ;
2025-07-01 11:49:37 +02:00
2025-07-08 16:45:55 +02:00
const gap = oldMetrics ? ( metrics . ts - oldMetrics . ts ) : null ;
2025-05-21 17:15:04 +02:00
2025-07-08 16:45:55 +02:00
const cpuPercent = oldMetrics ? ( metrics . userMsecs + metrics . sysMsecs - oldMetrics . userMsecs - oldMetrics . sysMsecs ) * 100 / gap : null ;
const blockReadRate = oldMetrics ? ( metrics . blockRead - oldMetrics . blockRead ) / ( gap / 1000 ) : null ;
const blockWriteRate = oldMetrics ? ( metrics . blockWrite - oldMetrics . blockWrite ) / ( gap / 1000 ) : null ;
const networkReadRate = oldMetrics ? ( metrics . networkRead - oldMetrics . networkRead ) / ( gap / 1000 ) : null ;
const networkWriteRate = oldMetrics ? ( metrics . networkWrite - oldMetrics . networkWrite ) / ( gap / 1000 ) : null ;
2025-05-21 17:15:04 +02:00
2025-07-08 16:45:55 +02:00
const nowSecs = Date . now ( ) / 1000 ; // convert to secs to match graphite return value but this is thrown away and patched during streaming time
const systemStats = {
raw : metrics ,
cpu : [ cpuPercent , nowSecs ] ,
memory : [ metrics . memoryUsed , nowSecs ] ,
swap : [ metrics . swapUsed , nowSecs ] ,
2025-07-01 22:32:59 +02:00
2025-07-08 16:45:55 +02:00
blockReadRate : [ blockReadRate , nowSecs ] ,
blockWriteRate : [ blockWriteRate , nowSecs ] ,
blockReadTotal : metrics . blockRead ,
blockWriteTotal : metrics . blockWrite ,
2025-07-01 22:32:59 +02:00
2025-07-08 16:45:55 +02:00
networkReadRate : [ networkReadRate , nowSecs ] ,
networkWriteRate : [ networkWriteRate , nowSecs ] ,
networkReadTotal : metrics . networkRead ,
networkWriteTotal : metrics . networkWrite ,
} ;
statsMap . set ( 'system' , systemStats ) ;
2025-05-21 17:15:04 +02:00
}
2025-07-01 11:49:37 +02:00
2025-07-07 15:53:09 +02:00
async function getStream ( options ) {
2025-07-01 11:49:37 +02:00
assert . strictEqual ( typeof options , 'object' ) ;
2025-07-08 16:45:55 +02:00
const statsMap = new Map ( ) ;
2025-07-08 17:50:20 +02:00
let intervalId = null , containerStreamPromises = [ ] ;
2025-07-01 11:49:37 +02:00
const metricsStream = new Readable ( {
2025-07-04 22:42:05 +02:00
objectMode : true ,
2025-07-01 11:49:37 +02:00
read ( /*size*/ ) { /* ignored, we push via interval */ } ,
destroy ( error , callback ) {
2025-07-08 16:45:55 +02:00
clearInterval ( intervalId ) ;
2025-07-08 18:07:19 +02:00
containerStreamPromises . forEach ( cs => { if ( cs . status === 'fulfilled' && cs . value ) cs . value . destroy ( error ) ; } ) ;
2025-07-01 11:49:37 +02:00
callback ( error ) ;
}
} ) ;
2025-07-08 16:45:55 +02:00
const containerNames = ( options . appIds || [ ] ) . concat ( options . serviceIds || [ ] ) ;
2025-07-08 17:50:20 +02:00
containerStreamPromises = await Promise . allSettled ( containerNames . map ( containerName => pipeContainerToMap ( containerName , statsMap ) ) ) ;
2025-07-01 22:32:59 +02:00
2025-07-08 16:45:55 +02:00
const INTERVAL _MSECS = 1000 ;
intervalId = setInterval ( async ( ) => {
2026-03-12 22:55:28 +05:30
if ( options . system ) await safe ( pipeSystemToMap ( statsMap ) , { debug : log } ) ;
2025-07-08 16:45:55 +02:00
const result = { } ;
const nowSecs = Date . now ( ) / 1000 ; // to match graphite return value
// patch the stats to have the current timestamp
for ( const [ id , stats ] of statsMap ) {
result [ id ] = structuredClone ( _ . omit ( stats , [ 'raw' ] ) ) ;
for ( const statValue of Object . values ( result [ id ] ) ) {
if ( Array . isArray ( statValue ) ) statValue [ 1 ] = nowSecs ;
}
}
metricsStream . push ( result ) ;
} , INTERVAL _MSECS ) ;
2025-07-01 11:49:37 +02:00
return metricsStream ;
}
2026-02-14 15:43:24 +01:00
export default {
get ,
getStream ,
sendToGraphite
} ;