@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.
- package/LICENSE +21 -0
- package/README.md +882 -0
- package/dist/ai/credentials.d.ts +72 -0
- package/dist/ai/credentials.js +157 -0
- package/dist/ai/credentials.js.map +1 -0
- package/dist/ai/login.d.ts +22 -0
- package/dist/ai/login.js +189 -0
- package/dist/ai/login.js.map +1 -0
- package/dist/ai/provider.d.ts +29 -0
- package/dist/ai/provider.js +113 -0
- package/dist/ai/provider.js.map +1 -0
- package/dist/assets.d.ts +20 -0
- package/dist/assets.js +56 -0
- package/dist/assets.js.map +1 -0
- package/dist/branding.d.ts +31 -0
- package/dist/branding.js +61 -0
- package/dist/branding.js.map +1 -0
- package/dist/config.d.ts +65 -0
- package/dist/config.js +128 -0
- package/dist/config.js.map +1 -0
- package/dist/docker/client.d.ts +66 -0
- package/dist/docker/client.js +161 -0
- package/dist/docker/client.js.map +1 -0
- package/dist/docker/compose.d.ts +74 -0
- package/dist/docker/compose.js +126 -0
- package/dist/docker/compose.js.map +1 -0
- package/dist/entry.d.ts +15 -0
- package/dist/entry.js +33 -0
- package/dist/entry.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +100 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +25 -0
- package/dist/logger.js +68 -0
- package/dist/logger.js.map +1 -0
- package/dist/panel/index.d.ts +16 -0
- package/dist/panel/index.js +53 -0
- package/dist/panel/index.js.map +1 -0
- package/dist/panel/public/app.js +1004 -0
- package/dist/panel/public/index.html +243 -0
- package/dist/panel/public/styles.css +428 -0
- package/dist/panel/server.d.ts +23 -0
- package/dist/panel/server.js +355 -0
- package/dist/panel/server.js.map +1 -0
- package/dist/panel/service.d.ts +327 -0
- package/dist/panel/service.js +1356 -0
- package/dist/panel/service.js.map +1 -0
- package/dist/plugins.d.ts +50 -0
- package/dist/plugins.js +121 -0
- package/dist/plugins.js.map +1 -0
- package/dist/ragedocker.d.ts +21 -0
- package/dist/ragedocker.js +164 -0
- package/dist/ragedocker.js.map +1 -0
- package/dist/tools/about.d.ts +15 -0
- package/dist/tools/about.js +55 -0
- package/dist/tools/about.js.map +1 -0
- package/dist/tools/compose.d.ts +19 -0
- package/dist/tools/compose.js +145 -0
- package/dist/tools/compose.js.map +1 -0
- package/dist/tools/containers.d.ts +13 -0
- package/dist/tools/containers.js +119 -0
- package/dist/tools/containers.js.map +1 -0
- package/dist/tools/context.d.ts +37 -0
- package/dist/tools/context.js +13 -0
- package/dist/tools/context.js.map +1 -0
- package/dist/tools/images.d.ts +11 -0
- package/dist/tools/images.js +38 -0
- package/dist/tools/images.js.map +1 -0
- package/dist/tools/index.d.ts +15 -0
- package/dist/tools/index.js +34 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/lifecycle.d.ts +16 -0
- package/dist/tools/lifecycle.js +165 -0
- package/dist/tools/lifecycle.js.map +1 -0
- package/dist/tools/logs.d.ts +15 -0
- package/dist/tools/logs.js +110 -0
- package/dist/tools/logs.js.map +1 -0
- package/dist/tools/system.d.ts +14 -0
- package/dist/tools/system.js +108 -0
- package/dist/tools/system.js.map +1 -0
- package/dist/tui/ansi.d.ts +66 -0
- package/dist/tui/ansi.js +118 -0
- package/dist/tui/ansi.js.map +1 -0
- package/dist/tui/app.d.ts +160 -0
- package/dist/tui/app.js +1339 -0
- package/dist/tui/app.js.map +1 -0
- package/dist/tui/box.d.ts +18 -0
- package/dist/tui/box.js +38 -0
- package/dist/tui/box.js.map +1 -0
- package/dist/tui/index.d.ts +19 -0
- package/dist/tui/index.js +66 -0
- package/dist/tui/index.js.map +1 -0
- package/dist/update/channel.d.ts +82 -0
- package/dist/update/channel.js +178 -0
- package/dist/update/channel.js.map +1 -0
- package/dist/updates.json +42 -0
- package/dist/utils/format.d.ts +24 -0
- package/dist/utils/format.js +69 -0
- package/dist/utils/format.js.map +1 -0
- package/dist/utils/result.d.ts +35 -0
- package/dist/utils/result.js +31 -0
- package/dist/utils/result.js.map +1 -0
- package/package.json +90 -0
- package/scripts/install.mjs +132 -0
package/dist/tui/app.js
ADDED
|
@@ -0,0 +1,1339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal UI application.
|
|
3
|
+
*
|
|
4
|
+
* A creative, dependency-free TUI in the spirit of lazydocker: a full-screen
|
|
5
|
+
* dashboard with a live container list, per-container CPU/memory gauges, a
|
|
6
|
+
* details/logs pane and one-key lifecycle actions — wrapped in a SoyRage
|
|
7
|
+
* Agency welcome that thanks you for using the repo and asks for a ⭐.
|
|
8
|
+
*
|
|
9
|
+
* Rendering is a hand-rolled ANSI frame (no curses library), which keeps the
|
|
10
|
+
* dependency surface at zero and the styling fully under our control.
|
|
11
|
+
*
|
|
12
|
+
* Part of Docker MCP Server.
|
|
13
|
+
* Crafted by SoyRage Agency — https://soyrage.es/
|
|
14
|
+
* MIT licensed (see LICENSE).
|
|
15
|
+
*/
|
|
16
|
+
import { spawn } from "node:child_process";
|
|
17
|
+
import { BRAND, ASCII_BANNER } from "../branding.js";
|
|
18
|
+
import { bar, center, color, ctl, padEnd, padStart, truncate, } from "./ansi.js";
|
|
19
|
+
import { drawBox } from "./box.js";
|
|
20
|
+
import { checkForUpdate } from "../update/channel.js";
|
|
21
|
+
/** Humanise bytes for compact display. */
|
|
22
|
+
function bytes(n) {
|
|
23
|
+
if (!n)
|
|
24
|
+
return "0B";
|
|
25
|
+
const u = ["B", "K", "M", "G", "T"];
|
|
26
|
+
const e = Math.min(Math.floor(Math.log(n) / Math.log(1024)), u.length - 1);
|
|
27
|
+
return `${(n / 1024 ** e).toFixed(e === 0 ? 0 : 1)}${u[e]}`;
|
|
28
|
+
}
|
|
29
|
+
/** The interactive terminal application. */
|
|
30
|
+
export class TuiApp {
|
|
31
|
+
service;
|
|
32
|
+
out;
|
|
33
|
+
inp;
|
|
34
|
+
mode = "splash";
|
|
35
|
+
containers = [];
|
|
36
|
+
system = null;
|
|
37
|
+
selected = 0;
|
|
38
|
+
showLogs = false;
|
|
39
|
+
logs = "";
|
|
40
|
+
status = "";
|
|
41
|
+
timer = null;
|
|
42
|
+
refreshing = false;
|
|
43
|
+
input = "normal";
|
|
44
|
+
aiInput = "";
|
|
45
|
+
message = "";
|
|
46
|
+
messageTitle = "";
|
|
47
|
+
pending = null;
|
|
48
|
+
update = null;
|
|
49
|
+
/** Lazy-loaded inspect detail for the selected container (LazyDocker-style). */
|
|
50
|
+
detail = null;
|
|
51
|
+
detailFor = "";
|
|
52
|
+
/** An in-flight lifecycle animation (start/stop/restart). */
|
|
53
|
+
anim = null;
|
|
54
|
+
animTimer = null;
|
|
55
|
+
// View controls.
|
|
56
|
+
filter = "";
|
|
57
|
+
sortKey = "name";
|
|
58
|
+
sortDesc = false;
|
|
59
|
+
frozen = false;
|
|
60
|
+
freezeOverride = false;
|
|
61
|
+
logFollow = true;
|
|
62
|
+
logTail = 200;
|
|
63
|
+
volumeInput = "";
|
|
64
|
+
// Animation frame ticker (drives the live state glyphs) and the action menu.
|
|
65
|
+
tick = 0;
|
|
66
|
+
ticker = null;
|
|
67
|
+
menuIndex = 0;
|
|
68
|
+
menuItems = [];
|
|
69
|
+
constructor(service, out = process.stdout, inp = process.stdin) {
|
|
70
|
+
this.service = service;
|
|
71
|
+
this.out = out;
|
|
72
|
+
this.inp = inp;
|
|
73
|
+
}
|
|
74
|
+
/** Enter alt-screen, wire input, show splash, then run. */
|
|
75
|
+
async start() {
|
|
76
|
+
this.out.write(ctl.enterAlt + ctl.hideCursor + ctl.clear);
|
|
77
|
+
this.setupInput();
|
|
78
|
+
this.out.on("resize", () => this.render());
|
|
79
|
+
this.renderSplash();
|
|
80
|
+
}
|
|
81
|
+
// ---- Input --------------------------------------------------------------
|
|
82
|
+
onData = (key) => this.onKey(key);
|
|
83
|
+
setupInput() {
|
|
84
|
+
if (this.inp.isTTY)
|
|
85
|
+
this.inp.setRawMode(true);
|
|
86
|
+
this.inp.resume();
|
|
87
|
+
this.inp.setEncoding("utf8");
|
|
88
|
+
this.inp.on("data", this.onData);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Hand the terminal to an interactive child (a shell, an editor), then take it
|
|
92
|
+
* back. We leave the alt-screen and raw mode, detach our key handler, run the
|
|
93
|
+
* child with inherited stdio, and on exit restore everything and repaint.
|
|
94
|
+
*/
|
|
95
|
+
runInteractive(cmd, args, note) {
|
|
96
|
+
return new Promise((resolvePromise) => {
|
|
97
|
+
if (this.timer)
|
|
98
|
+
clearInterval(this.timer);
|
|
99
|
+
if (this.ticker) {
|
|
100
|
+
clearInterval(this.ticker);
|
|
101
|
+
this.ticker = null;
|
|
102
|
+
}
|
|
103
|
+
this.inp.removeListener("data", this.onData);
|
|
104
|
+
if (this.inp.isTTY)
|
|
105
|
+
this.inp.setRawMode(false);
|
|
106
|
+
this.out.write(ctl.showCursor + ctl.exitAlt);
|
|
107
|
+
this.out.write(`\n ${note}\n\n`);
|
|
108
|
+
const child = spawn(cmd, args, { stdio: "inherit" });
|
|
109
|
+
const finish = (line) => {
|
|
110
|
+
this.out.write(`\n ${line}\n ${"Press any key to return to the dashboard."}\n`);
|
|
111
|
+
// Re-enter our world; the next keypress is swallowed as the "any key".
|
|
112
|
+
const resume = () => {
|
|
113
|
+
this.inp.removeListener("data", resume);
|
|
114
|
+
this.out.write(ctl.enterAlt + ctl.hideCursor + ctl.clear);
|
|
115
|
+
if (this.inp.isTTY)
|
|
116
|
+
this.inp.setRawMode(true);
|
|
117
|
+
this.inp.on("data", this.onData);
|
|
118
|
+
this.timer = setInterval(() => void this.refresh(), 3000);
|
|
119
|
+
this.ticker = setInterval(() => { this.tick++; if (this.mode === "main")
|
|
120
|
+
this.render(); }, 220);
|
|
121
|
+
if (typeof this.ticker.unref === "function")
|
|
122
|
+
this.ticker.unref();
|
|
123
|
+
void this.refresh();
|
|
124
|
+
resolvePromise();
|
|
125
|
+
};
|
|
126
|
+
this.inp.resume();
|
|
127
|
+
this.inp.setEncoding("utf8");
|
|
128
|
+
if (this.inp.isTTY)
|
|
129
|
+
this.inp.setRawMode(true);
|
|
130
|
+
this.inp.on("data", resume);
|
|
131
|
+
};
|
|
132
|
+
child.on("error", (e) => finish(e.code === "ENOENT" ? `Command not found: ${cmd}` : String(e)));
|
|
133
|
+
child.on("close", (code) => finish(code === 0 ? "Session ended." : `Exited with code ${code}.`));
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
onKey(key) {
|
|
137
|
+
// Ctrl-C / Ctrl-D always quit.
|
|
138
|
+
if (key === "\x03" || key === "\x04")
|
|
139
|
+
return void this.quit();
|
|
140
|
+
if (this.mode === "splash") {
|
|
141
|
+
this.enterMain();
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (this.input === "message") {
|
|
145
|
+
this.message = "";
|
|
146
|
+
this.input = "normal";
|
|
147
|
+
this.render();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (this.input === "confirm")
|
|
151
|
+
return this.onConfirmKey(key);
|
|
152
|
+
if (this.input === "ai")
|
|
153
|
+
return this.onAiKey(key);
|
|
154
|
+
if (this.input === "filter")
|
|
155
|
+
return this.onFilterKey(key);
|
|
156
|
+
if (this.input === "volume")
|
|
157
|
+
return this.onVolumeKey(key);
|
|
158
|
+
if (this.input === "snapshots")
|
|
159
|
+
return this.onSnapshotsKey(key);
|
|
160
|
+
if (this.input === "menu")
|
|
161
|
+
return this.onMenuKey(key);
|
|
162
|
+
switch (key) {
|
|
163
|
+
case "q":
|
|
164
|
+
return void this.quit();
|
|
165
|
+
case "\x1b[A": // up
|
|
166
|
+
case "k":
|
|
167
|
+
this.move(-1);
|
|
168
|
+
break;
|
|
169
|
+
case "\x1b[B": // down
|
|
170
|
+
case "j":
|
|
171
|
+
this.move(1);
|
|
172
|
+
break;
|
|
173
|
+
case "m":
|
|
174
|
+
case "\r":
|
|
175
|
+
case "\n":
|
|
176
|
+
this.openMenu();
|
|
177
|
+
return;
|
|
178
|
+
case "g":
|
|
179
|
+
this.jump("top");
|
|
180
|
+
break;
|
|
181
|
+
case "G":
|
|
182
|
+
this.jump("bottom");
|
|
183
|
+
break;
|
|
184
|
+
case "/":
|
|
185
|
+
this.input = "filter";
|
|
186
|
+
this.render();
|
|
187
|
+
return;
|
|
188
|
+
case "o":
|
|
189
|
+
this.cycleSort();
|
|
190
|
+
break;
|
|
191
|
+
case "O":
|
|
192
|
+
this.sortDesc = !this.sortDesc;
|
|
193
|
+
this.selected = 0;
|
|
194
|
+
this.render();
|
|
195
|
+
void this.loadDetail();
|
|
196
|
+
break;
|
|
197
|
+
case " ":
|
|
198
|
+
this.frozen = !this.frozen;
|
|
199
|
+
this.status = this.frozen ? color.yellow("⏸ Live updates paused (space to resume).") : color.green("▶ Live updates resumed.");
|
|
200
|
+
this.render();
|
|
201
|
+
break;
|
|
202
|
+
case "y":
|
|
203
|
+
this.copyId();
|
|
204
|
+
break;
|
|
205
|
+
case "i":
|
|
206
|
+
void this.showInspect();
|
|
207
|
+
return;
|
|
208
|
+
case "n":
|
|
209
|
+
void this.showNetworks();
|
|
210
|
+
return;
|
|
211
|
+
case "v":
|
|
212
|
+
void this.showVolumes();
|
|
213
|
+
return;
|
|
214
|
+
case "A":
|
|
215
|
+
void this.showAlerts();
|
|
216
|
+
return;
|
|
217
|
+
case "b":
|
|
218
|
+
this.confirmSnapshot();
|
|
219
|
+
return;
|
|
220
|
+
case "B":
|
|
221
|
+
this.openSnapshots();
|
|
222
|
+
return;
|
|
223
|
+
case "e":
|
|
224
|
+
void this.openShell();
|
|
225
|
+
return;
|
|
226
|
+
case "c":
|
|
227
|
+
void this.editCompose();
|
|
228
|
+
return;
|
|
229
|
+
case "V":
|
|
230
|
+
this.startVolume();
|
|
231
|
+
return;
|
|
232
|
+
case "f":
|
|
233
|
+
if (this.showLogs) {
|
|
234
|
+
this.logFollow = !this.logFollow;
|
|
235
|
+
this.status = color.gray(`Log follow ${this.logFollow ? "on" : "off"}`);
|
|
236
|
+
this.render();
|
|
237
|
+
}
|
|
238
|
+
break;
|
|
239
|
+
case "+":
|
|
240
|
+
case "=":
|
|
241
|
+
if (this.showLogs) {
|
|
242
|
+
this.logTail = Math.min(2000, this.logTail + 100);
|
|
243
|
+
void this.loadLogs();
|
|
244
|
+
}
|
|
245
|
+
break;
|
|
246
|
+
case "-":
|
|
247
|
+
case "_":
|
|
248
|
+
if (this.showLogs) {
|
|
249
|
+
this.logTail = Math.max(50, this.logTail - 100);
|
|
250
|
+
void this.loadLogs();
|
|
251
|
+
}
|
|
252
|
+
break;
|
|
253
|
+
case "r":
|
|
254
|
+
this.status = "Refreshing…";
|
|
255
|
+
this.freezeOverride = true;
|
|
256
|
+
void this.refresh();
|
|
257
|
+
break;
|
|
258
|
+
case "?":
|
|
259
|
+
this.message = this.helpText();
|
|
260
|
+
this.messageTitle = "Keyboard shortcuts";
|
|
261
|
+
this.input = "message";
|
|
262
|
+
return this.render();
|
|
263
|
+
case "a":
|
|
264
|
+
case ":":
|
|
265
|
+
if (this.service.aiEnabled) {
|
|
266
|
+
this.input = "ai";
|
|
267
|
+
this.aiInput = "";
|
|
268
|
+
this.render();
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
this.status = color.yellow("AI off — run 'ragedocker ia login' to sign in to Claude or ChatGPT.");
|
|
272
|
+
this.render();
|
|
273
|
+
}
|
|
274
|
+
return;
|
|
275
|
+
case "l":
|
|
276
|
+
this.showLogs = !this.showLogs;
|
|
277
|
+
if (this.showLogs)
|
|
278
|
+
void this.loadLogs();
|
|
279
|
+
else
|
|
280
|
+
this.render();
|
|
281
|
+
break;
|
|
282
|
+
case "u":
|
|
283
|
+
if (this.update?.hasUpdate) {
|
|
284
|
+
this.message = this.changelogText();
|
|
285
|
+
this.messageTitle = "What's new";
|
|
286
|
+
this.input = "message";
|
|
287
|
+
return this.render();
|
|
288
|
+
}
|
|
289
|
+
break;
|
|
290
|
+
case "s":
|
|
291
|
+
void this.action("stop");
|
|
292
|
+
break;
|
|
293
|
+
case "S":
|
|
294
|
+
void this.action("start");
|
|
295
|
+
break;
|
|
296
|
+
case "R":
|
|
297
|
+
void this.action("restart");
|
|
298
|
+
break;
|
|
299
|
+
default:
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
onAiKey(key) {
|
|
304
|
+
if (key === "\x1b") {
|
|
305
|
+
this.input = "normal";
|
|
306
|
+
this.aiInput = "";
|
|
307
|
+
this.render();
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
if (key === "\r" || key === "\n") {
|
|
311
|
+
const q = this.aiInput.trim();
|
|
312
|
+
this.input = "normal";
|
|
313
|
+
this.aiInput = "";
|
|
314
|
+
this.render();
|
|
315
|
+
if (q)
|
|
316
|
+
void this.runAi(q);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (key === "\x7f" || key === "\b")
|
|
320
|
+
this.aiInput = this.aiInput.slice(0, -1);
|
|
321
|
+
else if (key >= " " && key.length === 1)
|
|
322
|
+
this.aiInput += key;
|
|
323
|
+
this.render();
|
|
324
|
+
}
|
|
325
|
+
onConfirmKey(key) {
|
|
326
|
+
const pending = this.pending;
|
|
327
|
+
this.pending = null;
|
|
328
|
+
this.input = "normal";
|
|
329
|
+
if ((key === "y" || key === "Y") && pending)
|
|
330
|
+
void pending.run();
|
|
331
|
+
else {
|
|
332
|
+
this.status = color.gray("Cancelled.");
|
|
333
|
+
this.render();
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
/** Live-editing the container name/image filter. */
|
|
337
|
+
onFilterKey(key) {
|
|
338
|
+
if (key === "\x1b") {
|
|
339
|
+
this.filter = "";
|
|
340
|
+
this.input = "normal";
|
|
341
|
+
this.selected = 0;
|
|
342
|
+
this.render();
|
|
343
|
+
void this.loadDetail();
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (key === "\r" || key === "\n") {
|
|
347
|
+
this.input = "normal";
|
|
348
|
+
this.render();
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if (key === "\x7f" || key === "\b")
|
|
352
|
+
this.filter = this.filter.slice(0, -1);
|
|
353
|
+
else if (key >= " " && key.length === 1)
|
|
354
|
+
this.filter += key;
|
|
355
|
+
this.selected = 0;
|
|
356
|
+
this.render();
|
|
357
|
+
void this.loadDetail();
|
|
358
|
+
}
|
|
359
|
+
/** Typing the "host:container[:ro]" bind spec. */
|
|
360
|
+
onVolumeKey(key) {
|
|
361
|
+
if (key === "\x1b") {
|
|
362
|
+
this.input = "normal";
|
|
363
|
+
this.volumeInput = "";
|
|
364
|
+
this.render();
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
if (key === "\r" || key === "\n") {
|
|
368
|
+
this.submitVolume();
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
if (key === "\x7f" || key === "\b")
|
|
372
|
+
this.volumeInput = this.volumeInput.slice(0, -1);
|
|
373
|
+
else if (key >= " " && key.length === 1)
|
|
374
|
+
this.volumeInput += key;
|
|
375
|
+
this.render();
|
|
376
|
+
}
|
|
377
|
+
/** Cycle the sort column: name → cpu → mem → state → name. */
|
|
378
|
+
cycleSort() {
|
|
379
|
+
const order = ["name", "cpu", "mem", "state"];
|
|
380
|
+
this.sortKey = order[(order.indexOf(this.sortKey) + 1) % order.length];
|
|
381
|
+
// CPU/mem read most naturally biggest-first.
|
|
382
|
+
this.sortDesc = this.sortKey === "cpu" || this.sortKey === "mem";
|
|
383
|
+
this.selected = 0;
|
|
384
|
+
this.status = color.gray(`Sorted by ${this.sortKey}`);
|
|
385
|
+
this.render();
|
|
386
|
+
void this.loadDetail();
|
|
387
|
+
}
|
|
388
|
+
/** Copy the selected container's full ID to the clipboard (OSC 52). */
|
|
389
|
+
copyId() {
|
|
390
|
+
const c = this.current();
|
|
391
|
+
if (!c)
|
|
392
|
+
return;
|
|
393
|
+
const b64 = Buffer.from(c.id).toString("base64");
|
|
394
|
+
this.out.write(`\x1b]52;c;${b64}\x07`);
|
|
395
|
+
this.status = color.green(`✓ Copied ${c.name} id to clipboard`);
|
|
396
|
+
this.render();
|
|
397
|
+
}
|
|
398
|
+
/** Full inspect overlay: command, health, networks, mounts and env. */
|
|
399
|
+
async showInspect() {
|
|
400
|
+
const c = this.current();
|
|
401
|
+
if (!c)
|
|
402
|
+
return;
|
|
403
|
+
this.status = color.gray("Inspecting…");
|
|
404
|
+
this.render();
|
|
405
|
+
try {
|
|
406
|
+
const d = await this.service.inspectSummary(c.name);
|
|
407
|
+
const lines = [
|
|
408
|
+
`${color.gray("Name")} ${color.bold(d.name)}`,
|
|
409
|
+
`${color.gray("Image")} ${d.image}`,
|
|
410
|
+
`${color.gray("Id")} ${color.dim(d.id)}`,
|
|
411
|
+
`${color.gray("Created")} ${d.created}`,
|
|
412
|
+
`${color.gray("State")} ${d.state} ${color.gray("Health")} ${healthTag(d.health, c.status)}`,
|
|
413
|
+
`${color.gray("Restart")} ${d.restartPolicy}`,
|
|
414
|
+
`${color.gray("Command")} ${d.command}`,
|
|
415
|
+
"",
|
|
416
|
+
color.accent("Networks"), ...(d.networks.length ? d.networks.map((n) => " " + n) : [" " + color.gray("none")]),
|
|
417
|
+
"",
|
|
418
|
+
color.accent("Ports"), ...(d.ports.length ? d.ports.map((p) => " " + p) : [" " + color.gray("none")]),
|
|
419
|
+
"",
|
|
420
|
+
color.accent("Mounts"), ...(d.mounts.length ? d.mounts.map((m) => " " + m) : [" " + color.gray("none")]),
|
|
421
|
+
"",
|
|
422
|
+
color.accent(`Environment (${d.env.length})`), ...(d.env.length ? d.env.map((e) => " " + color.dim(e)) : [" " + color.gray("none")]),
|
|
423
|
+
];
|
|
424
|
+
this.showMessage(lines.join("\n"), `Inspect · ${c.name}`);
|
|
425
|
+
}
|
|
426
|
+
catch (err) {
|
|
427
|
+
this.showMessage(`Inspect failed: ${err.message}`);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
/** Networks overlay. */
|
|
431
|
+
async showNetworks() {
|
|
432
|
+
this.status = color.gray("Loading networks…");
|
|
433
|
+
this.render();
|
|
434
|
+
try {
|
|
435
|
+
const nets = await this.service.networks();
|
|
436
|
+
const body = nets.length
|
|
437
|
+
? nets.map((n) => `${color.bold(padEnd(n.name, 22))} ${color.gray(padEnd(n.driver, 10))} ${color.dim(n.scope)} ${color.dim(n.id.slice(0, 12))}`).join("\n")
|
|
438
|
+
: color.gray("No networks.");
|
|
439
|
+
this.showMessage(body, `Networks (${nets.length})`);
|
|
440
|
+
}
|
|
441
|
+
catch (err) {
|
|
442
|
+
this.showMessage(`Could not list networks: ${err.message}`);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
/** Volumes overlay. */
|
|
446
|
+
async showVolumes() {
|
|
447
|
+
this.status = color.gray("Loading volumes…");
|
|
448
|
+
this.render();
|
|
449
|
+
try {
|
|
450
|
+
const vols = await this.service.volumes();
|
|
451
|
+
const body = vols.length
|
|
452
|
+
? vols.map((v) => `${color.bold(padEnd(v.name, 26))} ${color.gray(padEnd(v.driver, 8))} ${color.dim(v.mountpoint)}`).join("\n")
|
|
453
|
+
: color.gray("No volumes.");
|
|
454
|
+
this.showMessage(body, `Volumes (${vols.length})`);
|
|
455
|
+
}
|
|
456
|
+
catch (err) {
|
|
457
|
+
this.showMessage(`Could not list volumes: ${err.message}`);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
/** Current alerts overlay. */
|
|
461
|
+
async showAlerts() {
|
|
462
|
+
this.status = color.gray("Checking alerts…");
|
|
463
|
+
this.render();
|
|
464
|
+
try {
|
|
465
|
+
const alerts = await this.service.alerts();
|
|
466
|
+
if (!alerts.length)
|
|
467
|
+
return this.showMessage(color.green("✓ No active alerts. Everything looks healthy."), "Alerts");
|
|
468
|
+
const glyph = (l) => l === "critical" ? color.red("●") : l === "warning" ? color.yellow("●") : color.brightBlue("●");
|
|
469
|
+
const body = alerts.map((a) => `${glyph(a.level)} ${color.bold(padEnd(a.source, 16))} ${a.message}`).join("\n");
|
|
470
|
+
this.showMessage(body, `Alerts (${alerts.length})`);
|
|
471
|
+
}
|
|
472
|
+
catch (err) {
|
|
473
|
+
this.showMessage(`Could not load alerts: ${err.message}`);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
/** Confirm then snapshot the selected container (image commit). */
|
|
477
|
+
confirmSnapshot() {
|
|
478
|
+
const c = this.current();
|
|
479
|
+
if (!c)
|
|
480
|
+
return;
|
|
481
|
+
if (this.service.isReadOnly) {
|
|
482
|
+
this.status = color.yellow("Read-only mode — snapshots are disabled.");
|
|
483
|
+
this.render();
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
this.pending = {
|
|
487
|
+
label: `Snapshot ${c.name} (docker commit)`,
|
|
488
|
+
run: async () => {
|
|
489
|
+
this.status = color.gray(`Snapshotting ${c.name}…`);
|
|
490
|
+
this.render();
|
|
491
|
+
try {
|
|
492
|
+
const snap = await this.service.createSnapshot(c.name, "commit");
|
|
493
|
+
this.showMessage(`✓ Snapshot created\n\n${color.gray("ref")} ${snap.ref}\n${color.gray("size")} ${bytes(snap.sizeBytes)}`, "Snapshot");
|
|
494
|
+
}
|
|
495
|
+
catch (err) {
|
|
496
|
+
this.showMessage(`Snapshot failed: ${err.message}`);
|
|
497
|
+
}
|
|
498
|
+
},
|
|
499
|
+
};
|
|
500
|
+
this.input = "confirm";
|
|
501
|
+
this.render();
|
|
502
|
+
}
|
|
503
|
+
/** Open an interactive shell inside the selected container (docker exec -it). */
|
|
504
|
+
async openShell() {
|
|
505
|
+
const c = this.current();
|
|
506
|
+
if (!c)
|
|
507
|
+
return;
|
|
508
|
+
if (this.service.isDemo) {
|
|
509
|
+
this.showMessage("Interactive shells aren't available in demo mode.\nRun against a real Docker host to drop into a container.", "Shell");
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
if (c.state !== "running") {
|
|
513
|
+
this.status = color.yellow(`${c.name} is not running.`);
|
|
514
|
+
this.render();
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
// Prefer bash, fall back to sh — decided inside the container.
|
|
518
|
+
await this.runInteractive("docker", ["exec", "-it", c.name, "sh", "-lc", "command -v bash >/dev/null 2>&1 && exec bash || exec sh"], `${color.accent("❯")} shell in ${color.bold(c.name)} ${color.dim("(type 'exit' to return)")}`);
|
|
519
|
+
}
|
|
520
|
+
/** Edit a compose file in the user's $EDITOR (suspends the dashboard). */
|
|
521
|
+
async editCompose() {
|
|
522
|
+
if (this.service.isDemo) {
|
|
523
|
+
this.showMessage("Compose editing isn't available in demo mode.\nRun against a real host with a docker-compose.yml nearby.", "Compose");
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
const files = this.service.findComposeFiles();
|
|
527
|
+
if (files.length === 0) {
|
|
528
|
+
this.showMessage("No compose files found near the working directory.\nStart the panel/TUI from a folder with a docker-compose.yml.", "Compose");
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
const editor = process.env.EDITOR || process.env.VISUAL || (process.platform === "win32" ? "notepad" : "nano");
|
|
532
|
+
const file = files[0];
|
|
533
|
+
await this.runInteractive(editor, [file], `${color.accent("✎")} editing ${color.bold(file)} ${color.dim("with " + editor)}`);
|
|
534
|
+
}
|
|
535
|
+
/** Open the per-container action menu over the current selection. */
|
|
536
|
+
openMenu() {
|
|
537
|
+
const c = this.current();
|
|
538
|
+
if (!c)
|
|
539
|
+
return;
|
|
540
|
+
const ro = this.service.isReadOnly;
|
|
541
|
+
const running = c.state === "running";
|
|
542
|
+
const items = [
|
|
543
|
+
{ label: "View logs", key: "l", run: () => { this.showLogs = true; void this.loadLogs(); } },
|
|
544
|
+
{ label: "Full inspect", key: "i", run: () => void this.showInspect() },
|
|
545
|
+
{ label: "Open shell (exec)", key: "e", run: () => void this.openShell() },
|
|
546
|
+
{ label: "Copy id", key: "y", run: () => this.copyId() },
|
|
547
|
+
];
|
|
548
|
+
if (!ro) {
|
|
549
|
+
if (!running)
|
|
550
|
+
items.push({ label: "Start", key: "S", run: () => void this.action("start") });
|
|
551
|
+
if (running)
|
|
552
|
+
items.push({ label: "Stop", key: "s", run: () => void this.action("stop") });
|
|
553
|
+
if (running)
|
|
554
|
+
items.push({ label: "Restart", key: "R", run: () => void this.action("restart") });
|
|
555
|
+
items.push({ label: "Snapshot (commit)", key: "b", run: () => this.confirmSnapshot() });
|
|
556
|
+
items.push({ label: "Restore a snapshot…", key: "B", run: () => this.openSnapshots() });
|
|
557
|
+
items.push({ label: "Attach a volume…", key: "V", run: () => this.startVolume() });
|
|
558
|
+
items.push({ label: "Edit docker-compose…", key: "c", run: () => void this.editCompose() });
|
|
559
|
+
}
|
|
560
|
+
items.push({ label: "Networks", key: "n", run: () => void this.showNetworks() });
|
|
561
|
+
items.push({ label: "Volumes", key: "v", run: () => void this.showVolumes() });
|
|
562
|
+
items.push({ label: "Alerts", key: "A", run: () => void this.showAlerts() });
|
|
563
|
+
this.menuItems = items;
|
|
564
|
+
this.menuIndex = 0;
|
|
565
|
+
this.input = "menu";
|
|
566
|
+
this.render();
|
|
567
|
+
}
|
|
568
|
+
onMenuKey(key) {
|
|
569
|
+
const n = this.menuItems.length;
|
|
570
|
+
if (n === 0 || key === "\x1b" || key === "q") {
|
|
571
|
+
this.input = "normal";
|
|
572
|
+
this.render();
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
if (key === "\x1b[A" || key === "k") {
|
|
576
|
+
this.menuIndex = (this.menuIndex - 1 + n) % n;
|
|
577
|
+
return this.render();
|
|
578
|
+
}
|
|
579
|
+
if (key === "\x1b[B" || key === "j") {
|
|
580
|
+
this.menuIndex = (this.menuIndex + 1) % n;
|
|
581
|
+
return this.render();
|
|
582
|
+
}
|
|
583
|
+
if (key === "\r" || key === "\n") {
|
|
584
|
+
const it = this.menuItems[this.menuIndex];
|
|
585
|
+
this.input = "normal";
|
|
586
|
+
it.run();
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
const hit = this.menuItems.find((i) => i.key === key);
|
|
590
|
+
if (hit) {
|
|
591
|
+
this.input = "normal";
|
|
592
|
+
hit.run();
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
/** The action-menu body, with the highlighted row and shortcut hints. */
|
|
596
|
+
menuText() {
|
|
597
|
+
const width = Math.max(...this.menuItems.map((i) => (i.label + (i.key ? " [" + i.key + "]" : "")).length));
|
|
598
|
+
return this.menuItems.map((it, i) => {
|
|
599
|
+
const raw = padEnd(it.label + (it.key ? " [" + it.key + "]" : ""), width);
|
|
600
|
+
return i === this.menuIndex ? color.bgAccent(" " + raw + " ") : " " + raw;
|
|
601
|
+
}).join("\n");
|
|
602
|
+
}
|
|
603
|
+
/** Snapshot picker → restore (numbered overlay). */
|
|
604
|
+
openSnapshots() {
|
|
605
|
+
const snaps = this.service.listSnapshots();
|
|
606
|
+
if (snaps.length === 0) {
|
|
607
|
+
this.showMessage(`No snapshots yet. Press ${color.accent("b")} to create one from the selected container.`, "Snapshots");
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
this.input = "snapshots";
|
|
611
|
+
this.render();
|
|
612
|
+
}
|
|
613
|
+
/** Attach an extra volume via a one-line "host:container[:ro]" prompt. */
|
|
614
|
+
startVolume() {
|
|
615
|
+
const c = this.current();
|
|
616
|
+
if (!c)
|
|
617
|
+
return;
|
|
618
|
+
if (this.service.isReadOnly) {
|
|
619
|
+
this.status = color.yellow("Read-only mode — attaching volumes is disabled.");
|
|
620
|
+
this.render();
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
this.volumeInput = "";
|
|
624
|
+
this.input = "volume";
|
|
625
|
+
this.render();
|
|
626
|
+
}
|
|
627
|
+
submitVolume() {
|
|
628
|
+
const c = this.current();
|
|
629
|
+
const spec = this.volumeInput.trim();
|
|
630
|
+
this.input = "normal";
|
|
631
|
+
this.volumeInput = "";
|
|
632
|
+
if (!c || !spec) {
|
|
633
|
+
this.render();
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
const m = /^([^:]+):([^:]+)(:ro)?$/.exec(spec);
|
|
637
|
+
if (!m) {
|
|
638
|
+
this.status = color.red('Format: host:container (add :ro for read-only)');
|
|
639
|
+
this.render();
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
const [, hostPath, containerPath, ro] = m;
|
|
643
|
+
this.pending = {
|
|
644
|
+
label: `Recreate ${c.name} with ${hostPath}:${containerPath}${ro ? " (ro)" : ""}`,
|
|
645
|
+
run: async () => {
|
|
646
|
+
this.status = color.gray(`Recreating ${c.name}…`);
|
|
647
|
+
this.render();
|
|
648
|
+
try {
|
|
649
|
+
const r = await this.service.attachVolume(c.name, hostPath, containerPath, Boolean(ro));
|
|
650
|
+
this.showMessage(`✓ ${r.message}`, "Volume attached");
|
|
651
|
+
await this.refresh();
|
|
652
|
+
}
|
|
653
|
+
catch (err) {
|
|
654
|
+
this.showMessage(`Attach failed: ${err.message}`);
|
|
655
|
+
}
|
|
656
|
+
},
|
|
657
|
+
};
|
|
658
|
+
this.input = "confirm";
|
|
659
|
+
this.render();
|
|
660
|
+
}
|
|
661
|
+
/** Handle key presses while the snapshot picker is open. */
|
|
662
|
+
onSnapshotsKey(key) {
|
|
663
|
+
if (key === "\x1b" || key === "q") {
|
|
664
|
+
this.input = "normal";
|
|
665
|
+
this.render();
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
const snaps = this.service.listSnapshots().slice(0, 9);
|
|
669
|
+
const idx = Number(key) - 1;
|
|
670
|
+
if (Number.isInteger(idx) && idx >= 0 && idx < snaps.length) {
|
|
671
|
+
const snap = snaps[idx];
|
|
672
|
+
this.input = "normal";
|
|
673
|
+
this.pending = {
|
|
674
|
+
label: `Restore ${snap.ref} (${snap.type})`,
|
|
675
|
+
run: async () => {
|
|
676
|
+
this.status = color.gray("Restoring…");
|
|
677
|
+
this.render();
|
|
678
|
+
try {
|
|
679
|
+
const r = await this.service.restoreSnapshot(snap.id);
|
|
680
|
+
this.showMessage(`✓ ${r.message}`, "Restore");
|
|
681
|
+
await this.refresh();
|
|
682
|
+
}
|
|
683
|
+
catch (err) {
|
|
684
|
+
this.showMessage(`Restore failed: ${err.message}`);
|
|
685
|
+
}
|
|
686
|
+
},
|
|
687
|
+
};
|
|
688
|
+
this.input = "confirm";
|
|
689
|
+
this.render();
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
/** Ask the AI copilot; propose the docker command and confirm before running. */
|
|
693
|
+
async runAi(prompt) {
|
|
694
|
+
this.status = color.gray("AI thinking…");
|
|
695
|
+
this.render();
|
|
696
|
+
try {
|
|
697
|
+
const r = await this.service.aiAssist("command", prompt);
|
|
698
|
+
if (r.command) {
|
|
699
|
+
const label = `AI: ${r.command}${r.explanation ? " — " + r.explanation : ""}`;
|
|
700
|
+
this.pending = { label, run: () => this.runAiCommand(r.command) };
|
|
701
|
+
this.input = "confirm";
|
|
702
|
+
this.status = "";
|
|
703
|
+
this.render();
|
|
704
|
+
}
|
|
705
|
+
else {
|
|
706
|
+
this.showMessage(r.text || "AI had no suggestion.");
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
catch (err) {
|
|
710
|
+
this.showMessage(`AI error: ${err.message}`);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
async runAiCommand(command) {
|
|
714
|
+
this.status = color.gray(`Running: ${command}`);
|
|
715
|
+
this.render();
|
|
716
|
+
try {
|
|
717
|
+
const res = await this.service.runCommand(command);
|
|
718
|
+
this.showMessage(`❯ ${command}\n\n${res.output}`, `Command output (exit ${res.code})`);
|
|
719
|
+
await this.refresh();
|
|
720
|
+
}
|
|
721
|
+
catch (err) {
|
|
722
|
+
this.showMessage(`Error: ${err.message}`);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
showMessage(text, title = "AI copilot") {
|
|
726
|
+
this.message = text;
|
|
727
|
+
this.messageTitle = title;
|
|
728
|
+
this.input = "message";
|
|
729
|
+
this.status = "";
|
|
730
|
+
this.render();
|
|
731
|
+
}
|
|
732
|
+
helpText() {
|
|
733
|
+
return [
|
|
734
|
+
` ${color.accent("Enter")} or ${color.accent("m")} open the action menu over the selected container`,
|
|
735
|
+
color.gray("Navigation"),
|
|
736
|
+
" ↑/↓ j/k move selection g/G top / bottom",
|
|
737
|
+
" / filter by name/image o cycle sort O reverse",
|
|
738
|
+
" space pause / resume live updates",
|
|
739
|
+
"",
|
|
740
|
+
color.gray("Inspect"),
|
|
741
|
+
" l logs i full inspect y copy id to clipboard",
|
|
742
|
+
" n networks v volumes A alerts",
|
|
743
|
+
" In logs: f follow · +/- change tail length",
|
|
744
|
+
"",
|
|
745
|
+
color.gray("Actions"),
|
|
746
|
+
" S start s stop R restart",
|
|
747
|
+
" b snapshot B restore a snapshot V attach a volume",
|
|
748
|
+
" e shell into container c edit docker-compose ($EDITOR)",
|
|
749
|
+
"",
|
|
750
|
+
color.gray("AI copilot"),
|
|
751
|
+
` ${color.accent("a")} or ${color.accent(":")} give an order in plain language, e.g.:`,
|
|
752
|
+
` ${color.brightCyan('"restart web"')} ${color.brightCyan('"why did api crash"')} ${color.brightCyan('"show running containers"')}`,
|
|
753
|
+
" The AI proposes a docker command; confirm with y before it runs.",
|
|
754
|
+
"",
|
|
755
|
+
color.gray("General"),
|
|
756
|
+
" r refresh u what's new ? this help q quit",
|
|
757
|
+
].join("\n");
|
|
758
|
+
}
|
|
759
|
+
/** Containers after the active filter and sort — what the list actually shows. */
|
|
760
|
+
visible() {
|
|
761
|
+
const f = this.filter.trim().toLowerCase();
|
|
762
|
+
const list = f
|
|
763
|
+
? this.containers.filter((c) => c.name.toLowerCase().includes(f) || c.image.toLowerCase().includes(f))
|
|
764
|
+
: this.containers.slice();
|
|
765
|
+
const dir = this.sortDesc ? -1 : 1;
|
|
766
|
+
list.sort((a, b) => {
|
|
767
|
+
switch (this.sortKey) {
|
|
768
|
+
case "cpu": return dir * ((a.cpu ?? 0) - (b.cpu ?? 0));
|
|
769
|
+
case "mem": return dir * ((a.memory ?? 0) - (b.memory ?? 0));
|
|
770
|
+
case "state": return dir * a.state.localeCompare(b.state);
|
|
771
|
+
default: return dir * a.name.localeCompare(b.name);
|
|
772
|
+
}
|
|
773
|
+
});
|
|
774
|
+
return list;
|
|
775
|
+
}
|
|
776
|
+
move(delta) {
|
|
777
|
+
const list = this.visible();
|
|
778
|
+
if (list.length === 0)
|
|
779
|
+
return;
|
|
780
|
+
this.selected = (this.selected + delta + list.length) % list.length;
|
|
781
|
+
this.showLogs = false;
|
|
782
|
+
this.render();
|
|
783
|
+
void this.loadDetail();
|
|
784
|
+
}
|
|
785
|
+
/** Jump to the first or last row. */
|
|
786
|
+
jump(to) {
|
|
787
|
+
const list = this.visible();
|
|
788
|
+
if (list.length === 0)
|
|
789
|
+
return;
|
|
790
|
+
this.selected = to === "top" ? 0 : list.length - 1;
|
|
791
|
+
this.showLogs = false;
|
|
792
|
+
this.render();
|
|
793
|
+
void this.loadDetail();
|
|
794
|
+
}
|
|
795
|
+
/** Fetch the inspect summary for the selected container, once, lazily. */
|
|
796
|
+
async loadDetail() {
|
|
797
|
+
const c = this.current();
|
|
798
|
+
if (!c) {
|
|
799
|
+
this.detail = null;
|
|
800
|
+
this.detailFor = "";
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
if (this.detailFor === c.id)
|
|
804
|
+
return;
|
|
805
|
+
this.detailFor = c.id;
|
|
806
|
+
this.detail = null;
|
|
807
|
+
try {
|
|
808
|
+
const d = await this.service.inspectSummary(c.name);
|
|
809
|
+
if (this.current()?.id === c.id) {
|
|
810
|
+
this.detail = d;
|
|
811
|
+
if (!this.showLogs && this.mode === "main")
|
|
812
|
+
this.render();
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
catch { /* details are a bonus, never fatal */ }
|
|
816
|
+
}
|
|
817
|
+
current() {
|
|
818
|
+
return this.visible()[this.selected];
|
|
819
|
+
}
|
|
820
|
+
// ---- Lifecycle ----------------------------------------------------------
|
|
821
|
+
async enterMain() {
|
|
822
|
+
this.mode = "main";
|
|
823
|
+
await this.refresh();
|
|
824
|
+
void this.loadDetail();
|
|
825
|
+
this.timer = setInterval(() => void this.refresh(), 3000);
|
|
826
|
+
// A gentle frame ticker so the state glyphs breathe / spin / blink.
|
|
827
|
+
this.ticker = setInterval(() => {
|
|
828
|
+
this.tick++;
|
|
829
|
+
if (this.mode === "main")
|
|
830
|
+
this.render();
|
|
831
|
+
}, 220);
|
|
832
|
+
if (typeof this.ticker.unref === "function")
|
|
833
|
+
this.ticker.unref();
|
|
834
|
+
void this.checkUpdate();
|
|
835
|
+
}
|
|
836
|
+
/** Fetch the update channel once in the background; silent on any failure. */
|
|
837
|
+
async checkUpdate() {
|
|
838
|
+
try {
|
|
839
|
+
const status = await checkForUpdate();
|
|
840
|
+
if (status?.hasUpdate) {
|
|
841
|
+
this.update = status;
|
|
842
|
+
if (this.mode === "main" && this.input === "normal")
|
|
843
|
+
this.render();
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
catch { /* an update notice is a nicety, never an error */ }
|
|
847
|
+
}
|
|
848
|
+
async action(kind) {
|
|
849
|
+
const c = this.current();
|
|
850
|
+
if (!c)
|
|
851
|
+
return;
|
|
852
|
+
if (this.service.isReadOnly) {
|
|
853
|
+
this.status = color.yellow("Read-only mode — actions are disabled.");
|
|
854
|
+
this.render();
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
// Animate the row while the daemon works — a pulsing spinner, held for at
|
|
858
|
+
// least a beat so even an instant (demo) action reads as motion.
|
|
859
|
+
this.startAnim(c.name, kind);
|
|
860
|
+
const floor = new Promise((r) => setTimeout(r, 850));
|
|
861
|
+
try {
|
|
862
|
+
await Promise.all([this.service.act(c.name, kind), floor]);
|
|
863
|
+
this.stopAnim();
|
|
864
|
+
this.status = color.green(`✓ ${verb(kind)} ${c.name}`);
|
|
865
|
+
await this.refresh();
|
|
866
|
+
}
|
|
867
|
+
catch (err) {
|
|
868
|
+
this.stopAnim();
|
|
869
|
+
this.status = color.red(`✗ ${err.message}`);
|
|
870
|
+
this.render();
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
/** Begin the lifecycle spinner and drive its frames. */
|
|
874
|
+
startAnim(name, kind) {
|
|
875
|
+
this.anim = { name, kind, frame: 0 };
|
|
876
|
+
if (this.animTimer)
|
|
877
|
+
clearInterval(this.animTimer);
|
|
878
|
+
this.animTimer = setInterval(() => {
|
|
879
|
+
if (this.anim) {
|
|
880
|
+
this.anim.frame++;
|
|
881
|
+
this.render();
|
|
882
|
+
}
|
|
883
|
+
}, 80);
|
|
884
|
+
this.render();
|
|
885
|
+
}
|
|
886
|
+
stopAnim() {
|
|
887
|
+
if (this.animTimer) {
|
|
888
|
+
clearInterval(this.animTimer);
|
|
889
|
+
this.animTimer = null;
|
|
890
|
+
}
|
|
891
|
+
this.anim = null;
|
|
892
|
+
}
|
|
893
|
+
async loadLogs() {
|
|
894
|
+
const c = this.current();
|
|
895
|
+
if (!c)
|
|
896
|
+
return;
|
|
897
|
+
this.logs = "Loading…";
|
|
898
|
+
this.render();
|
|
899
|
+
try {
|
|
900
|
+
this.logs = await this.service.logs(c.name, this.logTail);
|
|
901
|
+
}
|
|
902
|
+
catch (err) {
|
|
903
|
+
this.logs = `Error: ${err.message}`;
|
|
904
|
+
}
|
|
905
|
+
this.render();
|
|
906
|
+
}
|
|
907
|
+
async refresh() {
|
|
908
|
+
if (this.refreshing)
|
|
909
|
+
return;
|
|
910
|
+
// A frozen view still refreshes on an explicit [r]; the timer path skips it.
|
|
911
|
+
if (this.frozen && !this.freezeOverride)
|
|
912
|
+
return;
|
|
913
|
+
this.freezeOverride = false;
|
|
914
|
+
this.refreshing = true;
|
|
915
|
+
try {
|
|
916
|
+
const snap = await this.service.snapshot();
|
|
917
|
+
this.system = snap.system;
|
|
918
|
+
this.containers = snap.containers;
|
|
919
|
+
if (this.selected >= this.visible().length)
|
|
920
|
+
this.selected = 0;
|
|
921
|
+
if (this.showLogs)
|
|
922
|
+
await this.loadLogs();
|
|
923
|
+
else
|
|
924
|
+
this.render();
|
|
925
|
+
}
|
|
926
|
+
catch (err) {
|
|
927
|
+
this.status = color.red(`Error: ${err.message}`);
|
|
928
|
+
this.render();
|
|
929
|
+
}
|
|
930
|
+
finally {
|
|
931
|
+
this.refreshing = false;
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
quit() {
|
|
935
|
+
if (this.timer)
|
|
936
|
+
clearInterval(this.timer);
|
|
937
|
+
if (this.animTimer)
|
|
938
|
+
clearInterval(this.animTimer);
|
|
939
|
+
if (this.ticker)
|
|
940
|
+
clearInterval(this.ticker);
|
|
941
|
+
this.out.write(ctl.showCursor + ctl.exitAlt);
|
|
942
|
+
this.out.write(`\n Thanks for using ${color.accent(BRAND.product)} by ${color.bold(BRAND.author)} — ${color.brightBlue(BRAND.url)}\n` +
|
|
943
|
+
` ${color.yellow("★")} If it helped you, please leave a star. See you soon!\n\n`);
|
|
944
|
+
process.exit(0);
|
|
945
|
+
}
|
|
946
|
+
// ---- Rendering ----------------------------------------------------------
|
|
947
|
+
cols() {
|
|
948
|
+
return this.out.columns && this.out.columns > 20 ? this.out.columns : 100;
|
|
949
|
+
}
|
|
950
|
+
rows() {
|
|
951
|
+
return this.out.rows && this.out.rows > 10 ? this.out.rows : 30;
|
|
952
|
+
}
|
|
953
|
+
/** Paint the whole frame from a line array. */
|
|
954
|
+
paint(lines) {
|
|
955
|
+
const cols = this.cols();
|
|
956
|
+
let frame = ctl.home;
|
|
957
|
+
for (let i = 0; i < lines.length; i++) {
|
|
958
|
+
frame += truncate(lines[i], cols) + ctl.clearLine;
|
|
959
|
+
if (i < lines.length - 1)
|
|
960
|
+
frame += "\n";
|
|
961
|
+
}
|
|
962
|
+
frame += ctl.clearBelow;
|
|
963
|
+
this.out.write(frame);
|
|
964
|
+
}
|
|
965
|
+
/** Build the welcome-splash lines (also used by `--splash` snapshot mode). */
|
|
966
|
+
splashLines(cols = this.cols(), rows = this.rows()) {
|
|
967
|
+
const banner = ASCII_BANNER.split("\n");
|
|
968
|
+
const block = [
|
|
969
|
+
...banner.map((l) => color.accent(l)),
|
|
970
|
+
"",
|
|
971
|
+
center(color.bold(`Welcome to ${BRAND.product}`), cols),
|
|
972
|
+
center(color.gray(`by ${BRAND.author} · ${BRAND.url}`), cols),
|
|
973
|
+
"",
|
|
974
|
+
center("Thank you for using our repository.", cols),
|
|
975
|
+
center(`${color.yellow("★")} If it's useful, please leave a ${color.yellow("star")} and share it.`, cols),
|
|
976
|
+
center(`${color.gray("Support the project:")} ${color.brightBlue(BRAND.donate)}`, cols),
|
|
977
|
+
"",
|
|
978
|
+
center(color.dim("Press any key to launch the dashboard…"), cols),
|
|
979
|
+
];
|
|
980
|
+
const top = Math.max(1, Math.floor((rows - block.length) / 2));
|
|
981
|
+
return [...Array(top).fill(""), ...block.map((l) => center(l, cols))];
|
|
982
|
+
}
|
|
983
|
+
renderSplash() {
|
|
984
|
+
this.paint(this.splashLines());
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* Render one static main frame to a string (no cursor control). Used by the
|
|
988
|
+
* `--frame` snapshot mode to document the TUI. Refreshes data first.
|
|
989
|
+
*/
|
|
990
|
+
async frame(cols = 100, rows = 30, demoInput) {
|
|
991
|
+
this.mode = "main";
|
|
992
|
+
const snap = await this.service.snapshot();
|
|
993
|
+
this.system = snap.system;
|
|
994
|
+
this.containers = snap.containers;
|
|
995
|
+
await this.loadDetail();
|
|
996
|
+
if (demoInput === "ai") {
|
|
997
|
+
this.input = "ai";
|
|
998
|
+
this.aiInput = "restart the web container";
|
|
999
|
+
}
|
|
1000
|
+
else if (demoInput === "message") {
|
|
1001
|
+
this.input = "message";
|
|
1002
|
+
this.messageTitle = "Command output (exit 0)";
|
|
1003
|
+
this.message = "❯ docker restart web-prod\n\nweb-prod\n\nContainer restarted in 1.4s — health check passing.";
|
|
1004
|
+
}
|
|
1005
|
+
return this.buildMainLines(cols, rows).join("\n");
|
|
1006
|
+
}
|
|
1007
|
+
render() {
|
|
1008
|
+
if (this.mode !== "main")
|
|
1009
|
+
return;
|
|
1010
|
+
this.paint(this.buildMainLines(this.cols(), this.rows()));
|
|
1011
|
+
}
|
|
1012
|
+
buildMainLines(cols, rows) {
|
|
1013
|
+
const lines = [];
|
|
1014
|
+
lines.push(this.headerLine(cols));
|
|
1015
|
+
lines.push(this.resourceLine());
|
|
1016
|
+
const banner = this.updateBanner(cols);
|
|
1017
|
+
if (banner)
|
|
1018
|
+
lines.push(banner);
|
|
1019
|
+
const bodyHeight = Math.max(6, rows - 5 - (banner ? 1 : 0));
|
|
1020
|
+
const leftW = Math.max(34, Math.floor(cols * 0.42));
|
|
1021
|
+
const rightW = cols - leftW - 1;
|
|
1022
|
+
const shown = this.visible().length;
|
|
1023
|
+
const total = this.containers.length;
|
|
1024
|
+
const countLabel = shown === total ? `${total}` : `${shown}/${total}`;
|
|
1025
|
+
const sortLabel = color.dim(` ↕${this.sortKey}${this.sortDesc ? "↓" : "↑"}`);
|
|
1026
|
+
const left = drawBox(`Containers (${countLabel})${this.filter ? color.dim(" /" + this.filter) : ""}${sortLabel}`, this.containerRows(leftW - 4, bodyHeight - 2), leftW, bodyHeight);
|
|
1027
|
+
const right = drawBox(this.showLogs
|
|
1028
|
+
? `Logs · ${this.current()?.name ?? ""} ${color.dim(`(tail ${this.logTail}${this.logFollow ? " · following" : ""})`)}`
|
|
1029
|
+
: "Details", this.showLogs
|
|
1030
|
+
? this.logRows(rightW - 4, bodyHeight - 2)
|
|
1031
|
+
: this.detailRows(rightW - 4, bodyHeight - 2), rightW, bodyHeight);
|
|
1032
|
+
for (let i = 0; i < bodyHeight; i++) {
|
|
1033
|
+
lines.push(`${left[i] ?? ""} ${right[i] ?? ""}`);
|
|
1034
|
+
}
|
|
1035
|
+
lines.push(this.footerKeys(cols));
|
|
1036
|
+
lines.push(this.footerBrand(cols));
|
|
1037
|
+
if (this.input === "message" && this.message)
|
|
1038
|
+
this.overlay(lines, this.messageTitle || "Info", this.message, cols, rows);
|
|
1039
|
+
if (this.input === "snapshots")
|
|
1040
|
+
this.overlay(lines, "Restore a snapshot", this.snapshotsText(), cols, rows);
|
|
1041
|
+
if (this.input === "menu")
|
|
1042
|
+
this.overlay(lines, `☰ ${this.current()?.name ?? "Actions"}`, this.menuText(), cols, rows);
|
|
1043
|
+
return lines;
|
|
1044
|
+
}
|
|
1045
|
+
headerLine(cols) {
|
|
1046
|
+
const brand = `${color.accent(color.bold("SOYRAGE"))} ${color.gray("▸")} ${color.bold("Docker TUI")}`;
|
|
1047
|
+
const badges = [];
|
|
1048
|
+
if (this.service.isDemo)
|
|
1049
|
+
badges.push(color.yellow(" DEMO "));
|
|
1050
|
+
if (this.service.isReadOnly)
|
|
1051
|
+
badges.push(color.brightBlue(" READ-ONLY "));
|
|
1052
|
+
const sys = this.system;
|
|
1053
|
+
const right = sys
|
|
1054
|
+
? `${color.gray(sys.os)} ${color.dim("·")} Docker ${sys.engine}`
|
|
1055
|
+
: "";
|
|
1056
|
+
const rightAll = `${badges.join(" ")} ${right}`;
|
|
1057
|
+
const gap = Math.max(1, cols - 1 - (7 + 3 + 10) - stripLen(rightAll));
|
|
1058
|
+
return ` ${brand}${" ".repeat(gap)}${rightAll}`;
|
|
1059
|
+
}
|
|
1060
|
+
resourceLine() {
|
|
1061
|
+
const sys = this.system;
|
|
1062
|
+
if (!sys)
|
|
1063
|
+
return "";
|
|
1064
|
+
const running = this.containers.filter((c) => c.state === "running");
|
|
1065
|
+
const cpu = running.reduce((s, c) => s + (c.cpu ?? 0), 0);
|
|
1066
|
+
const cpuPct = sys.cpus ? Math.min(100, cpu / sys.cpus) : cpu;
|
|
1067
|
+
const mem = running.reduce((s, c) => s + (c.memory ?? 0), 0);
|
|
1068
|
+
const memPct = sys.memoryBytes ? (mem / sys.memoryBytes) * 100 : 0;
|
|
1069
|
+
const seg = [
|
|
1070
|
+
`${color.gray("CPU")} ${bar(cpuPct, 14)} ${padStart(cpuPct.toFixed(1) + "%", 6)}`,
|
|
1071
|
+
`${color.gray("MEM")} ${bar(memPct, 14)} ${padStart(bytes(mem), 7)}/${bytes(sys.memoryBytes)}`,
|
|
1072
|
+
`${color.gray("Running")} ${sys.containersRunning}/${sys.containersTotal}`,
|
|
1073
|
+
`${color.gray("Images")} ${sys.images}`,
|
|
1074
|
+
];
|
|
1075
|
+
return " " + seg.join(color.dim(" "));
|
|
1076
|
+
}
|
|
1077
|
+
containerRows(width, height) {
|
|
1078
|
+
const rows = [];
|
|
1079
|
+
const animating = this.anim;
|
|
1080
|
+
const list = this.visible();
|
|
1081
|
+
for (let i = 0; i < list.length && i < height; i++) {
|
|
1082
|
+
const c = list[i];
|
|
1083
|
+
const isAnim = animating && animating.name === c.name;
|
|
1084
|
+
const dot = isAnim
|
|
1085
|
+
? color.accent(SPINNER[animating.frame % SPINNER.length])
|
|
1086
|
+
: animatedGlyph(c, this.tick);
|
|
1087
|
+
const cpu = c.state === "running" ? `${(c.cpu ?? 0).toFixed(1)}%` : "—";
|
|
1088
|
+
const mem = c.state === "running" ? bytes(c.memory) : "—";
|
|
1089
|
+
const label = isAnim ? `${c.name} ${color.dim(gerund(animating.kind))}` : c.name;
|
|
1090
|
+
const name = padEnd(label, Math.max(8, width - 20));
|
|
1091
|
+
let line = `${dot} ${name} ${padStart(cpu, 6)} ${padStart(mem, 6)}`;
|
|
1092
|
+
if (i === this.selected)
|
|
1093
|
+
line = color.bgAccent(padEnd(line, width));
|
|
1094
|
+
rows.push(line);
|
|
1095
|
+
}
|
|
1096
|
+
if (rows.length === 0)
|
|
1097
|
+
rows.push(color.gray(this.filter ? `No matches for "${this.filter}".` : "No containers."));
|
|
1098
|
+
return rows;
|
|
1099
|
+
}
|
|
1100
|
+
detailRows(width, height) {
|
|
1101
|
+
const c = this.current();
|
|
1102
|
+
if (!c)
|
|
1103
|
+
return [color.gray("Select a container.")];
|
|
1104
|
+
const memPct = c.memoryLimit ? ((c.memory ?? 0) / c.memoryLimit) * 100 : 0;
|
|
1105
|
+
const field = (k, v) => `${color.gray(padEnd(k, 10))} ${v}`;
|
|
1106
|
+
const d = this.detail && this.detailFor === c.id ? this.detail : null;
|
|
1107
|
+
const rows = [
|
|
1108
|
+
field("Name", color.bold(c.name)),
|
|
1109
|
+
field("Image", c.image),
|
|
1110
|
+
field("State", `${stateGlyph(c.state)} ${healthTag(d?.health, c.status)}`),
|
|
1111
|
+
field("Status", c.status),
|
|
1112
|
+
field("ID", color.dim(c.id.slice(0, 12))),
|
|
1113
|
+
];
|
|
1114
|
+
// LazyDocker-level detail from the inspect summary, when it's loaded.
|
|
1115
|
+
if (d) {
|
|
1116
|
+
if (d.created)
|
|
1117
|
+
rows.push(field("Uptime", `${age(d.created)} ${color.dim("· started " + shortDate(d.created))}`));
|
|
1118
|
+
if (d.command)
|
|
1119
|
+
rows.push(field("Command", color.dim(truncate(d.command, width - 12))));
|
|
1120
|
+
if (d.restartPolicy)
|
|
1121
|
+
rows.push(field("Restart", d.restartPolicy));
|
|
1122
|
+
rows.push(field("Networks", d.networks.length ? d.networks.join(", ") : color.gray("—")));
|
|
1123
|
+
rows.push(field("Ports", c.ports.length ? c.ports.join(", ") : color.gray("—")));
|
|
1124
|
+
rows.push(field("Mounts", d.mounts.length ? `${d.mounts.length} · ${truncate(d.mounts[0], width - 20)}` : color.gray("none")));
|
|
1125
|
+
rows.push(field("Env", d.env.length ? color.dim(`${d.env.length} variables`) : color.gray("none")));
|
|
1126
|
+
}
|
|
1127
|
+
else {
|
|
1128
|
+
rows.push(field("Ports", c.ports.length ? c.ports.join(", ") : color.gray("—")));
|
|
1129
|
+
rows.push(color.dim(" loading inspect…"));
|
|
1130
|
+
}
|
|
1131
|
+
rows.push("");
|
|
1132
|
+
rows.push(field("CPU", c.state === "running" ? `${bar(Math.min(100, c.cpu ?? 0), 16)} ${(c.cpu ?? 0).toFixed(1)}%` : color.gray("—")));
|
|
1133
|
+
rows.push(field("Memory", c.state === "running" ? `${bar(memPct, 16)} ${bytes(c.memory)}${c.memoryLimit ? "/" + bytes(c.memoryLimit) : ""}` : color.gray("—")));
|
|
1134
|
+
rows.push("");
|
|
1135
|
+
rows.push(color.dim("[l] logs · [S] start · [s] stop · [R] restart"));
|
|
1136
|
+
return rows.slice(0, height).map((r) => truncate(r, width));
|
|
1137
|
+
}
|
|
1138
|
+
logRows(width, height) {
|
|
1139
|
+
const all = this.logs.split("\n");
|
|
1140
|
+
const slice = all.slice(-height);
|
|
1141
|
+
return slice.map((l) => truncate(colorizeLog(l), width));
|
|
1142
|
+
}
|
|
1143
|
+
footerKeys(cols) {
|
|
1144
|
+
let keys;
|
|
1145
|
+
if (this.input === "ai")
|
|
1146
|
+
keys = `${color.accent("AI ❯")} ${color.bold(this.aiInput)}${color.dim("▏")} ${color.gray("Enter to run · Esc to cancel")}`;
|
|
1147
|
+
else if (this.input === "filter")
|
|
1148
|
+
keys = `${color.accent("filter ❯")} ${color.bold(this.filter)}${color.dim("▏")} ${color.gray("Esc to clear · Enter to keep")}`;
|
|
1149
|
+
else if (this.input === "volume")
|
|
1150
|
+
keys = `${color.accent("volume ❯")} ${color.bold(this.volumeInput)}${color.dim("▏")} ${color.gray("host:container[:ro] · Enter · Esc")}`;
|
|
1151
|
+
else if (this.input === "snapshots")
|
|
1152
|
+
keys = color.gray("press 1-9 to restore that snapshot · Esc to cancel");
|
|
1153
|
+
else if (this.input === "menu")
|
|
1154
|
+
keys = color.gray("↑/↓ choose · Enter run · shortcut letter · Esc close");
|
|
1155
|
+
else if (this.input === "confirm" && this.pending)
|
|
1156
|
+
keys = color.yellow(`${this.pending.label} run? y / n`);
|
|
1157
|
+
else if (this.input === "message")
|
|
1158
|
+
keys = color.gray("press any key to dismiss");
|
|
1159
|
+
else if (this.showLogs)
|
|
1160
|
+
keys = "l back · f follow · +/- tail · a AI · r refresh · ? help · q";
|
|
1161
|
+
else
|
|
1162
|
+
keys = `${color.accent("Enter/m")} menu · ↑/↓ move · / filter · o sort · l logs · a AI · ? help · q quit`;
|
|
1163
|
+
const status = this.status && this.input === "normal" ? ` ${this.status}` : "";
|
|
1164
|
+
const flags = this.frozen && this.input === "normal" ? ` ${color.yellow("⏸ paused")}` : "";
|
|
1165
|
+
return truncate(` ${color.gray(keys)}${status}${flags}`, cols);
|
|
1166
|
+
}
|
|
1167
|
+
/** Overlay a centered modal box (help / AI answers) onto the frame. */
|
|
1168
|
+
overlay(lines, title, text, cols, rows) {
|
|
1169
|
+
const raw = text.split("\n");
|
|
1170
|
+
const w = Math.min(cols - 6, Math.max(20, ...raw.map((l) => stripLen(l))) + 4);
|
|
1171
|
+
const content = raw.map((l) => ` ${truncate(l, w - 4)}`);
|
|
1172
|
+
const box = drawBox(title, content, w, content.length + 2);
|
|
1173
|
+
const top = Math.max(1, Math.floor((rows - box.length) / 2));
|
|
1174
|
+
const left = Math.max(0, Math.floor((cols - w) / 2));
|
|
1175
|
+
for (let i = 0; i < box.length; i++) {
|
|
1176
|
+
if (top + i < lines.length)
|
|
1177
|
+
lines[top + i] = " ".repeat(left) + box[i];
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
/** A single-line update notice under the resource bar; null when up to date. */
|
|
1181
|
+
updateBanner(cols) {
|
|
1182
|
+
const u = this.update;
|
|
1183
|
+
if (!u || !u.hasUpdate)
|
|
1184
|
+
return null;
|
|
1185
|
+
const rel = u.newer[0];
|
|
1186
|
+
const tag = u.critical
|
|
1187
|
+
? color.bold(color.red(" SECURITY UPDATE "))
|
|
1188
|
+
: color.bgAccent(" UPDATE ");
|
|
1189
|
+
const msg = `${color.bold(u.latest)} available ${color.dim("(you have " + u.current + ")")} — ${rel?.title ?? "see changelog"}`;
|
|
1190
|
+
const hint = color.gray("press [u] for changelog");
|
|
1191
|
+
const text = ` ${tag} ${msg}`;
|
|
1192
|
+
const pad = Math.max(1, cols - 2 - stripLen(text) - stripLen(hint));
|
|
1193
|
+
return truncate(text + " ".repeat(pad) + hint, cols);
|
|
1194
|
+
}
|
|
1195
|
+
/** The numbered snapshot list shown in the restore picker. */
|
|
1196
|
+
snapshotsText() {
|
|
1197
|
+
const snaps = this.service.listSnapshots().slice(0, 9);
|
|
1198
|
+
const lines = snaps.map((s, i) => `${color.accent(String(i + 1))} ${color.bold(padEnd(s.container, 14))} ${color.gray(padEnd(s.type, 7))} ${color.dim(bytes(s.sizeBytes))} ${s.ref}`);
|
|
1199
|
+
lines.push("");
|
|
1200
|
+
lines.push(color.dim("A commit snapshot starts a new container; an export imports a new image. Nothing is overwritten."));
|
|
1201
|
+
return lines.join("\n");
|
|
1202
|
+
}
|
|
1203
|
+
/** The modal body for [u]: every release newer than what's running. */
|
|
1204
|
+
changelogText() {
|
|
1205
|
+
const u = this.update;
|
|
1206
|
+
if (!u)
|
|
1207
|
+
return "";
|
|
1208
|
+
const lines = [
|
|
1209
|
+
`${color.bold(BRAND.product)} ${color.gray(u.current)} ${color.dim("→")} ${color.accent(color.bold(u.latest))}`,
|
|
1210
|
+
"",
|
|
1211
|
+
];
|
|
1212
|
+
for (const rel of u.newer) {
|
|
1213
|
+
lines.push(`${color.accent("●")} ${color.bold(rel.version)} ${color.gray(rel.date)}${rel.critical ? color.red(" (security)") : ""}`);
|
|
1214
|
+
if (rel.title)
|
|
1215
|
+
lines.push(` ${rel.title}`);
|
|
1216
|
+
for (const h of rel.highlights ?? [])
|
|
1217
|
+
lines.push(` ${color.gray("·")} ${h}`);
|
|
1218
|
+
if (rel.url)
|
|
1219
|
+
lines.push(` ${color.brightBlue(rel.url)}`);
|
|
1220
|
+
lines.push("");
|
|
1221
|
+
}
|
|
1222
|
+
lines.push(color.dim("Update: npm i -g docker-mcp-server@latest · press any key to close"));
|
|
1223
|
+
return lines.join("\n");
|
|
1224
|
+
}
|
|
1225
|
+
footerBrand(cols) {
|
|
1226
|
+
const text = `${color.accent("SoyRage Agency")} ${color.dim("·")} ${color.brightBlue(BRAND.url)}`;
|
|
1227
|
+
return " " + text + " ".repeat(Math.max(0, cols - 2 - stripLen(text)));
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
/** Braille spinner frames for lifecycle animations. */
|
|
1231
|
+
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
1232
|
+
/** A coloured dot for a container state (static — used in the details pane). */
|
|
1233
|
+
function stateGlyph(state) {
|
|
1234
|
+
if (state === "running")
|
|
1235
|
+
return color.green("●");
|
|
1236
|
+
if (state === "exited" || state === "dead")
|
|
1237
|
+
return color.red("●");
|
|
1238
|
+
return color.yellow("●");
|
|
1239
|
+
}
|
|
1240
|
+
/**
|
|
1241
|
+
* An expressive, *animated* status glyph — far more than a red/green dot. Each
|
|
1242
|
+
* state gets its own motion so a glance across the list reads like a heartbeat
|
|
1243
|
+
* monitor: healthy containers breathe, unhealthy ones blink for attention,
|
|
1244
|
+
* transitional ones spin.
|
|
1245
|
+
*/
|
|
1246
|
+
function animatedGlyph(c, tick) {
|
|
1247
|
+
const status = c.status || "";
|
|
1248
|
+
const restarting = /restart/i.test(status);
|
|
1249
|
+
const starting = /health:\s*starting|\(starting\)/i.test(status);
|
|
1250
|
+
const unhealthy = /unhealthy/i.test(status);
|
|
1251
|
+
const healthy = /healthy/i.test(status);
|
|
1252
|
+
if (restarting)
|
|
1253
|
+
return color.accent(SPINNER[tick % SPINNER.length]);
|
|
1254
|
+
if (c.state === "running") {
|
|
1255
|
+
if (unhealthy)
|
|
1256
|
+
return (tick % 2 ? color.red("▲") : color.yellow("△")); // blink to grab attention
|
|
1257
|
+
if (starting)
|
|
1258
|
+
return color.yellow(SPINNER[tick % SPINNER.length]);
|
|
1259
|
+
// Healthy / plain running: a gentle two-frame heartbeat (~every 0.9s).
|
|
1260
|
+
const beat = Math.floor(tick / 4) % 4 === 0;
|
|
1261
|
+
if (healthy)
|
|
1262
|
+
return beat ? color.brightGreen("◉") : color.green("●");
|
|
1263
|
+
return beat ? color.green("◉") : color.green("●");
|
|
1264
|
+
}
|
|
1265
|
+
if (c.state === "paused")
|
|
1266
|
+
return color.brightCyan("⏸");
|
|
1267
|
+
if (c.state === "exited" || c.state === "dead")
|
|
1268
|
+
return color.red("○");
|
|
1269
|
+
if (c.state === "created")
|
|
1270
|
+
return color.gray("◌");
|
|
1271
|
+
// restarting/removing/other transient
|
|
1272
|
+
return color.yellow(SPINNER[tick % SPINNER.length]);
|
|
1273
|
+
}
|
|
1274
|
+
/** Health/state label, preferring the inspected health when it's meaningful. */
|
|
1275
|
+
function healthTag(health, status) {
|
|
1276
|
+
const h = (health ?? "").toLowerCase();
|
|
1277
|
+
if (h === "healthy")
|
|
1278
|
+
return color.green("healthy");
|
|
1279
|
+
if (h === "unhealthy")
|
|
1280
|
+
return color.red("unhealthy");
|
|
1281
|
+
if (h === "starting")
|
|
1282
|
+
return color.yellow("starting");
|
|
1283
|
+
if (/unhealthy/i.test(status))
|
|
1284
|
+
return color.red("unhealthy");
|
|
1285
|
+
if (/healthy/i.test(status))
|
|
1286
|
+
return color.green("healthy");
|
|
1287
|
+
return color.gray("no healthcheck");
|
|
1288
|
+
}
|
|
1289
|
+
/** Compact age from an ISO timestamp, e.g. "3d 4h", "12m". */
|
|
1290
|
+
function age(iso) {
|
|
1291
|
+
const then = Date.parse(iso);
|
|
1292
|
+
if (Number.isNaN(then))
|
|
1293
|
+
return "—";
|
|
1294
|
+
let s = Math.max(0, Math.floor((Date.now() - then) / 1000));
|
|
1295
|
+
const d = Math.floor(s / 86400);
|
|
1296
|
+
s -= d * 86400;
|
|
1297
|
+
const h = Math.floor(s / 3600);
|
|
1298
|
+
s -= h * 3600;
|
|
1299
|
+
const m = Math.floor(s / 60);
|
|
1300
|
+
if (d > 0)
|
|
1301
|
+
return `${d}d ${h}h`;
|
|
1302
|
+
if (h > 0)
|
|
1303
|
+
return `${h}h ${m}m`;
|
|
1304
|
+
return `${m}m`;
|
|
1305
|
+
}
|
|
1306
|
+
/** "Jul 27 14:03" from an ISO timestamp. */
|
|
1307
|
+
function shortDate(iso) {
|
|
1308
|
+
const t = Date.parse(iso);
|
|
1309
|
+
if (Number.isNaN(t))
|
|
1310
|
+
return "";
|
|
1311
|
+
const dt = new Date(t);
|
|
1312
|
+
const mon = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][dt.getMonth()];
|
|
1313
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
1314
|
+
return `${mon} ${p(dt.getDate())} ${p(dt.getHours())}:${p(dt.getMinutes())}`;
|
|
1315
|
+
}
|
|
1316
|
+
/** Past-tense confirmation verb. */
|
|
1317
|
+
function verb(kind) {
|
|
1318
|
+
return kind === "restart" ? "restarted" : kind === "start" ? "started" : "stopped";
|
|
1319
|
+
}
|
|
1320
|
+
/** Present-continuous label shown next to the spinner. */
|
|
1321
|
+
function gerund(kind) {
|
|
1322
|
+
return kind === "restart" ? "restarting…" : kind === "start" ? "starting…" : "stopping…";
|
|
1323
|
+
}
|
|
1324
|
+
/** Visible length ignoring ANSI (local copy to avoid import churn). */
|
|
1325
|
+
function stripLen(s) {
|
|
1326
|
+
// eslint-disable-next-line no-control-regex
|
|
1327
|
+
return s.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
1328
|
+
}
|
|
1329
|
+
/** Colour a log line by level keyword. */
|
|
1330
|
+
function colorizeLog(line) {
|
|
1331
|
+
if (/\[error\]|\berror\b/i.test(line))
|
|
1332
|
+
return color.red(line);
|
|
1333
|
+
if (/\[warn\]|\bwarn/i.test(line))
|
|
1334
|
+
return color.yellow(line);
|
|
1335
|
+
if (/\[info\]/i.test(line))
|
|
1336
|
+
return line.replace(/\[info\]/i, color.green("[info]"));
|
|
1337
|
+
return color.gray(line);
|
|
1338
|
+
}
|
|
1339
|
+
//# sourceMappingURL=app.js.map
|