@soyrageagency/docker-mcp 1.1.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 (104) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +882 -0
  3. package/dist/ai/credentials.d.ts +72 -0
  4. package/dist/ai/credentials.js +157 -0
  5. package/dist/ai/credentials.js.map +1 -0
  6. package/dist/ai/login.d.ts +22 -0
  7. package/dist/ai/login.js +189 -0
  8. package/dist/ai/login.js.map +1 -0
  9. package/dist/ai/provider.d.ts +29 -0
  10. package/dist/ai/provider.js +113 -0
  11. package/dist/ai/provider.js.map +1 -0
  12. package/dist/assets.d.ts +20 -0
  13. package/dist/assets.js +56 -0
  14. package/dist/assets.js.map +1 -0
  15. package/dist/branding.d.ts +31 -0
  16. package/dist/branding.js +61 -0
  17. package/dist/branding.js.map +1 -0
  18. package/dist/config.d.ts +65 -0
  19. package/dist/config.js +128 -0
  20. package/dist/config.js.map +1 -0
  21. package/dist/docker/client.d.ts +66 -0
  22. package/dist/docker/client.js +161 -0
  23. package/dist/docker/client.js.map +1 -0
  24. package/dist/docker/compose.d.ts +74 -0
  25. package/dist/docker/compose.js +126 -0
  26. package/dist/docker/compose.js.map +1 -0
  27. package/dist/entry.d.ts +15 -0
  28. package/dist/entry.js +33 -0
  29. package/dist/entry.js.map +1 -0
  30. package/dist/index.d.ts +14 -0
  31. package/dist/index.js +100 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/logger.d.ts +25 -0
  34. package/dist/logger.js +68 -0
  35. package/dist/logger.js.map +1 -0
  36. package/dist/panel/index.d.ts +16 -0
  37. package/dist/panel/index.js +53 -0
  38. package/dist/panel/index.js.map +1 -0
  39. package/dist/panel/public/app.js +1004 -0
  40. package/dist/panel/public/index.html +243 -0
  41. package/dist/panel/public/styles.css +428 -0
  42. package/dist/panel/server.d.ts +23 -0
  43. package/dist/panel/server.js +355 -0
  44. package/dist/panel/server.js.map +1 -0
  45. package/dist/panel/service.d.ts +327 -0
  46. package/dist/panel/service.js +1356 -0
  47. package/dist/panel/service.js.map +1 -0
  48. package/dist/plugins.d.ts +50 -0
  49. package/dist/plugins.js +121 -0
  50. package/dist/plugins.js.map +1 -0
  51. package/dist/ragedocker.d.ts +21 -0
  52. package/dist/ragedocker.js +164 -0
  53. package/dist/ragedocker.js.map +1 -0
  54. package/dist/tools/about.d.ts +15 -0
  55. package/dist/tools/about.js +55 -0
  56. package/dist/tools/about.js.map +1 -0
  57. package/dist/tools/compose.d.ts +19 -0
  58. package/dist/tools/compose.js +145 -0
  59. package/dist/tools/compose.js.map +1 -0
  60. package/dist/tools/containers.d.ts +13 -0
  61. package/dist/tools/containers.js +119 -0
  62. package/dist/tools/containers.js.map +1 -0
  63. package/dist/tools/context.d.ts +37 -0
  64. package/dist/tools/context.js +13 -0
  65. package/dist/tools/context.js.map +1 -0
  66. package/dist/tools/images.d.ts +11 -0
  67. package/dist/tools/images.js +38 -0
  68. package/dist/tools/images.js.map +1 -0
  69. package/dist/tools/index.d.ts +15 -0
  70. package/dist/tools/index.js +34 -0
  71. package/dist/tools/index.js.map +1 -0
  72. package/dist/tools/lifecycle.d.ts +16 -0
  73. package/dist/tools/lifecycle.js +165 -0
  74. package/dist/tools/lifecycle.js.map +1 -0
  75. package/dist/tools/logs.d.ts +15 -0
  76. package/dist/tools/logs.js +110 -0
  77. package/dist/tools/logs.js.map +1 -0
  78. package/dist/tools/system.d.ts +14 -0
  79. package/dist/tools/system.js +108 -0
  80. package/dist/tools/system.js.map +1 -0
  81. package/dist/tui/ansi.d.ts +66 -0
  82. package/dist/tui/ansi.js +118 -0
  83. package/dist/tui/ansi.js.map +1 -0
  84. package/dist/tui/app.d.ts +160 -0
  85. package/dist/tui/app.js +1339 -0
  86. package/dist/tui/app.js.map +1 -0
  87. package/dist/tui/box.d.ts +18 -0
  88. package/dist/tui/box.js +38 -0
  89. package/dist/tui/box.js.map +1 -0
  90. package/dist/tui/index.d.ts +19 -0
  91. package/dist/tui/index.js +66 -0
  92. package/dist/tui/index.js.map +1 -0
  93. package/dist/update/channel.d.ts +82 -0
  94. package/dist/update/channel.js +178 -0
  95. package/dist/update/channel.js.map +1 -0
  96. package/dist/updates.json +42 -0
  97. package/dist/utils/format.d.ts +24 -0
  98. package/dist/utils/format.js +69 -0
  99. package/dist/utils/format.js.map +1 -0
  100. package/dist/utils/result.d.ts +35 -0
  101. package/dist/utils/result.js +31 -0
  102. package/dist/utils/result.js.map +1 -0
  103. package/package.json +90 -0
  104. package/scripts/install.mjs +132 -0
