@flame0510/project-aether 1.10.0 → 1.11.0

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.
Files changed (40) hide show
  1. package/README.md +1 -1
  2. package/app/agents/CostSection.tsx +2 -8
  3. package/app/agents/PageClient.tsx +7 -0
  4. package/app/agents/PanelRow.tsx +9 -0
  5. package/app/agents/ResourceSection.tsx +105 -0
  6. package/app/api/assistant/route.ts +2 -2
  7. package/app/api/containers/route.ts +18 -2
  8. package/app/api/costs/agent/route.ts +2 -3
  9. package/app/api/metrics/alerts/route.ts +47 -0
  10. package/app/api/metrics/containers/route.ts +54 -0
  11. package/app/api/metrics/route.ts +4 -6
  12. package/app/components/ui/Accordion.tsx +45 -0
  13. package/app/components/ui/TimeSeriesChart.tsx +20 -2
  14. package/app/components/ui/index.ts +1 -0
  15. package/app/containers/ContainersClient.tsx +48 -6
  16. package/app/globals.css +36 -0
  17. package/app/system/AgentCharts.tsx +138 -0
  18. package/app/system/AgentsSection.tsx +281 -0
  19. package/app/system/PageClient.tsx +7 -7
  20. package/app/system/RecentAlerts.tsx +72 -0
  21. package/app/system/SystemSkeleton.tsx +53 -1
  22. package/app/system/loading.tsx +5 -1
  23. package/daemon.js +195 -4
  24. package/docs/ARCHITECTURE.md +36 -5
  25. package/docs/FRONTEND-ARCHITECTURE.md +12 -3
  26. package/docs/REV4A.md +4 -4
  27. package/docs/dev/API-REFERENCE.md +91 -17
  28. package/docs/dev/DATABASE.md +58 -2
  29. package/docs/rag/DATA-FRESHNESS.md +16 -1
  30. package/docs/rag/GLOSSARY.md +2 -2
  31. package/docs/rag/REV4A-OVERVIEW.md +7 -3
  32. package/docs/rag/WHAT-I-CAN-ANSWER.md +4 -1
  33. package/lib/container-metrics.ts +340 -0
  34. package/lib/docker-socket-path.js +133 -0
  35. package/lib/docker-socket.ts +10 -99
  36. package/lib/docker-stats.js +284 -0
  37. package/lib/metrics-db.ts +15 -6
  38. package/lib/utils/format.ts +29 -3
  39. package/package.json +1 -1
  40. package/scripts/test-docker-stats.mjs +270 -0
@@ -1,90 +1,21 @@
1
1
  /**
2
2
  * Docker Engine API over its local unix socket.
3
3
  *
4
- * The socket path is NOT the same everywhere. `/var/run/docker.sock` is the
5
- * Linux default (and what the VPS uses), but Docker Desktop on macOS puts it
6
- * at `~/.docker/run/docker.sock` and does not create the /var/run symlink
7
- * unless the user opts in. Hardcoding the Linux path made every read-only
8
- * Docker route return an empty list on macOS — agents existed and ran, but
9
- * the dashboard showed nothing, because creation shells out to the `docker`
10
- * CLI (which reads the context) while listing went through this socket.
4
+ * Talking to the socket directly is preferred over shelling out for reads: no fork, no
5
+ * shell quoting, no `--format` template to parse — just JSON.
11
6
  *
12
- * Talking to the socket directly is preferred over shelling out for reads:
13
- * no fork, no shell quoting, no `--format` template to parse — just JSON.
7
+ * Where the socket lives (it is NOT the same on Linux and on Docker Desktop) and the
8
+ * request itself are in lib/docker-socket-path.js, plain CommonJS shared with daemon.js so
9
+ * there is one copy of those rules; this module keeps the names the routes import.
14
10
  */
15
- import * as http from 'http';
16
- import * as fs from 'fs';
17
- import * as os from 'os';
18
- import * as path from 'path';
19
- import { execFileSync } from 'child_process';
20
-
21
- let cached: string | null = null;
22
- let lastMissAt = 0;
11
+ import { resolveDockerSocket as resolve, dockerRequestJson } from './docker-socket-path';
23
12
 
