@flame0510/project-aether 1.10.0 → 1.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -2
- package/app/agents/CostSection.tsx +12 -10
- package/app/agents/PageClient.tsx +7 -0
- package/app/agents/PanelRow.tsx +9 -0
- package/app/agents/ResourceSection.tsx +105 -0
- package/app/api/assistant/route.ts +2 -2
- package/app/api/containers/route.ts +18 -2
- package/app/api/costs/agent/route.ts +20 -3
- package/app/api/gateway/provider/route.ts +20 -2
- package/app/api/metrics/alerts/route.ts +47 -0
- package/app/api/metrics/containers/route.ts +54 -0
- package/app/api/metrics/route.ts +4 -6
- package/app/api/update-check/route.ts +20 -11
- package/app/components/ui/Accordion.tsx +45 -0
- package/app/components/ui/TimeSeriesChart.tsx +20 -2
- package/app/components/ui/index.ts +1 -0
- package/app/containers/ContainersClient.tsx +48 -6
- package/app/gateway/ModelDetailsModal.tsx +4 -2
- package/app/gateway/PageClient.tsx +86 -36
- package/app/globals.css +36 -0
- package/app/system/AgentCharts.tsx +138 -0
- package/app/system/AgentsSection.tsx +281 -0
- package/app/system/PageClient.tsx +11 -9
- package/app/system/RecentAlerts.tsx +72 -0
- package/app/system/SystemSkeleton.tsx +53 -1
- package/app/system/loading.tsx +5 -1
- package/bin/postinstall.js +7 -4
- package/bin/rev4a.js +6 -7
- package/daemon.js +195 -4
- package/docs/ARCHITECTURE.md +36 -5
- package/docs/FRONTEND-ARCHITECTURE.md +13 -4
- package/docs/REV4A.md +9 -6
- package/docs/dev/API-REFERENCE.md +116 -30
- package/docs/dev/DATABASE.md +58 -2
- package/docs/dev/GATEWAY.md +13 -4
- package/docs/rag/DATA-FRESHNESS.md +16 -1
- package/docs/rag/GLOSSARY.md +3 -3
- package/docs/rag/REV4A-OVERVIEW.md +9 -5
- package/docs/rag/WHAT-I-CAN-ANSWER.md +4 -1
- package/lib/container-metrics.ts +340 -0
- package/lib/docker-socket-path.d.ts +9 -0
- package/lib/docker-socket-path.js +133 -0
- package/lib/docker-socket.ts +10 -99
- package/lib/docker-stats.d.ts +88 -0
- package/lib/docker-stats.js +284 -0
- package/lib/metrics-db.ts +15 -6
- package/lib/model-details.ts +3 -3
- package/lib/model-pricing.ts +6 -4
- package/lib/utils/format.ts +29 -3
- package/model-details.json +2799 -2274
- package/model-pricing.json +63 -48
- package/models.config.json +41 -10
- package/npm-shrinkwrap.json +1979 -0
- package/package.json +11 -9
- package/scripts/check-language.mjs +1 -1
- package/scripts/check-package-types.mjs +74 -0
- package/scripts/test-docker-stats.mjs +270 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flame0510/project-aether",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.1",
|
|
4
4
|
"description": "Rev4a — Revolution for Agents. OpenClaw agent fleet orchestrator.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"openclaw",
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"terminal-ws-server.js",
|
|
40
40
|
"server.js",
|
|
41
41
|
"package.json",
|
|
42
|
+
"npm-shrinkwrap.json",
|
|
42
43
|
"proxy.ts",
|
|
43
44
|
"instrumentation.ts",
|
|
44
45
|
"next.config.mjs",
|
|
@@ -54,6 +55,7 @@
|
|
|
54
55
|
"dev": "next dev -p 3720",
|
|
55
56
|
"build": "next build",
|
|
56
57
|
"typecheck": "tsc --noEmit",
|
|
58
|
+
"check:package-types": "node scripts/check-package-types.mjs",
|
|
57
59
|
"start": "rev4a serve",
|
|
58
60
|
"start:all": "rev4a serve",
|
|
59
61
|
"refresh:pricing": "node scripts/refresh-model-pricing.mjs",
|
|
@@ -64,6 +66,11 @@
|
|
|
64
66
|
},
|
|
65
67
|
"dependencies": {
|
|
66
68
|
"@types/better-sqlite3": "^7.6.13",
|
|
69
|
+
"@types/d3": "7.4.3",
|
|
70
|
+
"@types/node": "25.9.5",
|
|
71
|
+
"@types/react": "19.2.17",
|
|
72
|
+
"@types/react-dom": "19.2.3",
|
|
73
|
+
"@types/ws": "8.18.1",
|
|
67
74
|
"@xterm/addon-fit": "^0.11.0",
|
|
68
75
|
"@xterm/addon-web-links": "^0.12.0",
|
|
69
76
|
"@xterm/xterm": "^6.0.0",
|
|
@@ -75,19 +82,14 @@
|
|
|
75
82
|
"html2canvas": "^1.4.1",
|
|
76
83
|
"jose": "^6.2.3",
|
|
77
84
|
"marked": "^18.0.5",
|
|
78
|
-
"next": "
|
|
85
|
+
"next": "16.2.6",
|
|
79
86
|
"node-pty": "^1.1.0",
|
|
80
87
|
"react": "^19.2.0",
|
|
81
88
|
"react-dom": "^19.2.0",
|
|
89
|
+
"typescript": "6.0.3",
|
|
82
90
|
"ws": "^8.21.0"
|
|
83
91
|
},
|
|
84
92
|
"devDependencies": {
|
|
85
|
-
"@playwright/test": "^1.61.1"
|
|
86
|
-
"@types/d3": "^7.4.3",
|
|
87
|
-
"@types/node": "25.9.5",
|
|
88
|
-
"@types/react": "19.2.17",
|
|
89
|
-
"@types/react-dom": "^19.2.3",
|
|
90
|
-
"@types/ws": "^8.18.1",
|
|
91
|
-
"typescript": "6.0.3"
|
|
93
|
+
"@playwright/test": "^1.61.1"
|
|
92
94
|
}
|
|
93
95
|
}
|
|
@@ -57,7 +57,7 @@ const WORDS = [
|
|
|
57
57
|
|
|
58
58
|
const EXTENSIONS = ['.ts', '.tsx', '.js', '.mjs', '.cjs', '.md', '.json', '.sh', '.yml', '.yaml', '.css', '.html'];
|
|
59
59
|
/** Lockfiles: generated, huge, and never the place a word is written by hand. */
|
|
60
|
-
const IGNORED = new Set(['package-lock.json', 'scripts/check-language.mjs']);
|
|
60
|
+
const IGNORED = new Set(['package-lock.json', 'npm-shrinkwrap.json', 'scripts/check-language.mjs']);
|
|
61
61
|
const MARKER = 'lang-ok';
|
|
62
62
|
|
|
63
63
|
const staged = process.argv.includes('--staged');
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Typecheck the npm tarball from a package path under node_modules. TypeScript resolves
|
|
4
|
+
* CommonJS imports differently there than in the repository, so a local build alone
|
|
5
|
+
* cannot catch a missing sibling .d.ts file.
|
|
6
|
+
*/
|
|
7
|
+
import { execFileSync } from 'node:child_process';
|
|
8
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync } from 'node:fs';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { dirname, join, resolve } from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
|
|
13
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
14
|
+
const { name } = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
|
|
15
|
+
const temp = mkdtempSync(join(tmpdir(), 'rev4a-package-types-'));
|
|
16
|
+
// Runs on a normal exit and on process.exit(1); a finally block would not see the latter.
|
|
17
|
+
process.on('exit', () => rmSync(temp, { recursive: true, force: true }));
|
|
18
|
+
|
|
19
|
+
const [packed] = JSON.parse(execFileSync('npm', ['pack', '--json', '--pack-destination', temp], {
|
|
20
|
+
cwd: root,
|
|
21
|
+
encoding: 'utf8',
|
|
22
|
+
}));
|
|
23
|
+
const packageDir = join(temp, 'node_modules', ...name.split('/'));
|
|
24
|
+
mkdirSync(packageDir, { recursive: true });
|
|
25
|
+
execFileSync('tar', ['-xzf', join(temp, packed.filename), '-C', packageDir, '--strip-components=1']);
|
|
26
|
+
|
|
27
|
+
const packedManifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8'));
|
|
28
|
+
const shrinkwrapPath = join(packageDir, 'npm-shrinkwrap.json');
|
|
29
|
+
if (!existsSync(shrinkwrapPath)) {
|
|
30
|
+
console.error('npm-shrinkwrap.json is missing from the npm tarball.');
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
const shrinkwrap = JSON.parse(readFileSync(shrinkwrapPath, 'utf8'));
|
|
34
|
+
if (shrinkwrap.version !== packedManifest.version || shrinkwrap.packages?.['']?.version !== packedManifest.version) {
|
|
35
|
+
console.error('The published dependency lock does not match the package version.');
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
const buildDependencies = [
|
|
39
|
+
'typescript', '@types/node', '@types/react', '@types/react-dom',
|
|
40
|
+
'@types/d3', '@types/ws', '@types/better-sqlite3',
|
|
41
|
+
];
|
|
42
|
+
const missingBuildDependencies = buildDependencies.filter((dep) => !packedManifest.dependencies?.[dep] || !shrinkwrap.packages?.[`node_modules/${dep}`]);
|
|
43
|
+
if (missingBuildDependencies.length) {
|
|
44
|
+
console.error(`Global builds need these production dependencies: ${missingBuildDependencies.join(', ')}`);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
if (packedManifest.dependencies.next !== shrinkwrap.packages?.['node_modules/next']?.version) {
|
|
48
|
+
console.error('Pin Next.js to the version in the published dependency lock.');
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const libDir = join(packageDir, 'lib');
|
|
53
|
+
function missingDeclarations(dir) {
|
|
54
|
+
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
55
|
+
const file = join(dir, entry.name);
|
|
56
|
+
if (entry.isDirectory()) return missingDeclarations(file);
|
|
57
|
+
if (!/\.(?:js|mjs)$/.test(entry.name)) return [];
|
|
58
|
+
return existsSync(file.replace(/\.(?:js|mjs)$/, '.d.ts')) ? [] : [file.slice(libDir.length + 1)];
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
const missing = missingDeclarations(libDir);
|
|
62
|
+
if (missing.length) {
|
|
63
|
+
console.error(`Missing declaration files in the npm tarball: ${missing.join(', ')}`);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Use the checkout's installed dependencies, but keep the project itself under
|
|
68
|
+
// node_modules so TS follows the same package-resolution path as the global install.
|
|
69
|
+
symlinkSync(join(root, 'node_modules'), join(packageDir, 'node_modules'), 'dir');
|
|
70
|
+
execFileSync(process.execPath, [join(root, 'node_modules', 'typescript', 'bin', 'tsc'), '--noEmit', '--pretty', 'false'], {
|
|
71
|
+
cwd: packageDir,
|
|
72
|
+
stdio: 'inherit',
|
|
73
|
+
});
|
|
74
|
+
console.log(`Package-path typecheck passed for ${name}.`);
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Tests for lib/docker-stats.js — the parsing, the rate arithmetic and the container sampler
|
|
4
|
+
* behind the System page's per-container consumption. No Docker, no database: the sampler is
|
|
5
|
+
* given a fake Docker. Run it with
|
|
6
|
+
*
|
|
7
|
+
* node scripts/test-docker-stats.mjs
|
|
8
|
+
*
|
|
9
|
+
* and it exits non-zero on the first failing case. (Not wired into CI: the repo has no test
|
|
10
|
+
* runner yet — see plans for the Playwright work — so this is a script, like check-language.)
|
|
11
|
+
*/
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
|
|
17
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
18
|
+
const S = createRequire(import.meta.url)(path.join(ROOT, 'lib', 'docker-stats.js'));
|
|
19
|
+
|
|
20
|
+
let passed = 0;
|
|
21
|
+
async function test(name, fn) {
|
|
22
|
+
try {
|
|
23
|
+
await fn();
|
|
24
|
+
passed++;
|
|
25
|
+
console.log(`ok ${name}`);
|
|
26
|
+
} catch (e) {
|
|
27
|
+
console.error(`FAIL ${name}\n${e?.stack || e}`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ── parsing ──────────────────────────────────────────────────────────────────
|
|
33
|
+
await test('working set: usage minus inactive_file (cgroup v2)', () => {
|
|
34
|
+
assert.equal(S.workingSetBytes({ usage: 1000, stats: { inactive_file: 300 } }), 700);
|
|
35
|
+
});
|
|
36
|
+
await test('working set: total_inactive_file (cgroup v1)', () => {
|
|
37
|
+
assert.equal(S.workingSetBytes({ usage: 1000, stats: { total_inactive_file: 400 } }), 600);
|
|
38
|
+
});
|
|
39
|
+
await test('working set never negative, null when unknown', () => {
|
|
40
|
+
assert.equal(S.workingSetBytes({ usage: 100, stats: { inactive_file: 500 } }), 0);
|
|
41
|
+
assert.equal(S.workingSetBytes({}), null);
|
|
42
|
+
});
|
|
43
|
+
await test('network and block I/O sums, v1 and v2 op names', () => {
|
|
44
|
+
assert.deepEqual(S.sumNetwork({ a: { rx_bytes: 1, tx_bytes: 2 }, b: { rx_bytes: 10, tx_bytes: 20 } }), { rx: 11, tx: 22 });
|
|
45
|
+
assert.deepEqual(S.sumNetwork(undefined), { rx: null, tx: null });
|
|
46
|
+
assert.deepEqual(S.sumBlockIo({ io_service_bytes_recursive: [{ op: 'read', value: 5 }, { op: 'Write', value: 7 }, { op: 'sync', value: 9 }] }), { rd: 5, wr: 7 });
|
|
47
|
+
});
|
|
48
|
+
await test('stats without a CPU figure (stopped, just started) give no counters', () => {
|
|
49
|
+
assert.equal(S.readCounters({}, 1), null);
|
|
50
|
+
});
|
|
51
|
+
await test('cleanName strips control characters and falls back', () => {
|
|
52
|
+
assert.equal(S.cleanName('a\tb\nc', 'x'), 'a b c');
|
|
53
|
+
assert.equal(S.cleanName(' ', 'x'), 'x');
|
|
54
|
+
});
|
|
55
|
+
await test('primaryName: the name with one leading slash, whatever the order (legacy links)', () => {
|
|
56
|
+
assert.equal(S.primaryName(['/db']), 'db');
|
|
57
|
+
assert.equal(S.primaryName(['/app/db', '/db']), 'db');
|
|
58
|
+
assert.equal(S.primaryName(['/db', '/app/db']), 'db');
|
|
59
|
+
assert.equal(S.primaryName(['/app/db']), '');
|
|
60
|
+
assert.equal(S.primaryName(undefined), '');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ── rates ────────────────────────────────────────────────────────────────────
|
|
64
|
+
const c1 = { t: 0, cpuNs: 0, rx: 100, tx: 100, rd: 0, wr: 0 };
|
|
65
|
+
const c2 = { t: 10_000, cpuNs: 2e9, rx: 600, tx: 100, rd: 1000, wr: 0 };
|
|
66
|
+
await test('rates: 2 CPU-seconds in 10 s is 0.2 cores; bytes per second', () => {
|
|
67
|
+
const r = S.ratesBetween(c1, c2);
|
|
68
|
+
assert.equal(r.cpuCores, 0.2);
|
|
69
|
+
assert.equal(r.rxBps, 50);
|
|
70
|
+
assert.equal(r.txBps, 0);
|
|
71
|
+
assert.equal(r.rdBps, 100);
|
|
72
|
+
});
|
|
73
|
+
await test('rates: no earlier reading, or a window under a second, is null', () => {
|
|
74
|
+
assert.deepEqual(S.ratesBetween(null, c2), { cpuCores: null, rxBps: null, txBps: null, rdBps: null, wrBps: null });
|
|
75
|
+
assert.equal(S.ratesBetween(c2, { ...c2, t: 10_500 }).cpuCores, null);
|
|
76
|
+
});
|
|
77
|
+
await test('rates: a counter going backwards is null on its own, the others are kept', () => {
|
|
78
|
+
const r = S.ratesBetween(c2, { t: 20_000, cpuNs: 1e9, rx: 700, tx: 5, rd: 2000, wr: 0 });
|
|
79
|
+
assert.equal(r.cpuCores, null);
|
|
80
|
+
assert.equal(r.txBps, null);
|
|
81
|
+
assert.equal(r.rxBps, 10);
|
|
82
|
+
});
|
|
83
|
+
await test('rates: a window over the limit is null (a laptop that slept is not a near-zero CPU)', () => {
|
|
84
|
+
const slept = { ...c2, t: 8 * 3600 * 1000, cpuNs: 2e9 + 1e9 };
|
|
85
|
+
assert.ok(S.ratesBetween(c2, slept).cpuCores > 0, 'without a limit it would invent a tiny figure');
|
|
86
|
+
assert.equal(S.ratesBetween(c2, slept, 180).cpuCores, null);
|
|
87
|
+
assert.equal(S.ratesBetween(c1, c2, 180).cpuCores, 0.2);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// ── the sampler, against a fake Docker ───────────────────────────────────────
|
|
91
|
+
function fakeDocker() {
|
|
92
|
+
const d = {
|
|
93
|
+
clock: 1_000_000_000_000,
|
|
94
|
+
calls: [],
|
|
95
|
+
list: [
|
|
96
|
+
{ Id: 'A', Names: ['/agent_a'], Labels: { AGENT_ID: 'agent_a' }, Mounts: [{ Type: 'volume', Name: 'vol-a' }, { Type: 'bind' }] },
|
|
97
|
+
{ Id: 'B', Names: ['/registry'], Labels: {}, Mounts: [] },
|
|
98
|
+
{ Id: 'C', Names: ['/bad name!'], Labels: {} },
|
|
99
|
+
],
|
|
100
|
+
stats: { A: { cpu: 0, rx: 0 }, B: { cpu: 0, rx: 0 } },
|
|
101
|
+
failStats: new Set(),
|
|
102
|
+
inspectDelayMs: 0,
|
|
103
|
+
};
|
|
104
|
+
d.request = async (_method, p) => {
|
|
105
|
+
d.calls.push(p);
|
|
106
|
+
if (p === '/containers/json') return d.list;
|
|
107
|
+
if (p === '/info') return { NCPU: 4, MemTotal: 4 * 1024 * 1024 * 1024 };
|
|
108
|
+
const inspect = /^\/containers\/(\w+)\/json$/.exec(p);
|
|
109
|
+
if (inspect) {
|
|
110
|
+
if (d.inspectDelayMs) await new Promise((r) => setTimeout(r, d.inspectDelayMs));
|
|
111
|
+
return { Config: { Env: ['X=1', 'AGENT_NAME=My\tAgent\nTwo'] } };
|
|
112
|
+
}
|
|
113
|
+
const id = /containers\/(\w+)\/stats/.exec(p)?.[1];
|
|
114
|
+
if (d.failStats.has(id)) throw new Error('boom');
|
|
115
|
+
const s = d.stats[id];
|
|
116
|
+
if (!s) return {};
|
|
117
|
+
return {
|
|
118
|
+
cpu_stats: { cpu_usage: { total_usage: s.cpu } },
|
|
119
|
+
memory_stats: { usage: 200 * 1048576, stats: { inactive_file: 100 * 1048576 } },
|
|
120
|
+
pids_stats: { current: 7 },
|
|
121
|
+
networks: { eth0: { rx_bytes: s.rx, tx_bytes: 0 } },
|
|
122
|
+
};
|
|
123
|
+
};
|
|
124
|
+
return d;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
await test('sampler: the first sample primes (gauges kept, rates null); invalid names skipped; host read', async () => {
|
|
128
|
+
const d = fakeDocker();
|
|
129
|
+
const sampler = S.createContainerSampler({ request: d.request, now: () => d.clock });
|
|
130
|
+
const a = await sampler.sample();
|
|
131
|
+
assert.equal(a.rows.length, 2);
|
|
132
|
+
assert.equal(a.rows[0].cpu_cores, null);
|
|
133
|
+
assert.equal(a.rows[0].mem_mb, 100);
|
|
134
|
+
assert.equal(a.rows[0].pids, 7);
|
|
135
|
+
assert.ok(!a.rows.some((r) => r.container === 'bad name!'));
|
|
136
|
+
assert.deepEqual(a.host, { ncpu: 4, memTotalMb: 4096 });
|
|
137
|
+
});
|
|
138
|
+
await test('sampler: agent name cleaned, volumes from mounts, non-agents flagged', async () => {
|
|
139
|
+
const d = fakeDocker();
|
|
140
|
+
const a = await S.createContainerSampler({ request: d.request, now: () => d.clock }).sample();
|
|
141
|
+
const src = a.sources.find((x) => x.container === 'agent_a');
|
|
142
|
+
assert.equal(src.name, 'My Agent Two');
|
|
143
|
+
assert.equal(src.volumes, 'vol-a');
|
|
144
|
+
assert.equal(src.is_agent, 1);
|
|
145
|
+
assert.equal(a.sources.find((x) => x.container === 'registry').is_agent, 0);
|
|
146
|
+
});
|
|
147
|
+
await test('sampler: a container listed under a legacy-link alias is still sampled', async () => {
|
|
148
|
+
const d = fakeDocker();
|
|
149
|
+
d.list = [{ Id: 'B', Names: ['/app/registry', '/registry'], Labels: {}, Mounts: [] }];
|
|
150
|
+
const a = await S.createContainerSampler({ request: d.request, now: () => d.clock }).sample();
|
|
151
|
+
assert.deepEqual(a.rows.map((r) => r.container), ['registry']);
|
|
152
|
+
});
|
|
153
|
+
await test('sampler: second sample gives the rates; an agent is inspected once', async () => {
|
|
154
|
+
const d = fakeDocker();
|
|
155
|
+
const sampler = S.createContainerSampler({ request: d.request, now: () => d.clock });
|
|
156
|
+
await sampler.sample();
|
|
157
|
+
d.clock += 60_000;
|
|
158
|
+
d.stats.A.cpu = 6e9;
|
|
159
|
+
d.stats.A.rx = 6000;
|
|
160
|
+
const a = await sampler.sample();
|
|
161
|
+
const row = a.rows.find((r) => r.container === 'agent_a');
|
|
162
|
+
assert.equal(row.cpu_cores, 0.1);
|
|
163
|
+
assert.equal(row.net_rx_bps, 100);
|
|
164
|
+
assert.equal(d.calls.filter((p) => p === '/containers/A/json').length, 1);
|
|
165
|
+
});
|
|
166
|
+
await test('sampler: a counter reset (container restarted) gives no CPU figure, never a spike', async () => {
|
|
167
|
+
const d = fakeDocker();
|
|
168
|
+
const sampler = S.createContainerSampler({ request: d.request, now: () => d.clock });
|
|
169
|
+
await sampler.sample();
|
|
170
|
+
d.clock += 60_000; d.stats.A.cpu = 6e9; await sampler.sample();
|
|
171
|
+
d.clock += 60_000; d.stats.A.cpu = 1e9;
|
|
172
|
+
const a = await sampler.sample();
|
|
173
|
+
assert.equal(a.rows.find((r) => r.container === 'agent_a').cpu_cores, null);
|
|
174
|
+
});
|
|
175
|
+
await test('sampler: a long gap between passes gives no CPU figure, the next pass does', async () => {
|
|
176
|
+
const d = fakeDocker();
|
|
177
|
+
const sampler = S.createContainerSampler({ request: d.request, now: () => d.clock, maxWindowS: 180 });
|
|
178
|
+
await sampler.sample();
|
|
179
|
+
d.clock += 8 * 3600 * 1000; d.stats.A.cpu = 3e9;
|
|
180
|
+
assert.equal((await sampler.sample()).rows.find((r) => r.container === 'agent_a').cpu_cores, null);
|
|
181
|
+
d.clock += 60_000; d.stats.A.cpu = 9e9;
|
|
182
|
+
assert.equal((await sampler.sample()).rows.find((r) => r.container === 'agent_a').cpu_cores, 0.1);
|
|
183
|
+
});
|
|
184
|
+
await test('sampler: a stats call that fails skips that container only, and it keeps its counters', async () => {
|
|
185
|
+
const d = fakeDocker();
|
|
186
|
+
const sampler = S.createContainerSampler({ request: d.request, now: () => d.clock });
|
|
187
|
+
await sampler.sample();
|
|
188
|
+
d.clock += 60_000; d.stats.B.cpu = 1e9;
|
|
189
|
+
d.failStats.add('B');
|
|
190
|
+
const failed = await sampler.sample();
|
|
191
|
+
assert.deepEqual(failed.rows.map((r) => r.container), ['agent_a']);
|
|
192
|
+
// The failed container was still seen: it stays on the page (as stopped) instead of vanishing.
|
|
193
|
+
assert.deepEqual(failed.sources.map((r) => r.container), ['agent_a', 'registry']);
|
|
194
|
+
assert.equal(failed.sources.find((r) => r.container === 'registry').name, 'registry');
|
|
195
|
+
d.failStats.delete('B');
|
|
196
|
+
d.clock += 60_000; d.stats.B.cpu = 2e9;
|
|
197
|
+
const back = await sampler.sample();
|
|
198
|
+
// 2 CPU-seconds since the last good reading, 120 s ago: the failed pass did not lose the counters.
|
|
199
|
+
assert.equal(back.rows.find((r) => r.container === 'registry').cpu_cores, 0.0167);
|
|
200
|
+
});
|
|
201
|
+
await test('sampler: the agent name is fetched beside the stats call, not after it', async () => {
|
|
202
|
+
const d = fakeDocker();
|
|
203
|
+
d.list = Array.from({ length: 4 }, (_, i) => ({ Id: `A${i}`, Names: [`/agent_${i}`], Labels: { AGENT_ID: `agent_${i}` }, Mounts: [] }));
|
|
204
|
+
d.stats = Object.fromEntries(d.list.map((c) => [c.Id, { cpu: 0, rx: 0 }]));
|
|
205
|
+
d.inspectDelayMs = 150;
|
|
206
|
+
const t0 = Date.now();
|
|
207
|
+
await S.createContainerSampler({ request: d.request, now: () => d.clock }).sample();
|
|
208
|
+
assert.ok(Date.now() - t0 < 4 * 150, `4 inspects of 150 ms took ${Date.now() - t0} ms: they ran one after another`);
|
|
209
|
+
});
|
|
210
|
+
await test('sampler: no containers is an empty pass; Docker down throws', async () => {
|
|
211
|
+
const d = fakeDocker();
|
|
212
|
+
d.list = [];
|
|
213
|
+
assert.equal((await S.createContainerSampler({ request: d.request, now: () => d.clock }).sample()).rows.length, 0);
|
|
214
|
+
await assert.rejects(S.createContainerSampler({ request: async () => { throw new Error('down'); } }).sample(), /down/);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// ── /system/df ───────────────────────────────────────────────────────────────
|
|
218
|
+
await test('parseStorage: volumes (unused flagged), layers by primary name, images and cache with what nothing uses', () => {
|
|
219
|
+
const df = S.parseStorage({
|
|
220
|
+
LayersSize: 5000,
|
|
221
|
+
Volumes: [
|
|
222
|
+
{ Name: 'v-used', UsageData: { Size: 100, RefCount: 1 } },
|
|
223
|
+
{ Name: 'v-unused', UsageData: { Size: 700, RefCount: 0 } },
|
|
224
|
+
{ Name: 'v-unsized', UsageData: { Size: -1, RefCount: 1 } },
|
|
225
|
+
],
|
|
226
|
+
Containers: [{ Names: ['/app/agent_a', '/agent_a'], SizeRw: 10 }, { Names: ['/bad name'], SizeRw: 1 }],
|
|
227
|
+
Images: [{ Size: 3000, Containers: 2 }, { Size: 1500, Containers: 0 }],
|
|
228
|
+
BuildCache: [{ Size: 400, InUse: false }, { Size: 50, InUse: true }],
|
|
229
|
+
});
|
|
230
|
+
assert.deepEqual(df.volumes.map((v) => [v.name, v.sizeBytes, v.unused]), [['v-used', 100, false], ['v-unused', 700, true], ['v-unsized', null, false]]);
|
|
231
|
+
assert.deepEqual(df.layers, [{ container: 'agent_a', sizeBytes: 10 }]);
|
|
232
|
+
assert.deepEqual(df.images, { sizeBytes: 5000, reclaimableBytes: 1500 });
|
|
233
|
+
assert.deepEqual(df.buildCache, { sizeBytes: 450, reclaimableBytes: 400 });
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
await test('parseStorage: an error body is not a df answer — null, never zeros', () => {
|
|
237
|
+
assert.equal(S.parseStorage(null), null);
|
|
238
|
+
assert.equal(S.parseStorage({}), null);
|
|
239
|
+
assert.equal(S.parseStorage({ message: 'error processing /system/df' }), null);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
await test('dockerRequestJson: an HTTP error is not data — error status rejects, valid 200 resolves', async () => {
|
|
243
|
+
const http = (await import('node:http')).default;
|
|
244
|
+
const fs = (await import('node:fs')).default;
|
|
245
|
+
const os = (await import('node:os')).default;
|
|
246
|
+
const socket = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'docker-sock-')), 'docker.sock');
|
|
247
|
+
const answers = new Map([
|
|
248
|
+
['/error.json', [500, '{"message":"boom"}']],
|
|
249
|
+
['/missing', [404, '{"message":"no such thing"}']],
|
|
250
|
+
['/good', [200, '{"fine":true}']],
|
|
251
|
+
]);
|
|
252
|
+
const server = http.createServer((req, res) => {
|
|
253
|
+
const [code, body] = answers.get(req.url) ?? [500, '{}'];
|
|
254
|
+
res.writeHead(code, { 'Content-Type': 'application/json' });
|
|
255
|
+
res.end(body);
|
|
256
|
+
});
|
|
257
|
+
await new Promise((resolve) => server.listen(socket, resolve));
|
|
258
|
+
// First resolution of the process: the env wins, so the helper talks to this fake.
|
|
259
|
+
process.env.DOCKER_HOST = `unix://${socket}`;
|
|
260
|
+
const { dockerRequestJson } = createRequire(import.meta.url)(path.join(ROOT, 'lib', 'docker-socket-path.js'));
|
|
261
|
+
try {
|
|
262
|
+
await assert.rejects(dockerRequestJson('GET', '/error.json'), /^Error: Docker error 500: \{"message":"boom"\}$/);
|
|
263
|
+
await assert.rejects(dockerRequestJson('GET', '/missing'), /^Error: Docker error 404/);
|
|
264
|
+
assert.deepEqual(await dockerRequestJson('GET', '/good'), { fine: true });
|
|
265
|
+
} finally {
|
|
266
|
+
server.close();
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
console.log(`\n${passed} cases passed`);
|