@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.
Files changed (57) hide show
  1. package/README.md +6 -2
  2. package/app/agents/CostSection.tsx +12 -10
  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 +20 -3
  9. package/app/api/gateway/provider/route.ts +20 -2
  10. package/app/api/metrics/alerts/route.ts +47 -0
  11. package/app/api/metrics/containers/route.ts +54 -0
  12. package/app/api/metrics/route.ts +4 -6
  13. package/app/api/update-check/route.ts +20 -11
  14. package/app/components/ui/Accordion.tsx +45 -0
  15. package/app/components/ui/TimeSeriesChart.tsx +20 -2
  16. package/app/components/ui/index.ts +1 -0
  17. package/app/containers/ContainersClient.tsx +48 -6
  18. package/app/gateway/ModelDetailsModal.tsx +4 -2
  19. package/app/gateway/PageClient.tsx +86 -36
  20. package/app/globals.css +36 -0
  21. package/app/system/AgentCharts.tsx +138 -0
  22. package/app/system/AgentsSection.tsx +281 -0
  23. package/app/system/PageClient.tsx +11 -9
  24. package/app/system/RecentAlerts.tsx +72 -0
  25. package/app/system/SystemSkeleton.tsx +53 -1
  26. package/app/system/loading.tsx +5 -1
  27. package/bin/postinstall.js +7 -4
  28. package/bin/rev4a.js +6 -7
  29. package/daemon.js +195 -4
  30. package/docs/ARCHITECTURE.md +36 -5
  31. package/docs/FRONTEND-ARCHITECTURE.md +13 -4
  32. package/docs/REV4A.md +9 -6
  33. package/docs/dev/API-REFERENCE.md +116 -30
  34. package/docs/dev/DATABASE.md +58 -2
  35. package/docs/dev/GATEWAY.md +13 -4
  36. package/docs/rag/DATA-FRESHNESS.md +16 -1
  37. package/docs/rag/GLOSSARY.md +3 -3
  38. package/docs/rag/REV4A-OVERVIEW.md +9 -5
  39. package/docs/rag/WHAT-I-CAN-ANSWER.md +4 -1
  40. package/lib/container-metrics.ts +340 -0
  41. package/lib/docker-socket-path.d.ts +9 -0
  42. package/lib/docker-socket-path.js +133 -0
  43. package/lib/docker-socket.ts +10 -99
  44. package/lib/docker-stats.d.ts +88 -0
  45. package/lib/docker-stats.js +284 -0
  46. package/lib/metrics-db.ts +15 -6
  47. package/lib/model-details.ts +3 -3
  48. package/lib/model-pricing.ts +6 -4
  49. package/lib/utils/format.ts +29 -3
  50. package/model-details.json +2799 -2274
  51. package/model-pricing.json +63 -48
  52. package/models.config.json +41 -10
  53. package/npm-shrinkwrap.json +1979 -0
  54. package/package.json +11 -9
  55. package/scripts/check-language.mjs +1 -1
  56. package/scripts/check-package-types.mjs +74 -0
  57. package/scripts/test-docker-stats.mjs +270 -0