24
13
  /**
25
- * How long a failed resolution is remembered. Long enough that a Docker outage
26
- * doesn't spawn a `docker context inspect` per request, short enough that a
27
- * server which started before the Docker daemon picks it up on its own.
28
- */
29
- const MISS_TTL_MS = 5000;
30
-
31
- function fromDockerHostEnv(): string | null {
32
- const raw = process.env.DOCKER_HOST;
33
- if (!raw) return null;
34
- // Only unix sockets are usable here; tcp:// would need a different client.
35
- if (!raw.startsWith('unix://')) return null;
36
- return raw.slice('unix://'.length);
37
- }
38
-
39
- function fromDockerContext(): string | null {
40
- try {
41
- const out = execFileSync(
42
- 'docker',
43
- ['context', 'inspect', '--format', '{{.Endpoints.docker.Host}}'],
44
- { encoding: 'utf-8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'] },
45
- ).trim();
46
- return out.startsWith('unix://') ? out.slice('unix://'.length) : null;
47
- } catch {
48
- return null;
49
- }
50
- }
51
-
52
- function usable(candidate: string | null): string | null {
53
- if (!candidate) return null;
54
- try {
55
- fs.accessSync(candidate, fs.constants.R_OK | fs.constants.W_OK);
56
- return candidate;
57
- } catch {
58
- return null;
59
- }
60
- }
61
-
62
- /**
63
- * Resolve the Docker socket, most authoritative source first. Returns null when
64
- * Docker isn't reachable at all.
65
- *
66
- * A success is cached for the life of the process — the path doesn't move. A
67
- * failure is only cached for MISS_TTL_MS: Rev4a can legitimately start before
68
- * the Docker daemon is up (systemd ordering, Docker Desktop still booting), and
69
- * caching that failure permanently would leave every Docker route returning an
70
- * empty list until someone restarted the server.
14
+ * Resolve the Docker socket, most authoritative source first. Returns null when Docker
15
+ * isn't reachable at all (a failure is remembered for a few seconds only).
71
16
  */
72
17
  export function resolveDockerSocket(): string | null {
73
- if (cached) return cached;
74
- if (Date.now() - lastMissAt < MISS_TTL_MS) return null;
75
-
76
- const found =
77
- usable(fromDockerHostEnv()) ??
78
- usable(fromDockerContext()) ??
79
- // Docker Desktop (macOS, and Windows with WSL integration)
80
- usable(path.join(os.homedir(), '.docker', 'run', 'docker.sock')) ??
81
- // Linux default — the VPS lands here
82
- usable('/var/run/docker.sock');
83
-
84
- if (found) cached = found;
85
- else lastMissAt = Date.now();
86
-
87
- return found;
18
+ return resolve();
88
19
  }
89
20
 
90
21
  /** True when a usable Docker socket exists. */