@@ -0,0 +1,1356 @@
1
+ /**
2
+ * Panel & monitoring data service.
3
+ *
4
+ * A UI/monitoring-oriented data layer shared by the panel's REST API, the
5
+ * Prometheus `/metrics` endpoint and the terminal UI. It normalises Docker
6
+ * payloads into compact DTOs, samples live CPU/memory usage, and — crucially —
7
+ * supports a **demo mode** that serves realistic fabricated data (with gentle
8
+ * jitter so it looks alive) for previews, screenshots and client demos.
9
+ *
10
+ * Part of Docker MCP Server.
11
+ * Crafted by SoyRage Agency — https://soyrage.es/
12
+ * MIT licensed (see LICENSE).
13
+ */
14
+ import { spawn } from "node:child_process";
15
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
16
+ import { isAbsolute, resolve } from "node:path";
17
+ import { normalizeName } from "../docker/client.js";
18
+ import { BRAND } from "../branding.js";
19
+ import { activeAccount } from "../ai/credentials.js";
20
+ import { chat } from "../ai/provider.js";
21
+ /** Compute CPU % from dockerode's raw accounting deltas. */
22
+ function cpuPercent(stats) {
23
+ const cpuDelta = stats.cpu_stats.cpu_usage.total_usage -
24
+ stats.precpu_stats.cpu_usage.total_usage;
25
+ const systemDelta = (stats.cpu_stats.system_cpu_usage ?? 0) -
26
+ (stats.precpu_stats.system_cpu_usage ?? 0);
27
+ const cores = stats.cpu_stats.online_cpus ??
28
+ stats.cpu_stats.cpu_usage.percpu_usage?.length ??
29
+ 1;
30
+ if (systemDelta <= 0 || cpuDelta <= 0)
31
+ return 0;
32
+ return (cpuDelta / systemDelta) * cores * 100;
33
+ }
34
+ /** Unified data provider; delegates to Docker or fabricated demo data. */
35
+ export class PanelService {
36
+ docker;
37
+ config;
38
+ logger;
39
+ demo;
40
+ /** Demo baselines, mutated with light jitter to feel live. */
41
+ demoBase = demoContainers();
42
+ /** Names with auto-restart enabled (in-memory registry). */
43
+ autoRestart = new Set();
44
+ /** Rolling event log of raised alerts (most recent last). */
45
+ alertLog = [];
46
+ watchdog = null;
47
+ /** In-memory snapshot registry. */
48
+ snapshotLog = [];
49
+ /** Backup schedule + scheduler bookkeeping. */
50
+ schedule = { enabled: false, time: "03:00", containers: ["all"], type: "commit", email: "" };
51
+ scheduler = null;
52
+ lastScheduleRun = "";
53
+ /** Rolling time-series for the Trends charts (most recent last). */
54
+ history = [];
55
+ sampler = null;
56
+ static HISTORY_CAP = 60;
57
+ constructor(docker, config, logger) {
58
+ this.docker = docker;
59
+ this.config = config;
60
+ this.logger = logger;
61
+ this.demo = config.panel.demo;
62
+ if (this.demo) {
63
+ this.logger.info("Panel running in DEMO mode (mock data).");
64
+ this.snapshotLog.push({ id: "snap-1001", container: "postgres", type: "export", ref: "./snapshots/postgres-20260716-0300.tar", sizeBytes: 214_958_080, createdAt: "2026-07-16T03:00:04.000Z", destination: "local + webhook" }, { id: "snap-1000", container: "api", type: "commit", ref: "soyrage-snapshot/api:20260715-0300", sizeBytes: 189_792_256, createdAt: "2026-07-15T03:00:02.000Z", destination: "local" });
65
+ // Seed the Trends charts so they aren't empty on first load.
66
+ const now = Date.now();
67
+ for (let i = 30; i > 0; i--) {
68
+ const cpu = 4 + Math.sin(i / 3) * 2 + Math.random() * 1.5;
69
+ const memBytes = 900_000_000 + Math.sin(i / 5) * 60_000_000 + Math.random() * 30_000_000;
70
+ this.history.push({ t: now - i * 5000, cpu: Math.max(0, cpu), mem: (memBytes / 33_567_776_768) * 100, memBytes });
71
+ }
72
+ }
73
+ }
74
+ get isDemo() {
75
+ return this.demo;
76
+ }
77
+ get isReadOnly() {
78
+ return this.config.readOnly;
79
+ }
80
+ /** Host summary. */
81
+ async system() {
82
+ if (this.demo)
83
+ return demoSystem(this.config.readOnly);
84
+ const [info, version] = await Promise.all([
85
+ this.docker.info(),
86
+ this.docker.version(),
87
+ ]);
88
+ return {
89
+ engine: String(version.Version ?? "unknown"),
90
+ os: String(info.OperatingSystem ?? "unknown"),
91
+ arch: String(info.Architecture ?? ""),
92
+ cpus: Number(info.NCPU ?? 0),
93
+ memoryBytes: Number(info.MemTotal ?? 0),
94
+ containersRunning: Number(info.ContainersRunning ?? 0),
95
+ containersTotal: Number(info.Containers ?? 0),
96
+ images: Number(info.Images ?? 0),
97
+ demo: false,
98
+ readOnly: this.config.readOnly,
99
+ };
100
+ }
101
+ /** Container list WITH live usage (CPU %, memory) for running containers. */
102
+ async containers() {
103
+ if (this.demo)
104
+ return this.demoContainersLive();
105
+ const list = await this.docker.listContainers(true);
106
+ const rows = list.map((c) => ({
107
+ id: c.Id.slice(0, 12),
108
+ name: normalizeName(c.Names[0] ?? c.Id),
109
+ image: c.Image,
110
+ state: c.State,
111
+ status: c.Status,
112
+ ports: (c.Ports ?? [])
113
+ .filter((p) => p.PublicPort)
114
+ .map((p) => `${p.PublicPort}→${p.PrivatePort}/${p.Type}`),
115
+ }));
116
+ // Sample live stats for running containers, in parallel, best-effort.
117
+ await Promise.all(rows.map(async (row, index) => {
118
+ if (row.state !== "running")
119
+ return;
120
+ try {
121
+ const handle = this.docker.raw.getContainer(list[index].Id);
122
+ const stats = (await handle.stats({
123
+ stream: false,
124
+ }));
125
+ row.cpu = Number(cpuPercent(stats).toFixed(2));
126
+ row.memory = stats.memory_stats.usage ?? 0;
127
+ row.memoryLimit = stats.memory_stats.limit ?? 0;
128
+ }
129
+ catch {
130
+ /* stats are best-effort; leave undefined */
131
+ }
132
+ }));
133
+ return rows;
134
+ }
135
+ /** Image list. */
136
+ async images() {
137
+ if (this.demo)
138
+ return demoImages();
139
+ const list = await this.docker.listImages();
140
+ return list.flatMap((img) => {
141
+ const tags = img.RepoTags?.length ? img.RepoTags : ["<none>:<none>"];
142
+ return tags.map((tag) => ({
143
+ id: (img.Id ?? "").replace("sha256:", "").slice(0, 12),
144
+ tag,
145
+ sizeBytes: img.Size,
146
+ }));
147
+ });
148
+ }
149
+ /** A full snapshot for monitoring dashboards and the metrics endpoint. */
150
+ async snapshot() {
151
+ const [system, containers, images] = await Promise.all([
152
+ this.system(),
153
+ this.containers(),
154
+ this.images(),
155
+ ]);
156
+ const running = containers.filter((c) => c.state === "running");
157
+ const cpuTotal = running.reduce((sum, c) => sum + (c.cpu ?? 0), 0);
158
+ const memoryUsed = running.reduce((sum, c) => sum + (c.memory ?? 0), 0);
159
+ // Bugfix: derive container counts from the actual list so the header cards
160
+ // stay consistent with the grid (e.g. after stopping a container in demo).
161
+ const consistent = {
162
+ ...system,
163
+ containersTotal: containers.length,
164
+ containersRunning: running.length,
165
+ images: images.length,
166
+ };
167
+ return {
168
+ system: consistent,
169
+ containers,
170
+ images,
171
+ cpuTotal: Number(cpuTotal.toFixed(2)),
172
+ memoryUsed,
173
+ };
174
+ }
175
+ /** Recent logs for one container. */
176
+ async logs(reference, tail = 200) {
177
+ if (this.demo)
178
+ return demoLogs(reference);
179
+ const handle = await this.docker.resolveContainer(reference);
180
+ const raw = (await handle.logs({
181
+ stdout: true,
182
+ stderr: true,
183
+ tail,
184
+ timestamps: false,
185
+ }));
186
+ return Buffer.from(raw)
187
+ .toString("utf8")
188
+ .replace(/[\x00-\x08\x0e-\x1f]/g, "")
189
+ .trim();
190
+ }
191
+ /** Perform a lifecycle action. Rejected in read-only mode. */
192
+ async act(reference, action) {
193
+ if (this.config.readOnly) {
194
+ throw new Error("Server is in read-only mode; actions are disabled.");
195
+ }
196
+ if (this.demo) {
197
+ this.logger.info(`(demo) ${action} ${reference}`);
198
+ this.applyDemoAction(reference, action);
199
+ return;
200
+ }
201
+ const handle = await this.docker.resolveContainer(reference);
202
+ if (action === "start")
203
+ await handle.start();
204
+ else if (action === "stop")
205
+ await handle.stop({ t: 10 });
206
+ else
207
+ await handle.restart({ t: 10 });
208
+ }
209
+ // ---- Auto-restart watchdog ---------------------------------------------
210
+ /** Names with auto-restart currently enabled. */
211
+ autoRestartList() {
212
+ return [...this.autoRestart];
213
+ }
214
+ /** Enable/disable auto-restart for a container. */
215
+ setAutoRestart(name, enabled) {
216
+ if (enabled)
217
+ this.autoRestart.add(name);
218
+ else
219
+ this.autoRestart.delete(name);
220
+ this.logger.info(`Auto-restart ${enabled ? "enabled" : "disabled"} for ${name}`);
221
+ }
222
+ /**
223
+ * Start the background watchdog: every `intervalMs`, any container that has
224
+ * auto-restart enabled but is not running is restarted (unless read-only).
225
+ * Each intervention is recorded as an alert.
226
+ */
227
+ startWatchdog(intervalMs = 5000) {
228
+ if (this.watchdog)
229
+ return;
230
+ this.watchdog = setInterval(() => void this.tickWatchdog(), intervalMs);
231
+ if (typeof this.watchdog.unref === "function")
232
+ this.watchdog.unref();
233
+ }
234
+ /** Stop the watchdog (used on shutdown). */
235
+ stopWatchdog() {
236
+ if (this.watchdog)
237
+ clearInterval(this.watchdog);
238
+ this.watchdog = null;
239
+ }
240
+ /** Recent CPU/memory samples for the Trends charts. */
241
+ getHistory() {
242
+ return [...this.history];
243
+ }
244
+ /** Start sampling CPU/memory into the history ring buffer. */
245
+ startSampler(intervalMs = 5000) {
246
+ if (this.sampler)
247
+ return;
248
+ this.sampler = setInterval(() => void this.tickSampler(), intervalMs);
249
+ if (typeof this.sampler.unref === "function")
250
+ this.sampler.unref();
251
+ }
252
+ async tickSampler() {
253
+ try {
254
+ const snap = await this.snapshot();
255
+ const cpuPct = snap.system.cpus ? Math.min(100, snap.cpuTotal / snap.system.cpus) : snap.cpuTotal;
256
+ const memPct = snap.system.memoryBytes ? (snap.memoryUsed / snap.system.memoryBytes) * 100 : 0;
257
+ this.history.push({ t: Date.now(), cpu: Number(cpuPct.toFixed(2)), mem: Number(memPct.toFixed(2)), memBytes: snap.memoryUsed });
258
+ while (this.history.length > PanelService.HISTORY_CAP)
259
+ this.history.shift();
260
+ }
261
+ catch {
262
+ /* transient */
263
+ }
264
+ }
265
+ async tickWatchdog() {
266
+ if (this.autoRestart.size === 0 || this.config.readOnly)
267
+ return;
268
+ try {
269
+ const containers = await this.containers();
270
+ for (const c of containers) {
271
+ if (!this.autoRestart.has(c.name))
272
+ continue;
273
+ if (c.state !== "running") {
274
+ this.addAlert({
275
+ level: "warning",
276
+ source: c.name,
277
+ rule: "auto-restart",
278
+ message: `Container "${c.name}" was ${c.state}; auto-restarting.`,
279
+ });
280
+ try {
281
+ await this.act(c.name, "start");
282
+ this.addAlert({
283
+ level: "info",
284
+ source: c.name,
285
+ rule: "auto-restart",
286
+ message: `Container "${c.name}" restarted by watchdog.`,
287
+ });
288
+ }
289
+ catch (err) {
290
+ this.addAlert({
291
+ level: "critical",
292
+ source: c.name,
293
+ rule: "auto-restart",
294
+ message: `Auto-restart of "${c.name}" failed: ${err.message}`,
295
+ });
296
+ }
297
+ }
298
+ }
299
+ }
300
+ catch {
301
+ /* transient; try again next tick */
302
+ }
303
+ }
304
+ // ---- Alerts -------------------------------------------------------------
305
+ /** Append an alert to the rolling log (bounded to the last 200). */
306
+ addAlert(alert) {
307
+ this.alertLog.push({ ...alert, time: nowIso() });
308
+ if (this.alertLog.length > 200)
309
+ this.alertLog.shift();
310
+ }
311
+ /**
312
+ * Compute the current alerts: live state-based rules (down / high CPU / high
313
+ * memory / unhealthy) plus a scan of recent logs for error/warn lines, merged
314
+ * with the rolling event log (watchdog interventions, etc.).
315
+ */
316
+ async alerts() {
317
+ const snap = await this.snapshot();
318
+ const active = [];
319
+ const t = nowIso();
320
+ for (const c of snap.containers) {
321
+ if (c.state === "exited" && !this.autoRestart.has(c.name)) {
322
+ active.push({ level: "critical", source: c.name, rule: "container-down", message: `Container "${c.name}" is down (${c.status}).`, time: t });
323
+ }
324
+ if (/unhealthy/i.test(c.status)) {
325
+ active.push({ level: "critical", source: c.name, rule: "unhealthy", message: `Container "${c.name}" reports unhealthy.`, time: t });
326
+ }
327
+ if (c.state === "running") {
328
+ const cpuOfCore = c.cpu ?? 0;
329
+ if (cpuOfCore >= 85) {
330
+ active.push({ level: "warning", source: c.name, rule: "high-cpu", message: `High CPU on "${c.name}": ${cpuOfCore.toFixed(1)}%.`, time: t });
331
+ }
332
+ const memPct = c.memoryLimit ? ((c.memory ?? 0) / c.memoryLimit) * 100 : 0;
333
+ if (memPct >= 85) {
334
+ active.push({ level: "warning", source: c.name, rule: "high-memory", message: `High memory on "${c.name}": ${memPct.toFixed(0)}% of limit.`, time: t });
335
+ }
336
+ }
337
+ }
338
+ // Log-based alerts: scan a short tail of each running container's logs.
339
+ const running = snap.containers.filter((c) => c.state === "running");
340
+ await Promise.all(running.map(async (c) => {
341
+ try {
342
+ const text = await this.logs(c.name, 60);
343
+ for (const line of text.split("\n")) {
344
+ if (/\b(error|fatal|panic|exception)\b/i.test(line)) {
345
+ active.push({ level: "warning", source: c.name, rule: "log-error", message: truncate(line, 140), time: t });
346
+ break; // one per container to avoid noise
347
+ }
348
+ }
349
+ }
350
+ catch {
351
+ /* ignore */
352
+ }
353
+ }));
354
+ // Most recent first: event log then active rules.
355
+ return [...this.alertLog].reverse().concat(active);
356
+ }
357
+ // ---- File browser -------------------------------------------------------
358
+ /** List a directory inside a container. */
359
+ async listFiles(name, path = "/") {
360
+ const clean = normalizePath(path);
361
+ if (this.demo)
362
+ return { path: clean, entries: demoListDir(name, clean) };
363
+ const handle = await this.docker.resolveContainer(name);
364
+ const info = await handle.inspect();
365
+ if (!info.State.Running)
366
+ throw new Error(`Container "${name}" is not running.`);
367
+ // Safe: arguments are passed as an array, never through a shell.
368
+ const out = await this.exec(handle, ["ls", "-la", clean]);
369
+ return { path: clean, entries: parseLsOutput(out) };
370
+ }
371
+ /** Read a text file inside a container (bounded to 128 KB). */
372
+ async readFile(name, path) {
373
+ const clean = normalizePath(path);
374
+ if (this.demo)
375
+ return { path: clean, content: demoReadFile(name, clean), truncated: false };
376
+ const handle = await this.docker.resolveContainer(name);
377
+ const info = await handle.inspect();
378
+ if (!info.State.Running)
379
+ throw new Error(`Container "${name}" is not running.`);
380
+ const out = await this.exec(handle, ["cat", clean]);
381
+ const limit = 128 * 1024;
382
+ const truncated = out.length > limit;
383
+ return { path: clean, content: truncated ? out.slice(0, limit) : out, truncated };
384
+ }
385
+ /**
386
+ * Write a text file inside a container (editor save). Blocked in read-only
387
+ * mode. Content is streamed to `sh -c 'cat > "$1"'` with the path passed as
388
+ * an argument (no shell interpolation of the path).
389
+ */
390
+ async writeFile(name, path, content) {
391
+ if (this.config.readOnly)
392
+ throw new Error("Server is read-only; saving is disabled.");
393
+ if (content.length > 256 * 1024)
394
+ throw new Error("File too large to save from the panel (256 KB max).");
395
+ const clean = normalizePath(path);
396
+ if (this.demo) {
397
+ demoWrite(name, clean, content);
398
+ return { path: clean, bytes: Buffer.byteLength(content) };
399
+ }
400
+ const handle = await this.docker.resolveContainer(name);
401
+ const info = await handle.inspect();
402
+ if (!info.State.Running)
403
+ throw new Error(`Container "${name}" is not running.`);
404
+ const exec = await handle.exec({
405
+ Cmd: ["sh", "-c", 'cat > "$1"', "sh", clean],
406
+ AttachStdin: true,
407
+ AttachStdout: true,
408
+ AttachStderr: true,
409
+ });
410
+ const stream = await exec.start({ hijack: true, stdin: true });
411
+ await new Promise((resolvePromise, rejectPromise) => {
412
+ stream.on("error", rejectPromise);
413
+ stream.on("end", () => resolvePromise());
414
+ stream.on("close", () => resolvePromise());
415
+ stream.end(Buffer.from(content, "utf8"));
416
+ });
417
+ const inspect = await exec.inspect();
418
+ if (inspect.ExitCode && inspect.ExitCode !== 0) {
419
+ throw new Error(`Save failed (exit ${inspect.ExitCode}). Is the path writable?`);
420
+ }
421
+ this.addAlert({ level: "info", source: name, rule: "edit", message: `Saved ${clean}` });
422
+ return { path: clean, bytes: Buffer.byteLength(content) };
423
+ }
424
+ /** Whether the AI copilot is available (demo simulates it). */
425
+ get aiEnabled() {
426
+ return this.demo || Boolean(this.config.panel.aiEndpoint) || Boolean(activeAccount());
427
+ }
428
+ /** A short human label for the active model, for the status bar. */
429
+ get aiLabel() {
430
+ if (this.demo)
431
+ return "demo AI";
432
+ const account = activeAccount();
433
+ if (account)
434
+ return `${account.label} · ${account.model}`;
435
+ return this.config.panel.aiEndpoint ? `env · ${this.config.panel.aiModel}` : "";
436
+ }
437
+ /**
438
+ * AI copilot. `mode`:
439
+ * - "command": natural language → a suggested docker command + explanation
440
+ * - "edit": rewrite file `context` per the `prompt` instruction
441
+ * Uses an OpenAI-compatible endpoint when configured; falls back to a helpful
442
+ * message (and a heuristic command) otherwise. Demo mode simulates it.
443
+ */
444
+ async aiAssist(mode, prompt, context = "") {
445
+ if (this.demo)
446
+ return demoAi(mode, prompt, context);
447
+ // Prefer whatever `ragedocker ia login` configured; fall back to the legacy
448
+ // env-var endpoint so an existing server deployment is unchanged.
449
+ const account = activeAccount();
450
+ if (!account) {
451
+ return {
452
+ source: "unconfigured",
453
+ text: 'AI is not configured. Run "ragedocker ia login" to sign in to Claude or ChatGPT — or set DOCKER_MCP_AI_ENDPOINT / _KEY / _MODEL for any OpenAI-compatible API.',
454
+ command: heuristicCommand(prompt),
455
+ };
456
+ }
457
+ const names = (await this.containers()).map((c) => c.name).join(", ");
458
+ const system = mode === "edit"
459
+ ? "You are a careful text-file editor. Given a file and an instruction, return ONLY the full new file content — no explanations, no markdown code fences."
460
+ : `You are a Docker CLI copilot. The user describes what they want in plain language. Reply ONLY with a compact JSON object {"command":"<one safe docker command>","explanation":"<one short sentence>"}. Available containers: ${names}. Never invent destructive flags.`;
461
+ const user = mode === "edit" ? `Instruction: ${prompt}\n\n--- FILE ---\n${context}` : prompt;
462
+ const raw = await chat(account, system, user);
463
+ if (mode === "edit")
464
+ return { source: "ai", content: stripFences(raw) };
465
+ // command mode: try to parse the JSON object.
466
+ try {
467
+ const json = JSON.parse(raw.slice(raw.indexOf("{"), raw.lastIndexOf("}") + 1));
468
+ return { source: "ai", command: String(json.command ?? ""), explanation: String(json.explanation ?? "") };
469
+ }
470
+ catch {
471
+ return { source: "ai", text: raw };
472
+ }
473
+ }
474
+ /** Run a command inside a container and return its combined output. */
475
+ async exec(handle, cmd) {
476
+ const exec = await handle.exec({ Cmd: cmd, AttachStdout: true, AttachStderr: true });
477
+ const stream = await exec.start({});
478
+ return new Promise((resolvePromise, rejectPromise) => {
479
+ const chunks = [];
480
+ stream.on("data", (d) => chunks.push(Buffer.from(d)));
481
+ stream.on("end", () => resolvePromise(Buffer.concat(chunks)
482
+ .toString("utf8")
483
+ .replace(/[\x00-\x08\x0e-\x1f]/g, "")));
484
+ stream.on("error", rejectPromise);
485
+ });
486
+ }
487
+ // ---- Terminal (docker/compose command runner) --------------------------
488
+ /**
489
+ * Run a `docker …` (or `docker compose …`) command typed in the panel
490
+ * terminal. Commands are parsed into an argv array and spawned WITHOUT a
491
+ * shell (no injection). A deny-list blocks dangerous verbs; write verbs are
492
+ * blocked in read-only mode; read verbs are always allowed.
493
+ */
494
+ async runCommand(input) {
495
+ const argv = parseArgv(input);
496
+ if (argv.length === 0)
497
+ return { command: input, code: 0, output: "" };
498
+ if (argv[0] !== "docker") {
499
+ return { command: input, code: 1, output: "Only `docker …` commands are allowed here." };
500
+ }
501
+ const sub = argv[1] ?? "";
502
+ if (DENY_SUBCOMMANDS.has(sub)) {
503
+ return { command: input, code: 1, output: `\`docker ${sub}\` is disabled in the panel terminal for safety.` };
504
+ }
505
+ const write = !isReadCommand(argv);
506
+ if (write && this.config.readOnly) {
507
+ return { command: input, code: 1, output: "Server is read-only; this command changes state and is blocked." };
508
+ }
509
+ if (this.demo) {
510
+ return { command: input, code: 0, output: demoCommand(argv) };
511
+ }
512
+ return this.spawnDocker(argv.slice(1), input);
513
+ }
514
+ spawnDocker(args, command) {
515
+ return new Promise((resolvePromise) => {
516
+ const child = spawn("docker", args, { shell: false, windowsHide: true });
517
+ let out = "";
518
+ const cap = (b) => (out += b.toString());
519
+ child.stdout.on("data", cap);
520
+ child.stderr.on("data", cap);
521
+ const timer = setTimeout(() => child.kill("SIGKILL"), 20_000);
522
+ child.on("error", (e) => {
523
+ clearTimeout(timer);
524
+ resolvePromise({ command, code: 127, output: e.code === "ENOENT" ? "`docker` CLI not found on PATH." : String(e) });
525
+ });
526
+ child.on("close", (code) => {
527
+ clearTimeout(timer);
528
+ resolvePromise({ command, code: code ?? -1, output: out.trim() || "(no output)" });
529
+ });
530
+ });
531
+ }
532
+ // ---- Snapshots & scheduled backups -------------------------------------
533
+ /** List recorded snapshots (most recent first). */
534
+ listSnapshots() {
535
+ return [...this.snapshotLog];
536
+ }
537
+ /** Create a snapshot of a container (image commit or filesystem export). */
538
+ async createSnapshot(container, type = "commit") {
539
+ const ts = fileStamp();
540
+ const hasHook = Boolean(this.config.panel.backupWebhook);
541
+ const destination = "local" + (hasHook ? " + webhook" : "");
542
+ let info;
543
+ if (this.demo) {
544
+ info = {
545
+ id: "snap-" + ts,
546
+ container,
547
+ type,
548
+ ref: type === "commit" ? `soyrage-snapshot/${container}:${ts}` : `${this.config.panel.backupDir}/${container}-${ts}.tar`,
549
+ sizeBytes: type === "commit" ? 120_000_000 + Math.floor(Math.random() * 80_000_000) : 180_000_000 + Math.floor(Math.random() * 60_000_000),
550
+ createdAt: nowIso(),
551
+ destination,
552
+ };
553
+ }
554
+ else if (type === "commit") {
555
+ const ref = `soyrage-snapshot/${container}:${ts}`;
556
+ const handle = await this.docker.resolveContainer(container);
557
+ await handle.commit({ repo: `soyrage-snapshot/${container}`, tag: ts });
558
+ let size = 0;
559
+ try {
560
+ const img = await this.docker.raw.getImage(ref).inspect();
561
+ size = Number(img.Size ?? 0);
562
+ }
563
+ catch {
564
+ /* size best-effort */
565
+ }
566
+ info = { id: "snap-" + ts, container, type, ref, sizeBytes: size, createdAt: nowIso(), destination };
567
+ }
568
+ else {
569
+ const dir = isAbsolute(this.config.panel.backupDir)
570
+ ? this.config.panel.backupDir
571
+ : resolve(process.cwd(), this.config.panel.backupDir);
572
+ if (!existsSync(dir))
573
+ mkdirSync(dir, { recursive: true });
574
+ const file = resolve(dir, `${container}-${ts}.tar`);
575
+ const res = await this.spawnDocker(["export", "-o", file, container], `docker export ${container}`);
576
+ if (res.code !== 0)
577
+ throw new Error(res.output);
578
+ const size = existsSync(file) ? statSync(file).size : 0;
579
+ info = { id: "snap-" + ts, container, type, ref: file, sizeBytes: size, createdAt: nowIso(), destination };
580
+ }
581
+ this.snapshotLog.unshift(info);
582
+ if (this.snapshotLog.length > 100)
583
+ this.snapshotLog.pop();
584
+ this.addAlert({ level: "info", source: container, rule: "snapshot", message: `Snapshot created: ${info.ref}` });
585
+ await this.notify(info);
586
+ return info;
587
+ }
588
+ /** Get the current backup schedule. */
589
+ getSchedule() {
590
+ return { ...this.schedule };
591
+ }
592
+ /** Update the backup schedule (fields are validated/coerced). */
593
+ setSchedule(next) {
594
+ const s = this.schedule;
595
+ if (typeof next.enabled === "boolean")
596
+ s.enabled = next.enabled;
597
+ if (typeof next.time === "string") {
598
+ const m = /^(\d{1,2}):(\d{2})$/.exec(next.time.trim());
599
+ if (m && Number(m[1]) <= 23 && Number(m[2]) <= 59) {
600
+ s.time = `${m[1].padStart(2, "0")}:${m[2]}`;
601
+ }
602
+ }
603
+ if (Array.isArray(next.containers))
604
+ s.containers = next.containers.map(String);
605
+ if (next.type === "commit" || next.type === "export")
606
+ s.type = next.type;
607
+ if (typeof next.email === "string")
608
+ s.email = next.email;
609
+ this.logger.info(`Backup schedule updated: ${JSON.stringify(s)}`);
610
+ return this.getSchedule();
611
+ }
612
+ /** Start the once-a-minute backup scheduler (idempotent). */
613
+ startScheduler() {
614
+ if (this.scheduler)
615
+ return;
616
+ this.scheduler = setInterval(() => void this.tickScheduler(), 30_000);
617
+ if (typeof this.scheduler.unref === "function")
618
+ this.scheduler.unref();
619
+ }
620
+ async tickScheduler() {
621
+ if (!this.schedule.enabled)
622
+ return;
623
+ const now = new Date();
624
+ const hhmm = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
625
+ if (hhmm !== this.schedule.time)
626
+ return;
627
+ const key = now.toISOString().slice(0, 16); // dedupe within the minute
628
+ if (key === this.lastScheduleRun)
629
+ return;
630
+ this.lastScheduleRun = key;
631
+ this.addAlert({ level: "info", source: "system", rule: "backup", message: `Scheduled backup starting (${this.schedule.type}).` });
632
+ try {
633
+ const list = await this.containers();
634
+ const targets = this.schedule.containers.includes("all")
635
+ ? list.filter((c) => c.state === "running").map((c) => c.name)
636
+ : this.schedule.containers;
637
+ for (const name of targets) {
638
+ await this.createSnapshot(name, this.schedule.type);
639
+ }
640
+ }
641
+ catch (err) {
642
+ this.addAlert({ level: "critical", source: "system", rule: "backup", message: `Scheduled backup failed: ${err.message}` });
643
+ }
644
+ }
645
+ /** POST a backup notification to the configured webhook (email/cloud bridge). */
646
+ async notify(snapshot) {
647
+ const url = this.config.panel.backupWebhook;
648
+ if (!url || this.demo)
649
+ return;
650
+ try {
651
+ await fetch(url, {
652
+ method: "POST",
653
+ headers: { "Content-Type": "application/json" },
654
+ body: JSON.stringify({ event: "backup", product: BRAND.product, author: BRAND.author, email: this.schedule.email || undefined, snapshot }),
655
+ });
656
+ }
657
+ catch (err) {
658
+ this.addAlert({ level: "warning", source: "system", rule: "notify", message: `Backup webhook failed: ${err.message}` });
659
+ }
660
+ }
661
+ // ---- Details, networks & volumes ---------------------------------------
662
+ /** Curated inspect summary for the details drawer. */
663
+ async inspectSummary(name) {
664
+ if (this.demo)
665
+ return demoInspect(name);
666
+ const handle = await this.docker.resolveContainer(name);
667
+ const d = await handle.inspect();
668
+ const ports = Object.entries(d.NetworkSettings?.Ports ?? {}).flatMap(([k, v]) => (v ?? []).map((b) => `${b.HostPort}→${k}`));
669
+ return {
670
+ name: normalizeName(d.Name),
671
+ image: d.Config?.Image ?? "",
672
+ id: d.Id.slice(0, 12),
673
+ created: d.Created ?? "",
674
+ state: d.State?.Status ?? "",
675
+ health: d.State?.Health?.Status ?? "n/a",
676
+ restartPolicy: d.HostConfig?.RestartPolicy?.Name || "no",
677
+ command: [d.Path, ...(d.Args ?? [])].join(" ").trim(),
678
+ ports: ports.length ? ports : ["—"],
679
+ mounts: (d.Mounts ?? []).map((m) => `${m.Source ?? m.Name ?? "?"} → ${m.Destination}`),
680
+ networks: Object.keys(d.NetworkSettings?.Networks ?? {}),
681
+ env: (d.Config?.Env ?? []).map((e) => redactEnv(e)),
682
+ };
683
+ }
684
+ /** Networks for the System tab. */
685
+ async networks() {
686
+ if (this.demo)
687
+ return demoNetworks();
688
+ const nets = await this.docker.listNetworks();
689
+ return nets.map((n) => ({ name: n.Name, id: (n.Id ?? "").slice(0, 12), driver: n.Driver ?? "—", scope: n.Scope ?? "—" }));
690
+ }
691
+ /** Volumes for the System tab. */
692
+ async volumes() {
693
+ if (this.demo)
694
+ return demoVolumes();
695
+ const vols = await this.docker.listVolumes();
696
+ return vols.map((v) => ({ name: v.Name, driver: v.Driver ?? "—", mountpoint: v.Mountpoint ?? "—" }));
697
+ }
698
+ // ---- Restore · exec · compose · volumes management ---------------------
699
+ /**
700
+ * Restore a recorded snapshot. Non-destructive by design: a commit snapshot
701
+ * launches a *new* container from the image (the image's CMD/ENTRYPOINT are
702
+ * preserved by `commit`), and an export tar is imported as a new image you can
703
+ * then run. Nothing existing is overwritten.
704
+ */
705
+ async restoreSnapshot(id) {
706
+ const snap = this.snapshotLog.find((s) => s.id === id);
707
+ if (!snap)
708
+ throw new Error(`Snapshot ${id} not found.`);
709
+ if (this.config.readOnly)
710
+ throw new Error("Server is read-only; restore is disabled.");
711
+ const ts = fileStamp();
712
+ if (this.demo) {
713
+ const container = `${snap.container}-restored-${ts}`;
714
+ this.addAlert({ level: "info", source: snap.container, rule: "restore", message: `Restored ${snap.ref} → ${container}` });
715
+ return { ok: true, message: `(demo) Restored ${snap.ref} as ${container}`, container };
716
+ }
717
+ if (snap.type === "commit") {
718
+ const name = `${snap.container}-restored-${ts}`;
719
+ const res = await this.spawnDocker(["run", "-d", "--name", name, snap.ref], `docker run -d --name ${name} ${snap.ref}`);
720
+ if (res.code !== 0)
721
+ throw new Error(res.output);
722
+ this.addAlert({ level: "info", source: snap.container, rule: "restore", message: `Restored ${snap.ref} → ${name}` });
723
+ return { ok: true, message: `Started ${name} from ${snap.ref}.`, container: name };
724
+ }
725
+ const image = `soyrage-restore/${snap.container}:${ts}`;
726
+ const res = await this.spawnDocker(["import", snap.ref, image], `docker import ${snap.ref} ${image}`);
727
+ if (res.code !== 0)
728
+ throw new Error(res.output);
729
+ this.addAlert({ level: "info", source: snap.container, rule: "restore", message: `Imported ${snap.ref} → ${image}` });
730
+ return { ok: true, message: `Imported ${snap.ref} as image ${image}. Run it with your original command.`, image };
731
+ }
732
+ /** One-off command inside a running container (`docker exec … sh -lc`). */
733
+ async execOnce(name, command) {
734
+ if (!command.trim())
735
+ return { command, code: 1, output: "Empty command." };
736
+ if (this.config.readOnly)
737
+ return { command, code: 1, output: "Server is read-only; exec is blocked." };
738
+ if (this.demo)
739
+ return { command: `docker exec ${name} ${command}`, code: 0, output: demoExec(name, command) };
740
+ return this.spawnDocker(["exec", name, "sh", "-lc", command], `docker exec ${name} ${command}`);
741
+ }
742
+ /** Candidate compose files near the working directory (for the editor pickers). */
743
+ findComposeFiles() {
744
+ if (this.demo)
745
+ return ["/srv/apps/web/docker-compose.yml", "/srv/apps/db/compose.yaml"];
746
+ const names = ["docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"];
747
+ const dirs = [process.cwd(), this.config.panel.backupDir];
748
+ const found = new Set();
749
+ for (const dir of dirs) {
750
+ const base = isAbsolute(dir) ? dir : resolve(process.cwd(), dir);
751
+ for (const n of names) {
752
+ const p = resolve(base, n);
753
+ if (existsSync(p))
754
+ found.add(p);
755
+ }
756
+ }
757
+ return [...found];
758
+ }
759
+ isComposePath(path) {
760
+ return /(^|[\\/])(docker-)?compose\.ya?ml$/i.test(path);
761
+ }
762
+ /** Read a host compose file (compose-named, ≤512 KB). */
763
+ readComposeFile(path) {
764
+ if (this.demo)
765
+ return { path, content: DEMO_COMPOSE };
766
+ if (!this.isComposePath(path))
767
+ throw new Error("Only docker-compose / compose YAML files can be edited here.");
768
+ const abs = resolve(path);
769
+ if (!existsSync(abs))
770
+ throw new Error(`No such file: ${abs}`);
771
+ if (statSync(abs).size > 512 * 1024)
772
+ throw new Error("File too large (>512 KB).");
773
+ return { path: abs, content: readFileSync(abs, "utf8") };
774
+ }
775
+ /** Write a host compose file (compose-named, ≤512 KB). Blocked when read-only. */
776
+ writeComposeFile(path, content) {
777
+ if (this.config.readOnly)
778
+ throw new Error("Server is read-only; editing is disabled.");
779
+ if (this.demo)
780
+ return { path, bytes: Buffer.byteLength(content) };
781
+ if (!this.isComposePath(path))
782
+ throw new Error("Only docker-compose / compose YAML files can be edited here.");
783
+ if (Buffer.byteLength(content) > 512 * 1024)
784
+ throw new Error("Content too large (>512 KB).");
785
+ const abs = resolve(path);
786
+ writeFileSync(abs, content, "utf8");
787
+ this.addAlert({ level: "info", source: "system", rule: "compose", message: `Edited compose file ${abs}` });
788
+ return { path: abs, bytes: Buffer.byteLength(content) };
789
+ }
790
+ /** `docker compose` lifecycle against a specific file. */
791
+ async composeAction(path, action) {
792
+ if (this.config.readOnly)
793
+ throw new Error("Server is read-only; compose actions are disabled.");
794
+ if (this.demo)
795
+ return { command: `docker compose -f ${path} ${action}`, code: 0, output: `(demo) compose ${action} completed.` };
796
+ const abs = resolve(path);
797
+ const tail = action === "up" ? ["up", "-d"] : action === "down" ? ["down"] : ["restart"];
798
+ return this.spawnDocker(["compose", "-f", abs, ...tail], `docker compose -f ${abs} ${tail.join(" ")}`);
799
+ }
800
+ /**
801
+ * Attach an extra host volume to a container by recreating it from its own
802
+ * image with the same env / ports / network / restart policy plus the new
803
+ * bind. The original is stopped and kept (renamed `<name>-preattach-<ts>`) so
804
+ * the change is reversible.
805
+ */
806
+ async attachVolume(name, hostPath, containerPath, readOnly = false) {
807
+ if (this.config.readOnly)
808
+ throw new Error("Server is read-only; attaching volumes is disabled.");
809
+ if (!hostPath.trim() || !containerPath.trim())
810
+ throw new Error("Both a host path and a container path are required.");
811
+ const bind = `${hostPath.trim()}:${containerPath.trim()}${readOnly ? ":ro" : ""}`;
812
+ const ts = fileStamp();
813
+ if (this.demo)
814
+ return { ok: true, message: `(demo) Recreated ${name} with ${bind}.`, container: name, backup: `${name}-preattach-${ts}` };
815
+ const handle = await this.docker.resolveContainer(name);
816
+ const d = await handle.inspect();
817
+ const image = d.Config?.Image;
818
+ if (!image)
819
+ throw new Error("Could not determine the container's image.");
820
+ const backupName = `${name}-preattach-${ts}`;
821
+ try {
822
+ await handle.stop({ t: 10 });
823
+ }
824
+ catch { /* may already be stopped */ }
825
+ await handle.rename({ name: backupName });
826
+ try {
827
+ const created = await this.docker.raw.createContainer({
828
+ name,
829
+ Image: image,
830
+ Env: d.Config?.Env ?? [],
831
+ Cmd: d.Config?.Cmd ?? undefined,
832
+ Entrypoint: d.Config?.Entrypoint ?? undefined,
833
+ Labels: d.Config?.Labels ?? undefined,
834
+ ExposedPorts: d.Config?.ExposedPorts ?? undefined,
835
+ HostConfig: {
836
+ Binds: [...(d.HostConfig?.Binds ?? []), bind],
837
+ PortBindings: d.HostConfig?.PortBindings ?? undefined,
838
+ NetworkMode: d.HostConfig?.NetworkMode ?? undefined,
839
+ RestartPolicy: d.HostConfig?.RestartPolicy ?? undefined,
840
+ },
841
+ });
842
+ await created.start();
843
+ }
844
+ catch (err) {
845
+ // Roll the name back so the original is usable again.
846
+ try {
847
+ await handle.rename({ name });
848
+ }
849
+ catch { /* best effort */ }
850
+ throw new Error(`Recreate failed (${err.message}); original left as-is.`);
851
+ }
852
+ this.addAlert({ level: "info", source: name, rule: "volume", message: `Attached ${bind}; previous kept as ${backupName}.` });
853
+ return { ok: true, message: `Recreated ${name} with ${bind}. Previous container kept as ${backupName}.`, container: name, backup: backupName };
854
+ }
855
+ /**
856
+ * Render a snapshot in the Prometheus text exposition format so it can be
857
+ * scraped by Prometheus, Grafana Agent, Zabbix (Prometheus preprocessing or
858
+ * HTTP agent), VictoriaMetrics, etc.
859
+ */
860
+ async prometheus() {
861
+ const snap = await this.snapshot();
862
+ const L = [];
863
+ const push = (line) => L.push(line);
864
+ // Build info — attribution travels with your monitoring, too. ❤
865
+ push("# HELP dockermcp_build_info Build & author metadata.");
866
+ push("# TYPE dockermcp_build_info gauge");
867
+ push(`dockermcp_build_info{product="${esc(BRAND.product)}",author="${esc(BRAND.author)}",version="${esc(BRAND.version)}",url="${esc(BRAND.url)}"} 1`);
868
+ push("# HELP dockermcp_up 1 if the exporter is running.");
869
+ push("# TYPE dockermcp_up gauge");
870
+ push("dockermcp_up 1");
871
+ gauge(push, "dockermcp_host_cpus", "Logical CPUs on the host.", snap.system.cpus);
872
+ gauge(push, "dockermcp_host_memory_bytes", "Total host memory in bytes.", snap.system.memoryBytes);
873
+ gauge(push, "dockermcp_containers_total", "Total containers.", snap.system.containersTotal);
874
+ gauge(push, "dockermcp_containers_running", "Running containers.", snap.system.containersRunning);
875
+ gauge(push, "dockermcp_images_total", "Locally cached images.", snap.system.images);
876
+ gauge(push, "dockermcp_cpu_percent_total", "Aggregate container CPU percent.", snap.cpuTotal);
877
+ gauge(push, "dockermcp_memory_used_bytes", "Aggregate container memory bytes.", snap.memoryUsed);
878
+ // Per-container series.
879
+ push("# HELP dockermcp_container_running 1 if the container is running.");
880
+ push("# TYPE dockermcp_container_running gauge");
881
+ for (const c of snap.containers) {
882
+ push(`dockermcp_container_running{${labels(c)}} ${c.state === "running" ? 1 : 0}`);
883
+ }
884
+ push("# HELP dockermcp_container_cpu_percent Container CPU percent.");
885
+ push("# TYPE dockermcp_container_cpu_percent gauge");
886
+ for (const c of snap.containers) {
887
+ push(`dockermcp_container_cpu_percent{${labels(c)}} ${c.cpu ?? 0}`);
888
+ }
889
+ push("# HELP dockermcp_container_memory_bytes Container memory usage in bytes.");
890
+ push("# TYPE dockermcp_container_memory_bytes gauge");
891
+ for (const c of snap.containers) {
892
+ push(`dockermcp_container_memory_bytes{${labels(c)}} ${c.memory ?? 0}`);
893
+ }
894
+ // Auto-restart & alerts (state-based, cheap — no log scanning here).
895
+ gauge(push, "dockermcp_autorestart_enabled", "Containers with auto-restart on.", this.autoRestart.size);
896
+ push("# HELP dockermcp_container_autorestart 1 if auto-restart is enabled.");
897
+ push("# TYPE dockermcp_container_autorestart gauge");
898
+ for (const c of snap.containers) {
899
+ push(`dockermcp_container_autorestart{${labels(c)}} ${this.autoRestart.has(c.name) ? 1 : 0}`);
900
+ }
901
+ const activeAlerts = snap.containers.filter((c) => (c.state === "exited" && !this.autoRestart.has(c.name)) ||
902
+ /unhealthy/i.test(c.status) ||
903
+ (c.state === "running" && (c.cpu ?? 0) >= 85)).length;
904
+ gauge(push, "dockermcp_alerts_active", "Active state-based alerts.", activeAlerts);
905
+ return L.join("\n") + "\n";
906
+ }
907
+ // ---- Demo helpers -------------------------------------------------------
908
+ /** Demo container list with gentle live jitter so usage looks real. */
909
+ demoContainersLive() {
910
+ return this.demoBase.map((c) => {
911
+ if (c.state !== "running")
912
+ return { ...c, cpu: 0, memory: 0 };
913
+ const jitterCpu = Math.max(0, (c.cpu ?? 0) + (Math.random() - 0.5) * 0.8);
914
+ const jitterMem = (c.memory ?? 0) * (1 + (Math.random() - 0.5) * 0.06);
915
+ return {
916
+ ...c,
917
+ cpu: Number(jitterCpu.toFixed(2)),
918
+ memory: Math.round(jitterMem),
919
+ };
920
+ });
921
+ }
922
+ /** Reflect a lifecycle action in the demo baseline so the UI updates. */
923
+ applyDemoAction(reference, action) {
924
+ const target = this.demoBase.find((c) => c.name === reference);
925
+ if (!target)
926
+ return;
927
+ if (action === "stop") {
928
+ target.state = "exited";
929
+ target.status = "Exited (0) just now";
930
+ }
931
+ else {
932
+ target.state = "running";
933
+ target.status = "Up a few seconds";
934
+ }
935
+ }
936
+ }
937
+ /** Current time as ISO string (kept in one place for testability). */
938
+ function nowIso() {
939
+ return new Date().toISOString();
940
+ }
941
+ /** Truncate a string to a max length with an ellipsis. */
942
+ function truncate(value, max) {
943
+ return value.length <= max ? value : value.slice(0, max - 1) + "…";
944
+ }
945
+ /** Normalise and harden a POSIX path (collapse .., strip trailing slash). */
946
+ function normalizePath(input) {
947
+ const raw = (input || "/").replace(/\\/g, "/");
948
+ const parts = [];
949
+ for (const seg of raw.split("/")) {
950
+ if (!seg || seg === ".")
951
+ continue;
952
+ if (seg === "..")
953
+ parts.pop();
954
+ else
955
+ parts.push(seg);
956
+ }
957
+ return "/" + parts.join("/");
958
+ }
959
+ /** Parse `ls -la` output into typed entries. */
960
+ function parseLsOutput(output) {
961
+ const entries = [];
962
+ for (const line of output.split("\n")) {
963
+ const trimmed = line.trim();
964
+ if (!trimmed || /^total\s/.test(trimmed))
965
+ continue;
966
+ const cols = trimmed.split(/\s+/);
967
+ if (cols.length < 9)
968
+ continue;
969
+ const perms = cols[0];
970
+ const size = Number.parseInt(cols[4], 10) || 0;
971
+ let name = cols.slice(8).join(" ");
972
+ let target;
973
+ let type = "file";
974
+ if (perms[0] === "d")
975
+ type = "dir";
976
+ else if (perms[0] === "l") {
977
+ type = "link";
978
+ const arrow = name.indexOf(" -> ");
979
+ if (arrow >= 0) {
980
+ target = name.slice(arrow + 4);
981
+ name = name.slice(0, arrow);
982
+ }
983
+ }
984
+ if (name === "." || name === "..")
985
+ continue;
986
+ entries.push({ name, type, size, target });
987
+ }
988
+ // Directories first, then alphabetical.
989
+ return entries.sort((a, b) => a.type === b.type ? a.name.localeCompare(b.name) : a.type === "dir" ? -1 : 1);
990
+ }
991
+ /** A filesystem-safe timestamp like 20260716-030004. */
992
+ function fileStamp() {
993
+ const d = new Date();
994
+ const p = (n) => String(n).padStart(2, "0");
995
+ return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
996
+ }
997
+ /** Redact obvious secrets in an ENV entry (KEY=value). */
998
+ function redactEnv(entry) {
999
+ const eq = entry.indexOf("=");
1000
+ if (eq < 0)
1001
+ return entry;
1002
+ const key = entry.slice(0, eq);
1003
+ if (/(pass|secret|token|key|pwd|auth)/i.test(key))
1004
+ return `${key}=••••••`;
1005
+ return entry;
1006
+ }
1007
+ /** Split a command line into argv, honouring simple single/double quotes. */
1008
+ function parseArgv(input) {
1009
+ const out = [];
1010
+ const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
1011
+ let m;
1012
+ while ((m = re.exec(input.trim())) !== null) {
1013
+ out.push(m[1] ?? m[2] ?? m[3]);
1014
+ }
1015
+ return out;
1016
+ }
1017
+ /** Verbs never allowed from the panel terminal (too open/interactive). */
1018
+ const DENY_SUBCOMMANDS = new Set([
1019
+ "run", "swarm", "login", "logout", "context", "plugin", "trust", "secret",
1020
+ "node", "service", "attach", "checkpoint",
1021
+ ]);
1022
+ /** Second-level read verbs for grouped commands. */
1023
+ const READ_SECONDARY = {
1024
+ network: new Set(["ls", "inspect"]),
1025
+ volume: new Set(["ls", "inspect"]),
1026
+ system: new Set(["df", "info", "events"]),
1027
+ compose: new Set(["ps", "config", "logs", "ls", "images", "top"]),
1028
+ image: new Set(["ls", "inspect", "history"]),
1029
+ container: new Set(["ls", "inspect", "logs", "stats", "top", "port", "diff"]),
1030
+ };
1031
+ /** Top-level read verbs (safe even in read-only mode). */
1032
+ const READ_TOP = new Set([
1033
+ "ps", "images", "logs", "inspect", "stats", "top", "version", "info",
1034
+ "port", "diff", "history", "events", "df",
1035
+ ]);
1036
+ /** Decide whether a docker command only reads state. */
1037
+ function isReadCommand(argv) {
1038
+ const sub = argv[1] ?? "";
1039
+ if (READ_TOP.has(sub))
1040
+ return true;
1041
+ const secondary = READ_SECONDARY[sub];
1042
+ if (secondary)
1043
+ return secondary.has(argv[2] ?? "");
1044
+ return false;
1045
+ }
1046
+ /** Escape a Prometheus label value. */
1047
+ function esc(value) {
1048
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, " ");
1049
+ }
1050
+ /** Build the common `name`/`state` label set for a container series. */
1051
+ function labels(c) {
1052
+ return `name="${esc(c.name)}",state="${esc(c.state)}",image="${esc(c.image)}"`;
1053
+ }
1054
+ /** Emit a single-value gauge with HELP/TYPE headers. */
1055
+ function gauge(push, name, help, value) {
1056
+ push(`# HELP ${name} ${help}`);
1057
+ push(`# TYPE ${name} gauge`);
1058
+ push(`${name} ${value}`);
1059
+ }
1060
+ // ---------------------------------------------------------------------------
1061
+ // Demo data — deliberately realistic so the panel & TUI look great out of box.
1062
+ // ---------------------------------------------------------------------------
1063
+ function demoSystem(readOnly) {
1064
+ return {
1065
+ engine: "27.3.1",
1066
+ os: "SoyRage Lab (Debian 12)",
1067
+ arch: "x86_64",
1068
+ cpus: 8,
1069
+ memoryBytes: 33_567_776_768,
1070
+ containersRunning: 6,
1071
+ containersTotal: 7,
1072
+ images: 14,
1073
+ demo: true,
1074
+ readOnly,
1075
+ };
1076
+ }
1077
+ function demoContainers() {
1078
+ const GB = 1024 ** 3;
1079
+ return [
1080
+ { id: "a1b2c3d4e5f6", name: "web", image: "nginx:alpine", state: "running", status: "Up 3 days (healthy)", ports: ["443→443/tcp", "80→80/tcp"], cpu: 0.4, memory: 41_943_040, memoryLimit: GB },
1081
+ { id: "b2c3d4e5f6a1", name: "api", image: "soyrage/api:1.8.2", state: "running", status: "Up 3 days (healthy)", ports: ["8080→8080/tcp"], cpu: 3.1, memory: 268_435_456, memoryLimit: 2 * GB },
1082
+ { id: "c3d4e5f6a1b2", name: "worker", image: "soyrage/worker:1.8.2", state: "running", status: "Up 3 days", ports: [], cpu: 1.7, memory: 157_286_400, memoryLimit: GB },
1083
+ { id: "d4e5f6a1b2c3", name: "postgres", image: "postgres:16-alpine", state: "running", status: "Up 3 days (healthy)", ports: ["5432→5432/tcp"], cpu: 0.9, memory: 402_653_184, memoryLimit: 2 * GB },
1084
+ { id: "e5f6a1b2c3d4", name: "redis", image: "redis:7-alpine", state: "running", status: "Up 3 days", ports: ["6379→6379/tcp"], cpu: 0.2, memory: 20_971_520, memoryLimit: 512 * 1024 ** 2 },
1085
+ { id: "f6a1b2c3d4e5", name: "grafana", image: "grafana/grafana:11.2.0", state: "running", status: "Up 3 days (healthy)", ports: ["3000→3000/tcp"], cpu: 0.6, memory: 96_468_992, memoryLimit: GB },
1086
+ { id: "0a1b2c3d4e5f", name: "backup", image: "soyrage/backup:latest", state: "exited", status: "Exited (0) 6 hours ago", ports: [], cpu: 0, memory: 0, memoryLimit: 0 },
1087
+ ];
1088
+ }
1089
+ function demoImages() {
1090
+ return [
1091
+ { id: "9f2a1b7c3d4e", tag: "nginx:alpine", sizeBytes: 24_117_248 },
1092
+ { id: "1a2b3c4d5e6f", tag: "soyrage/api:1.8.2", sizeBytes: 189_792_256 },
1093
+ { id: "2b3c4d5e6f70", tag: "soyrage/worker:1.8.2", sizeBytes: 176_160_768 },
1094
+ { id: "3c4d5e6f7081", tag: "postgres:16-alpine", sizeBytes: 251_658_240 },
1095
+ { id: "4d5e6f708192", tag: "redis:7-alpine", sizeBytes: 41_943_040 },
1096
+ { id: "5e6f70819203", tag: "grafana/grafana:11.2.0", sizeBytes: 419_430_400 },
1097
+ ];
1098
+ }
1099
+ function demoLogs(reference) {
1100
+ const name = reference.replace(/[^a-zA-Z0-9_-]/g, "") || "container";
1101
+ const now = "2026-07-16T09:";
1102
+ return [
1103
+ `${now}12:03 [info] ${name}: starting up (SoyRage Lab)`,
1104
+ `${now}12:03 [info] ${name}: configuration loaded from /etc/${name}/config.yaml`,
1105
+ `${now}12:04 [info] ${name}: connected to postgres:5432`,
1106
+ `${now}12:04 [info] ${name}: cache warmed (redis:6379)`,
1107
+ `${now}12:05 [info] ${name}: listening on 0.0.0.0:8080`,
1108
+ `${now}18:22 [info] ${name}: healthcheck ok (12ms)`,
1109
+ `${now}31:10 [warn] ${name}: slow query 214ms — SELECT * FROM events`,
1110
+ `${now}47:55 [info] ${name}: processed 1,204 jobs in the last hour`,
1111
+ `${now}59:01 [info] ${name}: healthcheck ok (9ms)`,
1112
+ ].join("\n");
1113
+ }
1114
+ function demoTree(name) {
1115
+ const file = (size, content) => ({ type: "file", size, content });
1116
+ return {
1117
+ type: "dir",
1118
+ children: {
1119
+ app: {
1120
+ type: "dir",
1121
+ children: {
1122
+ "server.js": file(4213, `// ${name} entry point — SoyRage Lab\nconst express = require("express");\nconst app = express();\napp.get("/health", (_, res) => res.json({ ok: true }));\napp.listen(8080, () => console.log("${name} listening on :8080"));\n`),
1123
+ "package.json": file(612, `{\n "name": "${name}",\n "version": "1.8.2",\n "scripts": { "start": "node server.js" }\n}\n`),
1124
+ public: { type: "dir", children: { "index.html": file(198, "<!doctype html><title>SoyRage</title><h1>OK</h1>\n") } },
1125
+ },
1126
+ },
1127
+ etc: {
1128
+ type: "dir",
1129
+ children: {
1130
+ [name]: { type: "dir", children: { "config.yaml": file(340, `service: ${name}\nlog_level: info\ndatabase:\n host: postgres\n port: 5432\ncache:\n host: redis\n port: 6379\n`) } },
1131
+ hostname: file(12, `${name}\n`),
1132
+ hosts: file(174, "127.0.0.1\tlocalhost\n172.18.0.4\t" + name + "\n"),
1133
+ },
1134
+ },
1135
+ "var": {
1136
+ type: "dir",
1137
+ children: {
1138
+ log: { type: "dir", children: { "app.log": file(20480, demoLogs(name) + "\n") } },
1139
+ "www": { type: "link", target: "/app/public" },
1140
+ },
1141
+ },
1142
+ "entrypoint.sh": file(286, "#!/bin/sh\nset -e\necho \"Starting " + name + "…\"\nexec node /app/server.js\n"),
1143
+ ".dockerenv": file(0, ""),
1144
+ },
1145
+ };
1146
+ }
1147
+ function walk(name, path) {
1148
+ let node = demoTree(name);
1149
+ if (path === "/")
1150
+ return node;
1151
+ for (const seg of path.split("/")) {
1152
+ if (!seg)
1153
+ continue;
1154
+ if (node.type !== "dir" || !node.children || !node.children[seg])
1155
+ return null;
1156
+ node = node.children[seg];
1157
+ }
1158
+ return node;
1159
+ }
1160
+ function demoListDir(name, path) {
1161
+ const node = walk(name, path);
1162
+ if (!node)
1163
+ throw new Error(`ls: ${path}: No such file or directory`);
1164
+ if (node.type !== "dir" || !node.children)
1165
+ throw new Error(`ls: ${path}: Not a directory`);
1166
+ const entries = Object.entries(node.children).map(([n, child]) => ({
1167
+ name: n,
1168
+ type: child.type,
1169
+ size: child.size ?? (child.type === "dir" ? 4096 : 0),
1170
+ target: child.target,
1171
+ }));
1172
+ // Merge any overlay files (created/edited in demo mode) living in this dir.
1173
+ for (const [key, content] of demoEdits) {
1174
+ const [ovName, ovPath] = key.split("::");
1175
+ if (ovName !== name || parentDir(ovPath) !== path)
1176
+ continue;
1177
+ const base = baseName(ovPath);
1178
+ const existing = entries.find((e) => e.name === base);
1179
+ if (existing)
1180
+ existing.size = Buffer.byteLength(content);
1181
+ else
1182
+ entries.push({ name: base, type: "file", size: Buffer.byteLength(content) });
1183
+ }
1184
+ return entries.sort((a, b) => a.type === b.type ? a.name.localeCompare(b.name) : a.type === "dir" ? -1 : 1);
1185
+ }
1186
+ /** In-memory overlay so demo-mode file edits persist within a session. */
1187
+ const demoEdits = new Map();
1188
+ function demoWrite(name, path, content) {
1189
+ demoEdits.set(`${name}::${path}`, content);
1190
+ }
1191
+ function demoReadFile(name, path) {
1192
+ const overlaid = demoEdits.get(`${name}::${path}`);
1193
+ if (overlaid !== undefined)
1194
+ return overlaid;
1195
+ const node = walk(name, path);
1196
+ if (!node)
1197
+ throw new Error(`cat: ${path}: No such file or directory`);
1198
+ if (node.type === "dir")
1199
+ throw new Error(`cat: ${path}: Is a directory`);
1200
+ if (node.type === "link")
1201
+ return `(symlink → ${node.target})\n`;
1202
+ return node.content ?? "";
1203
+ }
1204
+ /** Parent directory of a POSIX path. */
1205
+ function parentDir(p) {
1206
+ const i = p.lastIndexOf("/");
1207
+ return i <= 0 ? "/" : p.slice(0, i);
1208
+ }
1209
+ function baseName(p) {
1210
+ return p.slice(p.lastIndexOf("/") + 1);
1211
+ }
1212
+ /** Strip markdown code fences an LLM may wrap content in. */
1213
+ function stripFences(text) {
1214
+ const fence = /^```[a-zA-Z]*\n([\s\S]*?)\n```$/m.exec(text.trim());
1215
+ return fence ? fence[1] : text;
1216
+ }
1217
+ /** Best-effort natural-language → docker command (used as an AI fallback). */
1218
+ function heuristicCommand(prompt) {
1219
+ const p = prompt.toLowerCase();
1220
+ const names = demoContainers().map((c) => c.name);
1221
+ const target = names.find((n) => p.includes(n)) || "";
1222
+ if (/\brestart|reboot\b/.test(p))
1223
+ return `docker restart ${target || "<container>"}`;
1224
+ if (/\bstop|halt|kill\b/.test(p))
1225
+ return `docker stop ${target || "<container>"}`;
1226
+ if (/\bstart|launch|run again\b/.test(p))
1227
+ return `docker start ${target || "<container>"}`;
1228
+ if (/\blog|crash|why|error\b/.test(p))
1229
+ return `docker logs --tail 100 ${target || "<container>"}`;
1230
+ if (/\bstat|cpu|memory|ram|usage|slow\b/.test(p))
1231
+ return `docker stats${target ? " " + target : ""}`;
1232
+ if (/\bimage/.test(p))
1233
+ return "docker images";
1234
+ if (/\bnetwork/.test(p))
1235
+ return "docker network ls";
1236
+ if (/\bvolume/.test(p))
1237
+ return "docker volume ls";
1238
+ if (/\bdisk|space/.test(p))
1239
+ return "docker system df";
1240
+ return "docker ps -a";
1241
+ }
1242
+ /** Simulated AI responses for demo mode. */
1243
+ function demoAi(mode, prompt, context) {
1244
+ if (mode === "edit") {
1245
+ const banner = `# Edited by the AI copilot (demo) — request: ${prompt.slice(0, 60)}\n`;
1246
+ const body = context.startsWith("#") ? context : banner + context;
1247
+ return { source: "demo-ai", content: body };
1248
+ }
1249
+ return {
1250
+ source: "demo-ai",
1251
+ command: heuristicCommand(prompt),
1252
+ explanation: "Suggested from your request (demo AI). Configure DOCKER_MCP_AI_ENDPOINT for a real model.",
1253
+ };
1254
+ }
1255
+ /** Fabricated `docker …` output for demo mode. */
1256
+ function demoCommand(argv) {
1257
+ const sub = argv[1] ?? "";
1258
+ const c = demoContainers();
1259
+ if (sub === "ps") {
1260
+ const rows = c.filter((x) => x.state === "running");
1261
+ return ("CONTAINER ID IMAGE STATUS PORTS\n" +
1262
+ rows.map((x) => `${x.id} ${x.image.padEnd(22)} ${x.status.padEnd(18)} ${x.ports.join(", ")}`).join("\n"));
1263
+ }
1264
+ if (sub === "images") {
1265
+ return ("REPOSITORY TAG IMAGE ID SIZE\n" +
1266
+ demoImages().map((i) => { const [r, t] = i.tag.split(":"); return `${r.padEnd(22)} ${(t ?? "latest").padEnd(8)} ${i.id} ${(i.sizeBytes / 1048576).toFixed(0)}MB`; }).join("\n"));
1267
+ }
1268
+ if (sub === "version")
1269
+ return "Client: 27.3.1\nServer: Docker Engine 27.3.1 (API 1.47)\n(SoyRage Lab demo)";
1270
+ if (sub === "info")
1271
+ return "Containers: 7 (6 running)\nImages: 14\nServer Version: 27.3.1\nOperating System: SoyRage Lab (Debian 12)\nCPUs: 8\nTotal Memory: 31.26GiB";
1272
+ if (sub === "stats")
1273
+ return c.filter((x) => x.state === "running").map((x) => `${x.name.padEnd(10)} CPU ${(x.cpu ?? 0).toFixed(1)}% MEM ${(((x.memory ?? 0) / 1048576) | 0)}MiB`).join("\n");
1274
+ if (sub === "logs")
1275
+ return demoLogs(argv[argv.length - 1] || "web");
1276
+ if (sub === "network")
1277
+ return "NETWORK ID NAME DRIVER SCOPE\n" + demoNetworks().map((n) => `${n.id} ${n.name.padEnd(8)} ${n.driver.padEnd(7)} ${n.scope}`).join("\n");
1278
+ if (sub === "volume")
1279
+ return "DRIVER VOLUME NAME\n" + demoVolumes().map((v) => `${v.driver.padEnd(7)} ${v.name}`).join("\n");
1280
+ return `(demo) ran: docker ${argv.slice(1).join(" ")}\n✓ ok`;
1281
+ }
1282
+ function demoInspect(name) {
1283
+ const c = demoContainers().find((x) => x.name === name) ?? demoContainers()[0];
1284
+ return {
1285
+ name: c.name,
1286
+ image: c.image,
1287
+ id: c.id,
1288
+ created: "2026-07-13T08:11:52Z",
1289
+ state: c.state,
1290
+ health: /healthy/.test(c.status) ? "healthy" : "n/a",
1291
+ restartPolicy: "unless-stopped",
1292
+ command: name === "web" ? "nginx -g 'daemon off;'" : "node /app/server.js",
1293
+ ports: c.ports.length ? c.ports : ["—"],
1294
+ mounts: [`/srv/${name}/data → /data`, `soyrage_${name}_conf → /etc/${name}`],
1295
+ networks: ["soyrage_net", "bridge"],
1296
+ env: ["NODE_ENV=production", "LOG_LEVEL=info", "DB_HOST=postgres", "API_KEY=••••••"],
1297
+ };
1298
+ }
1299
+ function demoNetworks() {
1300
+ return [
1301
+ { name: "soyrage_net", id: "a1c2e3f40506", driver: "bridge", scope: "local" },
1302
+ { name: "bridge", id: "0b1c2d3e4f50", driver: "bridge", scope: "local" },
1303
+ { name: "host", id: "1c2d3e4f5061", driver: "host", scope: "local" },
1304
+ { name: "none", id: "2d3e4f506172", driver: "null", scope: "local" },
1305
+ ];
1306
+ }
1307
+ function demoVolumes() {
1308
+ return [
1309
+ { name: "soyrage_postgres_data", driver: "local", mountpoint: "/var/lib/docker/volumes/soyrage_postgres_data/_data" },
1310
+ { name: "soyrage_grafana_data", driver: "local", mountpoint: "/var/lib/docker/volumes/soyrage_grafana_data/_data" },
1311
+ { name: "demo-cache-data", driver: "local", mountpoint: "/var/lib/docker/volumes/demo-cache-data/_data" },
1312
+ ];
1313
+ }
1314
+ /** A believable exec response for demo mode. */
1315
+ function demoExec(name, command) {
1316
+ const cmd = command.trim();
1317
+ if (/^ls\b/.test(cmd))
1318
+ return "bin dev etc home lib proc root srv tmp usr var";
1319
+ if (/^ps\b/.test(cmd))
1320
+ return "PID USER TIME COMMAND\n 1 root 0:04 nginx: master process\n 31 nginx 0:12 nginx: worker process";
1321
+ if (/^cat\s+\/etc\/hostname/.test(cmd))
1322
+ return name;
1323
+ if (/^env\b/.test(cmd))
1324
+ return "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\nHOSTNAME=" + name + "\nNODE_ENV=production";
1325
+ if (/^whoami\b/.test(cmd))
1326
+ return "root";
1327
+ if (/^df\b/.test(cmd))
1328
+ return "Filesystem 1K-blocks Used Available Use% Mounted on\noverlay 61202244 8123456 49876543 15% /";
1329
+ return `$ ${cmd}\n(demo container — command acknowledged)`;
1330
+ }
1331
+ /** A sample compose file shown in demo mode. */
1332
+ const DEMO_COMPOSE = `services:
1333
+ web:
1334
+ image: nginx:alpine
1335
+ ports:
1336
+ - "80:80"
1337
+ - "443:443"
1338
+ volumes:
1339
+ - ./site:/usr/share/nginx/html:ro
1340
+ restart: unless-stopped
1341
+ api:
1342
+ image: soyrage/api:1.8.2
1343
+ environment:
1344
+ - NODE_ENV=production
1345
+ depends_on:
1346
+ - postgres
1347
+ postgres:
1348
+ image: postgres:16-alpine
1349
+ volumes:
1350
+ - postgres_data:/var/lib/postgresql/data
1351
+ restart: unless-stopped
1352
+
1353
+ volumes:
1354
+ postgres_data:
1355
+ `;
1356
+ //# sourceMappingURL=service.js.map