@@ -0,0 +1,340 @@
1
+ /**
2
+ * What each container consumes, read from metrics.db (daemon.js writes it — see
3
+ * docs/dev/DATABASE.md, Container consumption). The queries behind the System page's
4
+ * *Agents* section, the agent panel and the Containers page; the routes stay thin.
5
+ *
6
+ * CPU is stored in cores and shown as a share of Docker's cores (`docker_host.ncpu`): on the
7
+ * VPS that is the machine, on Docker Desktop its VM. Memory is the working set in MB,
8
+ * with its share of Docker's memory next to it.
9
+ */
10
+ import type Database from 'better-sqlite3';
11
+
12
+ /** How often daemon.js samples containers, and reads Docker's disk (seconds). */
13
+ export const CONTAINER_INTERVAL_S = 60;
14
+ export const STORAGE_INTERVAL_S = 600;
15
+ /** History points per chart. */
16
+ const POINTS = 120;
17
+ /** A container whose newest sample is older than this many intervals is not running. */
18
+ const RUNNING_WITHIN_INTERVALS = 3;
19
+
20
+ const TABLES = ['container_metrics', 'container_sources', 'docker_host', 'docker_storage'];
21
+
22
+ /** False until the daemon has created the tables (first start after an update). */
23
+ export function containerTablesReady(db: Database.Database): boolean {
24
+ const row = db
25
+ .prepare(`SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table' AND name IN (${TABLES.map(() => '?').join(', ')})`)
26
+ .get(...TABLES) as { n: number };
27
+ return row.n === TABLES.length;
28
+ }
29
+
30
+ /** Seconds per chart point: at least two samples, so a drifting timer leaves no empty bucket. */
31
+ export function bucketSeconds(spanS: number, intervalS: number): number {
32
+ return Math.max(2 * intervalS, Math.round(spanS / POINTS));
33
+ }
34
+
35
+ export interface DockerHost { ncpu: number; mem_total_mb: number }
36
+
37
+ export function readDockerHost(db: Database.Database): DockerHost | null {
38
+ const row = db.prepare('SELECT ncpu, mem_total_mb FROM docker_host WHERE id = 1').get() as
39
+ { ncpu: number | null; mem_total_mb: number | null } | undefined;
40
+ return row && row.ncpu && row.mem_total_mb ? { ncpu: row.ncpu, mem_total_mb: row.mem_total_mb } : null;
41
+ }
42
+
43
+ /** `value` as a percent of `whole`, to `digits` decimals — CPU needs two: an idle agent is a few hundredths of a percent of the machine. */
44
+ const pct = (value: number | null, whole: number | null | undefined, digits = 1): number | null =>
45
+ value === null || !whole ? null : Math.round((value / whole) * 100 * 10 ** digits) / 10 ** digits;
46
+ const round2 = (n: number | null): number | null => (n === null ? null : Math.round(n * 100) / 100);
47
+
48
+ /** The named volumes a container uses, as daemon.js recorded them. */
49
+ function volumesOf(list: string | null): string[] {
50
+ return list ? list.split(',').filter(Boolean) : [];
51
+ }
52
+
53
+ export interface ContainerNow {
54
+ cpu_cores: number | null;
55
+ cpu_percent: number | null;
56
+ mem_mb: number | null;
57
+ mem_percent: number | null;
58
+ pids: number | null;
59
+ net_rx_bps: number | null;
60
+ net_tx_bps: number | null;
61
+ blk_read_bps: number | null;
62
+ blk_write_bps: number | null;
63
+ }
64
+
65
+ export interface ContainerRow {
66
+ container: string;
67
+ name: string;
68
+ agent_id: string | null;
69
+ is_agent: boolean;
70
+ running: boolean;
71
+ last_seen: number;
72
+ now: ContainerNow | null;
73
+ /** Average and peak over the range, CPU as a share of Docker's cores. */
74
+ range: { cpu_avg_percent: number | null; cpu_max_percent: number | null; mem_avg_mb: number | null; mem_max_mb: number | null };
75
+ /** Named volumes plus the writable layer, from the last reading of Docker's disk. */
76
+ storage: { volume_mb: number | null; layer_mb: number | null; total_mb: number | null; ts: number | null };
77
+ }
78
+
79
+ export interface DockerStorage {
80
+ ts: number;
81
+ images_mb: number | null;
82
+ images_reclaimable_mb: number | null;
83
+ build_cache_mb: number | null;
84
+ build_cache_reclaimable_mb: number | null;
85
+ volumes_mb: number;
86
+ /** What no container uses, all of it — `unused_volumes` lists only the largest five. */
87
+ unused_volumes_mb: number;
88
+ layers_mb: number;
89
+ /** Volumes no container uses (cold backups, leftovers), largest first. */
90
+ unused_volumes: { name: string; size_mb: number }[];
91
+ }
92
+
93
+ export interface ContainerOverview {
94
+ host: DockerHost | null;
95
+ /** Unix seconds of the newest sample, and how old it is. */
96
+ sampled_at: number | null;
97
+ age_s: number | null;
98
+ containers: ContainerRow[];
99
+ /** The machine minus the containers: what is not one of them (the host, Rev4a, other software). */
100
+ rest: { cpu_percent: number | null; mem_mb: number | null } | null;
101
+ docker_storage: DockerStorage | null;
102
+ }
103
+
104
+ interface NowRow {
105
+ ts: number; cpu_cores: number | null; mem_mb: number | null; pids: number | null;
106
+ net_rx_bps: number | null; net_tx_bps: number | null; blk_read_bps: number | null; blk_write_bps: number | null;
107
+ }
108
+
109
+ /** The newest reading of each volume, layer and total, or null before the first. */
110
+ export function readDockerStorage(db: Database.Database): DockerStorage | null {
111
+ const ts = (db.prepare('SELECT MAX(ts) AS ts FROM docker_storage').get() as { ts: number | null }).ts;
112
+ if (ts === null) return null;
113
+ const rows = db.prepare('SELECT kind, name, size_mb, reclaimable_mb FROM docker_storage WHERE ts = ?').all(ts) as
114
+ { kind: string; name: string; size_mb: number | null; reclaimable_mb: number | null }[];
115
+ const one = (kind: string) => rows.find((r) => r.kind === kind);
116
+ const sum = (kind: string) => rows.filter((r) => r.kind === kind).reduce((acc, r) => acc + (r.size_mb ?? 0), 0);
117
+ const unused = rows
118
+ .filter((r) => r.kind === 'volume' && (r.reclaimable_mb ?? 0) > 0)
119
+ .map((r) => ({ name: r.name, size_mb: r.reclaimable_mb as number }))
120
+ .sort((a, b) => b.size_mb - a.size_mb);
121
+ return {
122
+ ts,
123
+ images_mb: one('images')?.size_mb ?? null,
124
+ images_reclaimable_mb: one('images')?.reclaimable_mb ?? null,
125
+ build_cache_mb: one('build_cache')?.size_mb ?? null,
126
+ build_cache_reclaimable_mb: one('build_cache')?.reclaimable_mb ?? null,
127
+ volumes_mb: sum('volume'),
128
+ unused_volumes_mb: unused.reduce((a, v) => a + v.size_mb, 0),
129
+ layers_mb: sum('layer'),
130
+ unused_volumes: unused.slice(0, 5),
131
+ };
132
+ }
133
+
134
+ interface AggregateRow { container: string; cpu_avg: number | null; cpu_max: number | null; mem_avg: number | null; mem_max: number | null }
135
+
136
+ /**
137
+ * From 24 hours up, the average and peak scan every sample of the range: the page's heaviest
138
+ * query — synchronous, so it holds the server's event loop (about 100 ms for ten containers
139
+ * over 24 h, measured) on every 30 s poll — and its answer hardly moves in two minutes. So
140
+ * those ranges are kept that long; shorter ones are cheap and read fresh.
141
+ */
142
+ const LONG_RANGE_S = 24 * 3_600;
143
+ const AGGREGATE_TTL_MS = 120_000;
144
+ const aggregateCache = new Map<string, { at: number; rows: Map<string, AggregateRow> }>();
145
+
146
+ function rangeAggregates(db: Database.Database, spanS: number, since: number): Map<string, AggregateRow> {
147
+ const cacheable = spanS >= LONG_RANGE_S;
148
+ const key = `${db.name}:${spanS}`;
149
+ if (cacheable) {
150
+ const hit = aggregateCache.get(key);
151
+ if (hit && Date.now() - hit.at < AGGREGATE_TTL_MS) return hit.rows;
152
+ }
153
+ const rows = new Map(
154
+ (db
155
+ .prepare(
156
+ `SELECT container, AVG(cpu_cores) AS cpu_avg, MAX(cpu_cores) AS cpu_max, AVG(mem_mb) AS mem_avg, MAX(mem_mb) AS mem_max
157
+ FROM container_metrics WHERE ts > ? GROUP BY container`,
158
+ )
159
+ .all(since) as AggregateRow[])
160
+ .map((r) => [r.container, r]),
161
+ );
162
+ if (cacheable) aggregateCache.set(key, { at: Date.now(), rows });
163
+ return rows;
164
+ }
165
+
166
+ /**
167
+ * Every container seen in the range: who it is, what it uses now, its average and peak over
168
+ * the range, and its disk — running ones first, by CPU.
169
+ */
170
+ export function readContainerOverview(
171
+ db: Database.Database,
172
+ spanS: number,
173
+ nowS = Math.floor(Date.now() / 1000),
174
+ /** Cores of the machine the API runs on, to put the containers' CPU on the machine's scale in `rest`. */
175
+ hostCores?: number,
176
+ ): ContainerOverview {
177
+ const host = readDockerHost(db);
178
+ const sampledAt = (db.prepare('SELECT MAX(ts) AS ts FROM container_metrics').get() as { ts: number | null }).ts;
179
+ const since = nowS - spanS;
180
+
181
+ const sources = db
182
+ .prepare('SELECT container, agent_id, name, is_agent, volumes, last_seen FROM container_sources WHERE last_seen > ?')
183
+ .all(since) as { container: string; agent_id: string | null; name: string | null; is_agent: number; volumes: string | null; last_seen: number }[];
184
+
185
+ const aggregates = rangeAggregates(db, spanS, since);
186
+ // Running is judged against the newest sample, not the wall clock: a daemon that stopped must
187
+ // not make every container look stopped (the page says "Not collecting" instead) — but never
188
+ // against a sample from the future, which a clock set back would leave behind.
189
+ const reference = sampledAt === null ? null : Math.min(sampledAt, nowS);
190
+
191
+ const storage = readDockerStorage(db);
192
+ const storageRows = storage
193
+ ? (db.prepare('SELECT kind, name, size_mb FROM docker_storage WHERE ts = ?').all(storage.ts) as { kind: string; name: string; size_mb: number | null }[])
194
+ : [];
195
+ const sizeOf = (kind: string, name: string) => storageRows.find((r) => r.kind === kind && r.name === name)?.size_mb ?? null;
196
+
197
+ const newest = db.prepare(
198
+ `SELECT ts, cpu_cores, mem_mb, pids, net_rx_bps, net_tx_bps, blk_read_bps, blk_write_bps
199
+ FROM container_metrics WHERE container = ? ORDER BY ts DESC LIMIT 3`,
200
+ );
201
+
202
+ const containers: ContainerRow[] = sources.map((s) => {
203
+ const recent = newest.all(s.container) as NowRow[];
204
+ const latest = recent[0];
205
+ const running = Boolean(latest && reference !== null && latest.ts >= reference - RUNNING_WITHIN_INTERVALS * CONTAINER_INTERVAL_S);
206
+ // A rate is null on the first sample after a start; the previous reading stands in for it.
207
+ const rate = (key: keyof NowRow) => recent.find((r) => r[key] !== null)?.[key] ?? null;
208
+ const cores = running ? (rate('cpu_cores') as number | null) : null;
209
+ const now: ContainerNow | null = running
210
+ ? {
211
+ cpu_cores: cores,
212
+ cpu_percent: pct(cores, host?.ncpu, 2),
213
+ mem_mb: latest.mem_mb,
214
+ mem_percent: pct(latest.mem_mb, host?.mem_total_mb),
215
+ pids: latest.pids,
216
+ net_rx_bps: rate('net_rx_bps') as number | null,
217
+ net_tx_bps: rate('net_tx_bps') as number | null,
218
+ blk_read_bps: rate('blk_read_bps') as number | null,
219
+ blk_write_bps: rate('blk_write_bps') as number | null,
220
+ }
221
+ : null;
222
+ const agg = aggregates.get(s.container);
223
+ const volumeSizes = volumesOf(s.volumes).map((v) => sizeOf('volume', v));
224
+ const volume = volumeSizes.length && volumeSizes.some((v) => v !== null) ? volumeSizes.reduce<number>((a, v) => a + (v ?? 0), 0) : null;
225
+ const layer = sizeOf('layer', s.container);
226
+ return {
227
+ container: s.container,
228
+ name: s.name || s.container,
229
+ agent_id: s.agent_id,
230
+ is_agent: s.is_agent === 1,
231
+ running,
232
+ last_seen: s.last_seen,
233
+ now,
234
+ range: {
235
+ cpu_avg_percent: pct(agg?.cpu_avg ?? null, host?.ncpu, 2),
236
+ cpu_max_percent: pct(agg?.cpu_max ?? null, host?.ncpu, 2),
237
+ mem_avg_mb: agg?.mem_avg == null ? null : Math.round(agg.mem_avg),
238
+ mem_max_mb: agg?.mem_max ?? null,
239
+ },
240
+ storage: {
241
+ volume_mb: volume,
242
+ layer_mb: layer,
243
+ total_mb: volume === null && layer === null ? null : (volume ?? 0) + (layer ?? 0),
244
+ ts: storage?.ts ?? null,
245
+ },
246
+ };
247
+ });
248
+
249
+ containers.sort((a, b) =>
250
+ Number(b.running) - Number(a.running)
251
+ || (b.now?.cpu_percent ?? -1) - (a.now?.cpu_percent ?? -1)
252
+ || a.name.localeCompare(b.name));
253
+
254
+ // Machine minus the containers, from the machine's newest sample.
255
+ const machine = db
256
+ .prepare('SELECT cpu_percent, ram_used_mb FROM system_metrics ORDER BY ts DESC LIMIT 1')
257
+ .get() as { cpu_percent: number | null; ram_used_mb: number | null } | undefined;
258
+ const running = containers.filter((c) => c.running && c.now);
259
+ // The machine's CPU is a share of the host's cores; a container's is of Docker's — the same on a
260
+ // server, not on Docker Desktop, whose VM may have fewer. Compare in cores over the host's.
261
+ const containersShare = hostCores && hostCores > 0
262
+ ? (running.reduce((a, c) => a + (c.now?.cpu_cores ?? 0), 0) / hostCores) * 100
263
+ : running.reduce((a, c) => a + (c.now?.cpu_percent ?? 0), 0);
264
+ const rest = machine && host
265
+ ? {
266
+ cpu_percent: machine.cpu_percent === null ? null : round2(Math.max(0, machine.cpu_percent - containersShare)),
267
+ mem_mb: machine.ram_used_mb === null ? null : Math.max(0, machine.ram_used_mb - running.reduce((a, c) => a + (c.now?.mem_mb ?? 0), 0)),
268
+ }
269
+ : null;
270
+
271
+ return {
272
+ host,
273
+ sampled_at: sampledAt,
274
+ age_s: sampledAt === null ? null : Math.max(0, nowS - sampledAt),
275
+ containers,
276
+ rest,
277
+ docker_storage: storage,
278
+ };
279
+ }
280
+
281
+ export interface ContainerHistory {
282
+ host: DockerHost | null;
283
+ container: string;
284
+ name: string | null;
285
+ bucket_s: number;
286
+ /** CPU as a share of Docker's cores (average and peak), memory in MB (average and peak). */
287
+ history: { ts: number; cpu_avg: number | null; cpu_max: number | null; mem_avg: number | null; mem_max: number | null }[];
288
+ storage_bucket_s: number;
289
+ /** Named volumes plus the writable layer, MB. */
290
+ storage_history: { ts: number; total_mb: number | null }[];
291
+ }
292
+
293
+ /** One container's history, bucketed like the machine's (`GET /api/metrics`). */
294
+ export function readContainerHistory(db: Database.Database, container: string, spanS: number, nowS = Math.floor(Date.now() / 1000)): ContainerHistory {
295
+ const host = readDockerHost(db);
296
+ const since = nowS - spanS;
297
+ const bucket = bucketSeconds(spanS, CONTAINER_INTERVAL_S);
298
+ const storageBucket = bucketSeconds(spanS, STORAGE_INTERVAL_S);
299
+
300
+ const source = db.prepare('SELECT name, volumes FROM container_sources WHERE container = ?').get(container) as
301
+ { name: string | null; volumes: string | null } | undefined;
302
+
303
+ const rows = db
304
+ .prepare(
305
+ `SELECT (ts / CAST(@bucket AS INTEGER)) * CAST(@bucket AS INTEGER) AS ts,
306
+ AVG(cpu_cores) AS cpu_avg, MAX(cpu_cores) AS cpu_max, AVG(mem_mb) AS mem_avg, MAX(mem_mb) AS mem_max
307
+ FROM container_metrics WHERE container = @container AND ts > @since
308
+ GROUP BY ts / CAST(@bucket AS INTEGER) ORDER BY ts`,
309
+ )
310
+ .all({ bucket, container, since }) as { ts: number; cpu_avg: number | null; cpu_max: number | null; mem_avg: number | null; mem_max: number | null }[];
311
+
312
+ const volumes = volumesOf(source?.volumes ?? null);
313
+ const placeholders = volumes.map(() => '?').join(', ');
314
+ // Volumes and the writable layer, summed per reading, then averaged per bucket.
315
+ const storageRows = db
316
+ .prepare(
317
+ `SELECT (ts / CAST(? AS INTEGER)) * CAST(? AS INTEGER) AS ts, AVG(total) AS total_mb FROM (
318
+ SELECT ts, SUM(size_mb) AS total FROM docker_storage
319
+ WHERE ts > ? AND ((kind = 'layer' AND name = ?)${volumes.length ? ` OR (kind = 'volume' AND name IN (${placeholders}))` : ''})
320
+ GROUP BY ts
321
+ ) GROUP BY ts / CAST(? AS INTEGER) ORDER BY ts`,
322
+ )
323
+ .all(storageBucket, storageBucket, since, container, ...volumes, storageBucket) as { ts: number; total_mb: number | null }[];
324
+
325
+ return {
326
+ host,
327
+ container,
328
+ name: source?.name ?? null,
329
+ bucket_s: bucket,
330
+ history: rows.map((r) => ({
331
+ ts: r.ts,
332
+ cpu_avg: pct(r.cpu_avg, host?.ncpu, 2),
333
+ cpu_max: pct(r.cpu_max, host?.ncpu, 2),
334
+ mem_avg: r.mem_avg === null ? null : Math.round(r.mem_avg),
335
+ mem_max: r.mem_max,
336
+ })),
337
+ storage_bucket_s: storageBucket,
338
+ storage_history: storageRows.map((r) => ({ ts: r.ts, total_mb: r.total_mb === null ? null : Math.round(r.total_mb) })),
339
+ };
340
+ }
@@ -0,0 +1,9 @@
1
+ /** Types for the CommonJS Docker socket client shared with daemon.js. */
2
+
3
+ export function resolveDockerSocket(): string | null;
4
+
5
+ export function dockerRequestJson<T = unknown>(
6
+ method: string,
7
+ apiPath: string,
8
+ timeoutMs?: number,
9
+ ): Promise<T>;
@@ -0,0 +1,133 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Where the Docker Engine API socket is, and a JSON request against it.
5
+ *
6
+ * Plain CommonJS on purpose: the routes reach it through lib/docker-socket.ts, and
7
+ * daemon.js — run by node directly, with no bundler — requires it. One copy of the rules
8
+ * below, so the daemon cannot drift from the dashboard on where Docker lives.
9
+ *
10
+ * The socket path is NOT the same everywhere. `/var/run/docker.sock` is the Linux default
11
+ * (and what the VPS uses), but Docker Desktop on macOS puts it at `~/.docker/run/docker.sock`
12
+ * and does not create the /var/run symlink unless the user opts in. Hardcoding the Linux
13
+ * path made every read-only Docker route return an empty list on macOS — agents existed
14
+ * and ran, but the dashboard showed nothing, because creation shells out to the `docker`
15
+ * CLI (which reads the context) while listing went through this socket.
16
+ */
17
+ const http = require('http');
18
+ const fs = require('fs');
19
+ const os = require('os');
20
+ const path = require('path');
21
+ const { execFileSync } = require('child_process');
22
+
23
+ let cached = null;
24
+ let lastMissAt = 0;
25
+
26
+ /**
27
+ * How long a failed resolution is remembered. Long enough that a Docker outage doesn't
28
+ * spawn a `docker context inspect` per request, short enough that a server which started
29
+ * before the Docker daemon picks it up on its own.
30
+ */
31
+ const MISS_TTL_MS = 5000;
32
+
33
+ function fromDockerHostEnv() {
34
+ const raw = process.env.DOCKER_HOST;
35
+ if (!raw) return null;
36
+ // Only unix sockets are usable here; tcp:// would need a different client.
37
+ if (!raw.startsWith('unix://')) return null;
38
+ return raw.slice('unix://'.length);
39
+ }
40
+
41
+ function fromDockerContext() {
42
+ try {
43
+ const out = execFileSync(
44
+ 'docker',
45
+ ['context', 'inspect', '--format', '{{.Endpoints.docker.Host}}'],
46
+ { encoding: 'utf-8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'] },
47
+ ).trim();
48
+ return out.startsWith('unix://') ? out.slice('unix://'.length) : null;
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ function usable(candidate) {
55
+ if (!candidate) return null;
56
+ try {
57
+ fs.accessSync(candidate, fs.constants.R_OK | fs.constants.W_OK);
58
+ return candidate;
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Resolve the Docker socket, most authoritative source first. Returns null when Docker
66
+ * isn't reachable at all.
67
+ *
68
+ * A success is cached for the life of the process — the path doesn't move. A failure is
69
+ * only cached for MISS_TTL_MS: Rev4a can legitimately start before the Docker daemon is up
70
+ * (systemd ordering, Docker Desktop still booting), and caching that failure permanently
71
+ * would leave every Docker route returning an empty list until someone restarted the server.
72
+ *
73
+ * @returns {string | null}
74
+ */
75
+ function resolveDockerSocket() {
76
+ if (cached) return cached;
77
+ if (Date.now() - lastMissAt < MISS_TTL_MS) return null;
78
+
79
+ const found =
80
+ usable(fromDockerHostEnv()) ??
81
+ usable(fromDockerContext()) ??
82
+ // Docker Desktop (macOS, and Windows with WSL integration)
83
+ usable(path.join(os.homedir(), '.docker', 'run', 'docker.sock')) ??
84
+ // Linux default — the VPS lands here
85
+ usable('/var/run/docker.sock');
86
+
87
+ if (found) cached = found;
88
+ else lastMissAt = Date.now();
89
+
90
+ return found;
91
+ }
92
+
93
+ /**
94
+ * A request against the Docker Engine API, resolved as JSON. Rejects when Docker is
95
+ * unreachable, the answer is an HTTP error (Docker's error bodies are JSON too — a status
96
+ * is never resolved as data), the payload isn't JSON, or `timeoutMs` (when given) passes.
97
+ *
98
+ * @template [T=any]
99
+ * @param {string} method
100
+ * @param {string} apiPath
101
+ * @param {number} [timeoutMs] 0 or omitted: no timeout
102
+ * @returns {Promise<T>}
103
+ */
104
+ function dockerRequestJson(method, apiPath, timeoutMs = 0) {
105
+ return new Promise((resolve, reject) => {
106
+ const socketPath = resolveDockerSocket();
107
+ if (!socketPath) {
108
+ reject(new Error('Docker socket not found'));
109
+ return;
110
+ }
111
+
112
+ const req = http.request(
113
+ { socketPath, path: apiPath, method, headers: { Host: 'localhost' } },
114
+ (res) => {
115
+ let data = '';
116
+ res.on('data', (chunk) => { data += chunk; });
117
+ res.on('end', () => {
118
+ if ((res.statusCode ?? 500) >= 400) {
119
+ reject(new Error(`Docker error ${res.statusCode}: ${data.slice(0, 200) || '(no body)'}`));
120
+ return;
121
+ }
122
+ try { resolve(JSON.parse(data)); }
123
+ catch { reject(new Error('Invalid JSON from Docker')); }
124
+ });
125
+ },
126
+ );
127
+ if (timeoutMs > 0) req.setTimeout(timeoutMs, () => req.destroy(new Error('Docker request timed out')));
128
+ req.on('error', reject);
129
+ req.end();
130
+ });
131
+ }
132
+
133
+ module.exports = { resolveDockerSocket, dockerRequestJson };
@@ -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
  }