@@ -97,25 +28,5 @@ export function dockerAvailable(): boolean {
97
28
  * Rejects when Docker is unreachable or the payload isn't JSON.
98
29
  */
99
30
  export function dockerFetch<T = any>(method: string, apiPath: string): Promise<T> {
100
- return new Promise((resolve, reject) => {
101
- const socketPath = resolveDockerSocket();
102
- if (!socketPath) {
103
- reject(new Error('Docker socket not found'));
104
- return;
105
- }
106
-
107
- const req = http.request(
108
- { socketPath, path: apiPath, method, headers: { Host: 'localhost' } },
109
- (res) => {
110
- let data = '';
111
- res.on('data', (chunk) => { data += chunk; });
112
- res.on('end', () => {
113
- try { resolve(JSON.parse(data) as T); }
114
- catch { reject(new Error('Invalid JSON from Docker')); }
115
- });
116
- },
117
- );
118
- req.on('error', reject);
119
- req.end();
120
- });
31
+ return dockerRequestJson<T>(method, apiPath);
121
32
  }
@@ -0,0 +1,284 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * What each container consumes, from the Docker Engine API — the parsing and the rate
5
+ * arithmetic, free of any database or timer so it can be tested on its own. daemon.js
6
+ * owns the timers and the tables (metrics.db); this module turns Docker's answers into
7
+ * rows.
8
+ *
9
+ * Plain CommonJS for the same reason as lib/docker-socket-path.js: the daemon requires it.
10
+ *
11
+ * Why the raw API and not `docker stats`: `GET /containers/{id}/stats?one-shot=true`
12
+ * answers in 10–20 ms with cumulative counters (CPU time in ns, network and block bytes),
13
+ * while `docker stats --no-stream` waits a second per container for a second sample and
14
+ * hands back formatted strings. Two samples of the daemon's own timer give the CPU
15
+ * share over a real window.
16
+ */
17
+
18
+ /** A Docker container name, as `docker ps` prints it; anything else is never used. */
19
+ const CONTAINER_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
20
+
21
+ const REQUEST_TIMEOUT_MS = 10_000;
22
+ const DF_TIMEOUT_MS = 60_000;
23
+ /** `docker info` (cores, memory) changes only when Docker Desktop is reconfigured. */
24
+ const HOST_TTL_MS = 10 * 60_000;
25
+ /** Two samples closer than this give a rate dominated by timing noise. */
26
+ const MIN_WINDOW_S = 1;
27
+ const MB = 1024 * 1024;
28
+
29
+ /**
30
+ * The container's own name from Docker's `Names`: the one with a single leading slash.
31
+ * A container that is the target of a legacy link also carries `/other/alias` entries, and
32
+ * nothing promises which comes first — taking the first blindly would drop it, silently.
33
+ */
34
+ function primaryName(names) {
35
+ const own = (Array.isArray(names) ? names : []).find((n) => typeof n === 'string' && /^\/[^/]+$/.test(n));
36
+ return own ? own.slice(1) : '';
37
+ }
38
+
39
+ /** A display name without control characters (a tab or a newline in a name), bounded. */
40
+ function cleanName(value, fallback = null) {
41
+ const s = String(value ?? '').replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, 80);
42
+ return s || fallback;
43
+ }
44
+
45
+ const finite = (n) => typeof n === 'number' && Number.isFinite(n);
46
+
47
+ /**
48
+ * Working-set memory: usage minus the inactive page cache, what `docker stats` shows (and
49
+ * what the kernel will not give back under pressure). cgroup v2 names it `inactive_file`,
50
+ * v1 `total_inactive_file`.
51
+ */
52
+ function workingSetBytes(memory) {
53
+ const usage = memory?.usage;
54
+ if (!finite(usage)) return null;
55
+ const inactive = memory.stats?.inactive_file ?? memory.stats?.total_inactive_file ?? 0;
56
+ return Math.max(0, usage - (finite(inactive) ? inactive : 0));
57
+ }
58
+
59
+ /** Bytes received and sent, summed over the container's networks. */
60
+ function sumNetwork(networks) {
61
+ let rx = 0;
62
+ let tx = 0;
63
+ let seen = false;
64
+ for (const n of Object.values(networks || {})) {
65
+ if (finite(n?.rx_bytes)) { rx += n.rx_bytes; seen = true; }
66
+ if (finite(n?.tx_bytes)) { tx += n.tx_bytes; seen = true; }
67
+ }
68
+ return seen ? { rx, tx } : { rx: null, tx: null };
69
+ }
70
+
71
+ /** Bytes read and written on block devices (`op` is `read`/`write` on v2, `Read`/`Write` on v1). */
72
+ function sumBlockIo(blkio) {
73
+ let rd = 0;
74
+ let wr = 0;
75
+ let seen = false;
76
+ for (const e of blkio?.io_service_bytes_recursive || []) {
77
+ if (!finite(e?.value)) continue;
78
+ const op = String(e.op || '').toLowerCase();
79
+ if (op === 'read') { rd += e.value; seen = true; }
80
+ else if (op === 'write') { wr += e.value; seen = true; }
81
+ }
82
+ return seen ? { rd, wr } : { rd: null, wr: null };
83
+ }
84
+
85
+ /**
86
+ * One stats answer as counters, or null when it holds no CPU figure (a stopped container, or
87
+ * one that has only just started, answers an empty object).
88
+ */
89
+ function readCounters(stats, atMs) {
90
+ const cpuNs = stats?.cpu_stats?.cpu_usage?.total_usage;
91
+ if (!finite(cpuNs)) return null;
92
+ const net = sumNetwork(stats.networks);
93
+ const blk = sumBlockIo(stats.blkio_stats);
94
+ return {
95
+ t: atMs,
96
+ cpuNs,
97
+ memBytes: workingSetBytes(stats.memory_stats),
98
+ pids: finite(stats.pids_stats?.current) ? stats.pids_stats.current : null,
99
+ rx: net.rx, tx: net.tx, rd: blk.rd, wr: blk.wr,
100
+ };
101
+ }
102
+
103
+ /**
104
+ * Per-second rates between two readings of one container. Each counter stands alone: one
105
+ * that went backwards (the container restarted, the network was re-attached) gives null and
106
+ * nothing else — never a negative rate, never a spike. No earlier reading, a window under a
107
+ * second, or one longer than `maxWindowS`: all null. The long window matters: after a laptop
108
+ * slept or a pass was skipped, the counters moved little over many hours, and dividing by the
109
+ * whole gap would draw a confident near-zero instead of no figure.
110
+ */
111
+ function ratesBetween(prev, cur, maxWindowS = Infinity) {
112
+ const none = { cpuCores: null, rxBps: null, txBps: null, rdBps: null, wrBps: null };
113
+ if (!prev || !cur) return none;
114
+ const dt = (cur.t - prev.t) / 1000;
115
+ if (!(dt >= MIN_WINDOW_S) || dt > maxWindowS) return none;
116
+ const rate = (a, b) => (finite(a) && finite(b) && b >= a ? (b - a) / dt : null);
117
+ const cpu = rate(prev.cpuNs, cur.cpuNs);
118
+ return {
119
+ cpuCores: cpu === null ? null : cpu / 1e9,
120
+ rxBps: rate(prev.rx, cur.rx),
121
+ txBps: rate(prev.tx, cur.tx),
122
+ rdBps: rate(prev.rd, cur.rd),
123
+ wrBps: rate(prev.wr, cur.wr),
124
+ };
125
+ }
126
+
127
+ const round = (n, digits) => (n === null ? null : Number(n.toFixed(digits)));
128
+
129
+ /**
130
+ * The container sampler: holds the previous counters of every container between calls (in
131
+ * memory only — a daemon restart primes again) and the names it had to inspect.
132
+ *
133
+ * `request(method, path, timeoutMs)` is the Docker call (lib/docker-socket-path.js in the
134
+ * daemon, a fake in a test); `now()` the clock; `maxWindowS` the longest gap between two
135
+ * readings that still gives a rate (the daemon passes three sampling intervals).
136
+ */
137
+ function createContainerSampler({ request, now = Date.now, maxWindowS = 180 }) {
138
+ const previous = new Map(); // container id → counters
139
+ const described = new Map(); // container id → display name
140
+ let host = null;
141
+ let hostAt = 0;
142
+
143
+ async function readHost() {
144
+ if (host && now() - hostAt < HOST_TTL_MS) return host;
145
+ try {
146
+ const info = await request('GET', '/info', REQUEST_TIMEOUT_MS);
147
+ if (finite(info?.NCPU) && finite(info?.MemTotal) && info.NCPU > 0) {
148
+ host = { ncpu: info.NCPU, memTotalMb: Math.round(info.MemTotal / MB) };
149
+ hostAt = now();
150
+ }
151
+ } catch { /* keep the last answer; none yet → no host row */ }
152
+ return host;
153
+ }
154
+
155
+ /** The name to show: an agent's AGENT_NAME (read once per container id), else the container's. */
156
+ async function displayName(c, container) {
157
+ if (!c.Labels?.AGENT_ID) return container;
158
+ if (described.has(c.Id)) return described.get(c.Id);
159
+ let name = container;
160
+ try {
161
+ const inspect = await request('GET', `/containers/${encodeURIComponent(c.Id)}/json`, REQUEST_TIMEOUT_MS);
162
+ const entry = (inspect?.Config?.Env || []).find((e) => typeof e === 'string' && e.startsWith('AGENT_NAME='));
163
+ name = cleanName(entry?.slice('AGENT_NAME='.length), container);
164
+ } catch { /* fall back to the container name; asked again next time */ return name; }
165
+ described.set(c.Id, name);
166
+ return name;
167
+ }
168
+
169
+ /**
170
+ * One pass over the running containers. Throws when the list itself cannot be read
171
+ * (Docker down); a container whose stats fail is skipped, the others are kept.
172
+ */
173
+ async function sample() {
174
+ const list = await request('GET', '/containers/json', REQUEST_TIMEOUT_MS);
175
+ if (!Array.isArray(list)) throw new Error('unexpected answer from Docker');
176
+ const atMs = now();
177
+ const containers = [];
178
+ for (const c of list) {
179
+ const container = primaryName(c?.Names);
180
+ if (!c?.Id || !CONTAINER_NAME_RE.test(container)) continue;
181
+ containers.push({ c, container });
182
+ }
183
+
184
+ // The name (an inspect, agents only) runs beside the stats call, not after it: a slow
185
+ // inspect must not stretch the pass by its timeout once per agent.
186
+ const answers = await Promise.allSettled(containers.map(async ({ c, container }) => {
187
+ const [stats, name] = await Promise.all([
188
+ request('GET', `/containers/${encodeURIComponent(c.Id)}/stats?stream=false&one-shot=true`, REQUEST_TIMEOUT_MS),
189
+ displayName(c, container),
190
+ ]);
191
+ return { c, container, name, counters: readCounters(stats, now()) };
192
+ }));
193
+
194
+ const rows = [];
195
+ const sources = [];
196
+ // Identity comes from the list itself — a container whose stats call failed was still
197
+ // seen, so it keeps its place on the page (as stopped) instead of vanishing. Rates come
198
+ // only from counters. Alive is what Docker lists, not what answered its stats call: one
199
+ // timeout must not make a container forget its counters (the next pass would have no
200
+ // rate) and its name.
201
+ const live = new Set(containers.map(({ c }) => c.Id));
202
+ for (let i = 0; i < containers.length; i++) {
203
+ const { c, container } = containers[i];
204
+ const a = answers[i];
205
+ const name = a.status === 'fulfilled' ? a.value.name : container;
206
+ const volumes = (c.Mounts || []).filter((m) => m?.Type === 'volume' && m.Name).map((m) => m.Name);
207
+ sources.push({
208
+ container,
209
+ agent_id: c.Labels?.AGENT_ID ? String(c.Labels.AGENT_ID) : null,
210
+ name,
211
+ is_agent: c.Labels?.AGENT_ID ? 1 : 0,
212
+ volumes: volumes.join(','),
213
+ });
214
+ if (a.status !== 'fulfilled') continue;
215
+ const { counters } = a.value;
216
+ if (!counters) continue; // just started: no CPU figure yet
217
+ const rates = ratesBetween(previous.get(c.Id), counters, maxWindowS);
218
+ previous.set(c.Id, counters);
219
+ rows.push({
220
+ container,
221
+ cpu_cores: round(rates.cpuCores, 4),
222
+ mem_mb: counters.memBytes === null ? null : Math.round(counters.memBytes / MB),
223
+ pids: counters.pids,
224
+ net_rx_bps: round(rates.rxBps, 0),
225
+ net_tx_bps: round(rates.txBps, 0),
226
+ blk_read_bps: round(rates.rdBps, 0),
227
+ blk_write_bps: round(rates.wrBps, 0),
228
+ });
229
+ }
230
+ // A container that is gone takes its counters with it (a new id starts from none).
231
+ for (const id of previous.keys()) if (!live.has(id)) previous.delete(id);
232
+ for (const id of described.keys()) if (!live.has(id)) described.delete(id);
233
+
234
+ return { ts: Math.floor(atMs / 1000), host: await readHost(), rows, sources };
235
+ }
236
+
237
+ return { sample };
238
+ }
239
+
240
+ /**
241
+ * `/system/df` as the readings worth keeping: each named volume (and what no container uses),
242
+ * each container's writable layer, the images and the build cache in total with what
243
+ * could be freed. Sizes in bytes; -1 (Docker could not size it) becomes null.
244
+ * Returns null for anything that is not a df answer (an error body parses as JSON too) —
245
+ * the caller keeps its last reading instead of writing zeros.
246
+ */
247
+ function parseStorage(df) {
248
+ if (!df || typeof df !== 'object' || !('Volumes' in df || 'Containers' in df || 'Images' in df || 'BuildCache' in df)) return null;
249
+ const size = (n) => (finite(n) && n >= 0 ? n : null);
250
+ const volumes = (df?.Volumes || [])
251
+ .filter((v) => v?.Name)
252
+ .map((v) => ({
253
+ name: String(v.Name),
254
+ sizeBytes: size(v.UsageData?.Size),
255
+ unused: (v.UsageData?.RefCount ?? 0) === 0,
256
+ }));
257
+ const layers = (df?.Containers || [])
258
+ .map((c) => ({ container: primaryName(c?.Names), sizeBytes: size(c?.SizeRw) }))
259
+ .filter((l) => CONTAINER_NAME_RE.test(l.container));
260
+ const images = df?.Images || [];
261
+ const cache = df?.BuildCache || [];
262
+ const sum = (items, pick) => items.reduce((acc, x) => acc + (size(pick(x)) ?? 0), 0);
263
+ return {
264
+ volumes,
265
+ layers,
266
+ images: {
267
+ // LayersSize counts shared layers once, unlike summing each image.
268
+ sizeBytes: size(df?.LayersSize) ?? sum(images, (i) => i.Size),
269
+ reclaimableBytes: sum(images.filter((i) => (i.Containers ?? 0) === 0), (i) => i.Size),
270
+ },
271
+ buildCache: {
272
+ sizeBytes: sum(cache, (e) => e.Size),
273
+ // Docker's own "reclaimable" figure for the cache reads a few KB next to a cache of
274
+ // 1.7 GB (observed): what `docker builder prune` can free is what no build uses.
275
+ reclaimableBytes: sum(cache.filter((e) => e.InUse === false), (e) => e.Size),
276
+ },
277
+ };
278
+ }
279
+
280
+ module.exports = {
281
+ CONTAINER_NAME_RE, DF_TIMEOUT_MS, MB,
282
+ cleanName, primaryName, workingSetBytes, sumNetwork, sumBlockIo, readCounters, ratesBetween,
283
+ createContainerSampler, parseStorage,
284
+ };
package/lib/metrics-db.ts CHANGED
@@ -4,25 +4,34 @@ import path from 'path';
4
4
  import Database from 'better-sqlite3';
5
5
  import { DB_PATH } from './db';
6
6
 
7
+ /** History ranges of the System page and their span in seconds (GET /api/metrics and /api/metrics/containers). */
8
+ export const METRIC_RANGES = { '1h': 3600, '24h': 86_400, '7d': 7 * 86_400, '30d': 30 * 86_400 } as const;
9
+ export type MetricRange = keyof typeof METRIC_RANGES;
10
+
7
11
  /** Next to events.db, as daemon.js places it; its own file so the event log stays clean. */
8
12
  export const METRICS_DB_PATH = path.join(path.dirname(DB_PATH), 'metrics.db');
9
13
 
10
14
  /**
11
15
  * Open the metrics database read-only, or null when there is nothing to read yet: no file
12
- * (first start, or a daemon that never ran) or a file without the daemon's tables (it
13
- * failed right after creating it). The daemon owns the schema.
16
+ * (first start, or a daemon that never ran), a file without the daemon's tables (it failed
17
+ * right after creating it), or a file that cannot be opened at all (corrupt, locked — the
18
+ * caller answers "no data", never a 500). The daemon owns the schema. Never throws.
14
19
  */
15
20
  export function openMetricsDb(): Database.Database | null {
16
21
  if (!fs.existsSync(METRICS_DB_PATH)) return null;
17
- const db = new Database(METRICS_DB_PATH, { readonly: true, fileMustExist: true });
22
+ let db: Database.Database;
23
+ try {
24
+ db = new Database(METRICS_DB_PATH, { readonly: true, fileMustExist: true });
25
+ } catch {
26
+ return null;
27
+ }
18
28
  try {
19
29
  const tables = db
20
30
  .prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table' AND name IN ('system_metrics', 'system_disks')")
21
31
  .get() as { n: number };
22
32
  if (tables.n === 2) return db;
23
- } catch (e) {
24
- db.close();
25
- throw e;
33
+ } catch {
34
+ /* fall through: unreadable schema counts as nothing to read */
26
35
  }
27
36
  db.close();
28
37
  return null;
@@ -4,6 +4,32 @@ export function formatUsd(value: number | null | undefined): string {
4
4
  return `$${Number(value ?? 0).toFixed(2)}`;
5
5
  }
6
6
 
7
+ /** Megabytes as MB, GB or TB with the precision a reader wants: '692 MB', '1.6 GB'. */
8
+ export function formatMb(mb: number | null | undefined): string {
9
+ if (mb === null || mb === undefined || !Number.isFinite(mb)) return '—';
10
+ if (mb >= 1024 * 1024) return `${(mb / 1024 / 1024).toFixed(2)} TB`;
11
+ if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`;
12
+ return `${Math.round(mb)} MB`;
13
+ }
14
+
15
+ /** A CPU share in percent, with the decimals a small figure needs (an idle agent is 0.02 % of Docker's cores). */
16
+ export function formatSharePercent(percent: number | null | undefined): string {
17
+ if (percent === null || percent === undefined || !Number.isFinite(percent)) return '—';
18
+ if (percent === 0) return '0%';
19
+ if (percent < 0.01) return '<0.01%';
20
+ if (percent < 1) return `${percent.toFixed(2)}%`;
21
+ if (percent < 10) return `${percent.toFixed(1)}%`;
22
+ return `${Math.round(percent)}%`;
23
+ }
24
+
25
+ /** Bytes per second as B/s, KB/s or MB/s. */
26
+ export function formatRate(bytesPerSecond: number | null | undefined): string {
27
+ if (bytesPerSecond === null || bytesPerSecond === undefined || !Number.isFinite(bytesPerSecond)) return '—';
28
+ if (bytesPerSecond >= 1024 * 1024) return `${(bytesPerSecond / 1024 / 1024).toFixed(1)} MB/s`;
29
+ if (bytesPerSecond >= 1024) return `${(bytesPerSecond / 1024).toFixed(1)} KB/s`;
30
+ return `${Math.round(bytesPerSecond)} B/s`;
31
+ }
32
+
7
33
  /**
8
34
  * Dollars with the precision a small figure needs: an agent's day can be a fraction of a
9
35
  * cent, which formatUsd would show as $0.00. Used wherever agent costs are shown.
@@ -11,9 +37,9 @@ export function formatUsd(value: number | null | undefined): string {
11
37
  export function formatCost(value: number | null | undefined): string {
12
38
  const v = Number(value ?? 0);
13
39
  if (!v) return '$0.00';
14
- if (v < 0.01) return `${v.toFixed(4)}`;
15
- if (v < 1) return `${v.toFixed(3)}`;
16
- return `${v.toFixed(2)}`;
40
+ if (v < 0.01) return `$${v.toFixed(4)}`;
41
+ if (v < 1) return `$${v.toFixed(3)}`;
42
+ return `$${v.toFixed(2)}`;
17
43
  }
18
44
 
19
45
  export function formatUsdOrDash(value: number | null | undefined): string {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flame0510/project-aether",
3
- "version": "1.10.0",
3
+ "version": "1.11.0",
4
4
  "description": "Rev4a — Revolution for Agents. OpenClaw agent fleet orchestrator.",
5
5
  "keywords": [
6
6
  "openclaw",