@mytinyapps/dockerino 0.5.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.
@@ -0,0 +1,942 @@
1
+ import { spawn } from 'node:child_process';
2
+ import blessed from 'neo-blessed';
3
+ import { listContainers, getContainerStats, startContainer, stopContainer, restartContainer, removeContainer, removeImage, detectShell, inspectContainer, listVolumes, removeVolume, inspectVolume } from '../docker/commands.js';
4
+ const STATE_COLORS = {
5
+ running: 'green',
6
+ exited: 'red',
7
+ paused: 'yellow',
8
+ restarting: 'yellow',
9
+ dead: 'red',
10
+ created: 'white',
11
+ };
12
+ /** Minimum terminal width to show stats as inline columns */
13
+ const WIDE_THRESHOLD = 120;
14
+ const TABS = [
15
+ { id: 'containers', label: 'Containers', shortcutKey: 'c', shortcutIndex: 0 },
16
+ { id: 'images', label: 'Images', shortcutKey: 'i', shortcutIndex: 0 },
17
+ { id: 'volumes', label: 'Volumes', shortcutKey: 'v', shortcutIndex: 0 },
18
+ ];
19
+ /**
20
+ * Main dashboard — adaptive table layout.
21
+ *
22
+ * Wide (≥120 cols): stats as columns
23
+ * ┌─ Containers ──────────────────────────────────────────────────────┐
24
+ * │ STATE NAME IMAGE STATUS CPU MEM │
25
+ * │ ▼ my-project │
26
+ * │ ├─ web nginx:latest Up 2h 1.2% 45M │
27
+ * │ └─ db postgres:16 Up 2h 3.1% 120M │
28
+ * │ standalone alpine:3.19 Exited │
29
+ * ├───────────────────────────────────────────────────────────────────┤
30
+ * │ ↑↓ Navigate s Start S Stop r Restart d Remove R Refresh │
31
+ * └───────────────────────────────────────────────────────────────────┘
32
+ *
33
+ * Narrow (<120 cols): detail panel at the bottom
34
+ * ┌─ Containers ──────────────────────────────┐
35
+ * │ STATE NAME IMAGE STATUS │
36
+ * ├─ Detail ──────────────────────────────────┤
37
+ * │ CPU: 1.2% MEM: 45Mi (12%) NET: 1.2kB │
38
+ * ├───────────────────────────────────────────┤
39
+ * │ ↑↓ Navigate s Start ... │
40
+ * └───────────────────────────────────────────┘
41
+ *
42
+ * Keys: ↑↓/j/k navigate, s start, S stop, r restart,
43
+ * d remove (exited only), R refresh, q/Esc quit
44
+ */
45
+ export function createDashboard() {
46
+ const screen = blessed.screen({
47
+ smartCSR: true,
48
+ title: 'dockerino',
49
+ });
50
+ // --- Tab bar ---
51
+ const tabBar = blessed.box({
52
+ parent: screen,
53
+ top: 0,
54
+ left: 0,
55
+ width: '100%',
56
+ height: 1,
57
+ tags: true,
58
+ style: { fg: 'white', bg: 'default' },
59
+ });
60
+ let activeTab = 0;
61
+ function renderTabBar() {
62
+ const parts = TABS.map((tab, i) => {
63
+ const before = tab.label.slice(0, tab.shortcutIndex);
64
+ const key = tab.label[tab.shortcutIndex];
65
+ const after = tab.label.slice(tab.shortcutIndex + 1);
66
+ const highlighted = `${before}{yellow-fg}{underline}${key}{/underline}{/yellow-fg}${after}`;
67
+ if (i === activeTab) {
68
+ return ` {bold}{white-bg}{black-fg} ${highlighted} {/black-fg}{/white-bg}{/bold}`;
69
+ }
70
+ return ` {gray-fg} ${highlighted} {/gray-fg}`;
71
+ });
72
+ tabBar.setContent(parts.join(''));
73
+ safeRender();
74
+ }
75
+ function switchTab(index) {
76
+ if (index < 0 || index >= TABS.length || index === activeTab)
77
+ return;
78
+ closePopup();
79
+ activeTab = index;
80
+ renderTabBar();
81
+ // Show/hide panels based on active tab
82
+ if (TABS[activeTab].id === 'containers') {
83
+ tableBox.show();
84
+ imagesBox.hide();
85
+ volumesBox.hide();
86
+ updateLayout();
87
+ renderTable();
88
+ renderDetail();
89
+ tableList.focus();
90
+ }
91
+ else if (TABS[activeTab].id === 'images') {
92
+ tableBox.hide();
93
+ detailBox.hide();
94
+ volumesBox.hide();
95
+ imagesBox.show();
96
+ imagesList.focus();
97
+ void refreshImages();
98
+ }
99
+ else if (TABS[activeTab].id === 'volumes') {
100
+ tableBox.hide();
101
+ detailBox.hide();
102
+ imagesBox.hide();
103
+ volumesBox.show();
104
+ volumesList.focus();
105
+ void refreshVolumes();
106
+ }
107
+ safeRender();
108
+ }
109
+ // --- Containers tab: main table ---
110
+ const tableBox = blessed.box({
111
+ parent: screen,
112
+ label: ' Containers ',
113
+ top: 1,
114
+ left: 0,
115
+ width: '100%',
116
+ height: '100%-2',
117
+ border: { type: 'line' },
118
+ style: { border: { fg: 'blue' } },
119
+ });
120
+ const tableList = blessed.list({
121
+ parent: tableBox,
122
+ top: 0,
123
+ left: 0,
124
+ width: '100%-2',
125
+ height: '100%-2',
126
+ keys: true,
127
+ vi: true,
128
+ mouse: true,
129
+ scrollable: true,
130
+ scrollbar: { style: { bg: 'blue' } },
131
+ style: {
132
+ fg: 'white',
133
+ selected: { fg: 'black', bg: 'cyan' },
134
+ },
135
+ tags: true,
136
+ });
137
+ // --- Detail panel (narrow mode only) ---
138
+ const detailBox = blessed.box({
139
+ parent: screen,
140
+ label: ' Detail ',
141
+ bottom: 1,
142
+ left: 0,
143
+ width: '100%',
144
+ height: 5,
145
+ border: { type: 'line' },
146
+ style: { border: { fg: 'blue' } },
147
+ tags: true,
148
+ hidden: true,
149
+ });
150
+ // --- Images tab ---
151
+ const imagesBox = blessed.box({
152
+ parent: screen,
153
+ label: ' Images ',
154
+ top: 1,
155
+ left: 0,
156
+ width: '100%',
157
+ height: '100%-2',
158
+ border: { type: 'line' },
159
+ style: { border: { fg: 'blue' } },
160
+ hidden: true,
161
+ });
162
+ const imagesList = blessed.list({
163
+ parent: imagesBox,
164
+ top: 0,
165
+ left: 0,
166
+ width: '100%-2',
167
+ height: '100%-2',
168
+ keys: true,
169
+ vi: true,
170
+ mouse: true,
171
+ scrollable: true,
172
+ scrollbar: { style: { bg: 'blue' } },
173
+ style: {
174
+ fg: 'white',
175
+ selected: { fg: 'black', bg: 'cyan' },
176
+ },
177
+ tags: true,
178
+ });
179
+ async function refreshImages() {
180
+ try {
181
+ const { listImages } = await import('../docker/commands.js');
182
+ images = await listImages();
183
+ const header = ` {bold}${pad('REPOSITORY', 30)} ${pad('TAG', 20)} ${pad('ID', 15)} ${pad('SIZE', 12)}{/bold}`;
184
+ const rows = images.map((img) => ` ${pad(img.repository, 30)} ${pad(img.tag, 20)} ${pad(img.id.slice(0, 12), 15)} ${pad(img.size, 12)}`);
185
+ imagesList.setItems([header, ...rows]);
186
+ if (rows.length > 0)
187
+ imagesList.select(1);
188
+ safeRender();
189
+ }
190
+ catch (err) {
191
+ const msg = err instanceof Error ? err.message : String(err);
192
+ imagesList.setItems([` {red-fg}Error: ${msg}{/red-fg}`]);
193
+ safeRender();
194
+ }
195
+ }
196
+ function selectedImage() {
197
+ const idx = imagesList.selected;
198
+ if (idx < 1 || idx > images.length)
199
+ return undefined; // 0 is header
200
+ return images[idx - 1];
201
+ }
202
+ // --- Volumes tab ---
203
+ const volumesBox = blessed.box({
204
+ parent: screen,
205
+ label: ' Volumes ',
206
+ top: 1,
207
+ left: 0,
208
+ width: '100%',
209
+ height: '100%-2',
210
+ border: { type: 'line' },
211
+ style: { border: { fg: 'blue' } },
212
+ hidden: true,
213
+ });
214
+ const volumesList = blessed.list({
215
+ parent: volumesBox,
216
+ top: 0,
217
+ left: 0,
218
+ width: '100%-2',
219
+ height: '100%-2',
220
+ keys: true,
221
+ vi: true,
222
+ mouse: true,
223
+ scrollable: true,
224
+ scrollbar: { style: { bg: 'blue' } },
225
+ style: {
226
+ fg: 'white',
227
+ selected: { fg: 'black', bg: 'cyan' },
228
+ },
229
+ tags: true,
230
+ });
231
+ async function refreshVolumes() {
232
+ try {
233
+ volumes = await listVolumes();
234
+ const header = ` {bold}${pad('NAME', 40)} ${pad('DRIVER', 12)} ${pad('SCOPE', 10)}{/bold}`;
235
+ const rows = volumes.map((v) => ` ${pad(v.name, 40)} ${pad(v.driver, 12)} ${pad(v.scope, 10)}`);
236
+ volumesList.setItems([header, ...rows]);
237
+ if (rows.length > 0)
238
+ volumesList.select(1);
239
+ safeRender();
240
+ }
241
+ catch (err) {
242
+ const msg = err instanceof Error ? err.message : String(err);
243
+ volumesList.setItems([` {red-fg}Error: ${msg}{/red-fg}`]);
244
+ safeRender();
245
+ }
246
+ }
247
+ function selectedVolume() {
248
+ const idx = volumesList.selected;
249
+ if (idx < 1 || idx > volumes.length)
250
+ return undefined;
251
+ return volumes[idx - 1];
252
+ }
253
+ // --- Status bar ---
254
+ const STATUS_KEYS = ' {bold}↑↓{/} Navigate {bold}s{/} Start {bold}S{/} Stop {bold}r{/} Restart {bold}del{/} Remove {bold}d{/} Details {bold}l{/} Logs {bold}h{/} Shell {bold}R{/} Refresh {bold}q{/} Quit';
255
+ const IMAGES_STATUS_KEYS = ' {bold}↑↓{/} Navigate {bold}del{/} Delete {bold}d{/} Details {bold}R{/} Refresh {bold}q{/} Quit';
256
+ const VOLUMES_STATUS_KEYS = ' {bold}↑↓{/} Navigate {bold}del{/} Delete {bold}d{/} Details {bold}R{/} Refresh {bold}q{/} Quit';
257
+ const statusBar = blessed.box({
258
+ parent: screen,
259
+ bottom: 0,
260
+ left: 0,
261
+ width: '100%',
262
+ height: 1,
263
+ tags: true,
264
+ style: { fg: 'white', bg: 'blue' },
265
+ content: STATUS_KEYS,
266
+ });
267
+ // --- State ---
268
+ let containers = [];
269
+ let stats = [];
270
+ let displayRows = [];
271
+ let selectedIdx = 0;
272
+ let refreshTimer = null;
273
+ let shellActive = false;
274
+ let images = [];
275
+ let volumes = [];
276
+ let popupOpen = false;
277
+ let activePopup = null;
278
+ let popupCleanup = null;
279
+ /** Restore focus to the active tab's list */
280
+ function refocusActiveList() {
281
+ const id = TABS[activeTab].id;
282
+ if (id === 'containers')
283
+ tableList.focus();
284
+ else if (id === 'images')
285
+ imagesList.focus();
286
+ else if (id === 'volumes')
287
+ volumesList.focus();
288
+ }
289
+ /** Close any open popup */
290
+ function closePopup() {
291
+ if (activePopup) {
292
+ if (popupCleanup) {
293
+ popupCleanup();
294
+ popupCleanup = null;
295
+ }
296
+ popupOpen = false;
297
+ activePopup.detach();
298
+ activePopup = null;
299
+ refocusActiveList();
300
+ safeRender();
301
+ }
302
+ }
303
+ /** Guarded render — does nothing while shell is active */
304
+ function safeRender() {
305
+ if (!shellActive)
306
+ screen.render();
307
+ }
308
+ /** Returns the status bar text for the currently active tab */
309
+ function activeStatusKeys() {
310
+ const id = TABS[activeTab].id;
311
+ if (id === 'images')
312
+ return IMAGES_STATUS_KEYS;
313
+ if (id === 'volumes')
314
+ return VOLUMES_STATUS_KEYS;
315
+ return STATUS_KEYS;
316
+ }
317
+ /** Flash an error in the status bar, then restore normal keys */
318
+ function flashError(msg) {
319
+ statusBar.style.bg = 'red';
320
+ statusBar.setContent(` {bold}Error:{/bold} ${msg}`);
321
+ safeRender();
322
+ setTimeout(() => { statusBar.style.bg = 'blue'; statusBar.setContent(activeStatusKeys()); safeRender(); }, 3000);
323
+ }
324
+ tableList.on('select item', (_item, index) => {
325
+ selectedIdx = index;
326
+ renderDetail();
327
+ });
328
+ // --- Helpers ---
329
+ function isWide() {
330
+ return screen.width >= WIDE_THRESHOLD;
331
+ }
332
+ function statsFor(c) {
333
+ return stats.find((s) => s.id === c.id || s.name === c.name);
334
+ }
335
+ function pad(s, n) {
336
+ return s.length > n ? s.slice(0, n - 1) + '…' : s.padEnd(n);
337
+ }
338
+ /** Build hierarchical row model: compose groups then standalone */
339
+ function buildDisplayRows(list) {
340
+ const rows = [];
341
+ const groups = new Map();
342
+ const standalone = [];
343
+ for (const c of list) {
344
+ if (c.composeProject) {
345
+ let g = groups.get(c.composeProject);
346
+ if (!g) {
347
+ g = [];
348
+ groups.set(c.composeProject, g);
349
+ }
350
+ g.push(c);
351
+ }
352
+ else {
353
+ standalone.push(c);
354
+ }
355
+ }
356
+ for (const [project, members] of groups) {
357
+ rows.push({ type: 'header', composeProject: project });
358
+ for (const c of members) {
359
+ rows.push({ type: 'container', container: c, composeProject: project });
360
+ }
361
+ }
362
+ for (const c of standalone) {
363
+ rows.push({ type: 'container', container: c });
364
+ }
365
+ return rows;
366
+ }
367
+ function headerLine(wide) {
368
+ let h = ` {bold}${pad('STATE', 10)} ${pad('NAME', 22)} ${pad('IMAGE', 25)} ${pad('STATUS', 18)}`;
369
+ if (wide)
370
+ h += ` ${pad('CPU', 8)} ${pad('MEMORY', 22)} ${pad('NET I/O', 18)}`;
371
+ return h + '{/bold}';
372
+ }
373
+ function formatRow(row, wide) {
374
+ if (row.type === 'header') {
375
+ // Align compose project name with the NAME column (after STATE)
376
+ return ` ${pad('', 10)} {bold}{cyan-fg}▼ ${row.composeProject}{/cyan-fg}{/bold}`;
377
+ }
378
+ const c = row.container;
379
+ const color = STATE_COLORS[c.state] ?? 'white';
380
+ const prefix = row.composeProject ? '├─ ' : '';
381
+ const displayName = c.composeService ?? c.name;
382
+ let line = ` {${color}-fg}${pad(c.state.toUpperCase(), 10)}{/${color}-fg}`
383
+ + ` ${pad(prefix + displayName, 22)}`
384
+ + ` ${pad(c.image, 25)}`
385
+ + ` ${pad(c.status, 18)}`;
386
+ if (wide) {
387
+ const s = statsFor(c);
388
+ if (s) {
389
+ const cpu = parseFloat(s.cpuPercent) || 0;
390
+ const mem = parseFloat(s.memPercent) || 0;
391
+ const cpuC = cpu > 80 ? 'red' : cpu > 50 ? 'yellow' : 'green';
392
+ const memC = mem > 80 ? 'red' : mem > 50 ? 'yellow' : 'green';
393
+ line += ` {${cpuC}-fg}${pad(s.cpuPercent, 8)}{/${cpuC}-fg}`;
394
+ line += ` {${memC}-fg}${pad(s.memUsage, 22)}{/${memC}-fg}`;
395
+ line += ` ${pad(s.netIO, 18)}`;
396
+ }
397
+ else if (c.state === 'running') {
398
+ line += ` ${pad('…', 8)} ${pad('…', 22)} ${pad('…', 18)}`;
399
+ }
400
+ }
401
+ return line;
402
+ }
403
+ // --- Layout ---
404
+ function updateLayout() {
405
+ if (TABS[activeTab].id !== 'containers')
406
+ return;
407
+ if (isWide()) {
408
+ detailBox.hide();
409
+ tableBox.height = '100%-2'; // tab bar + status bar
410
+ }
411
+ else {
412
+ detailBox.show();
413
+ tableBox.height = '100%-8'; // tab bar + detail(5+border) + status bar
414
+ }
415
+ }
416
+ // --- Rendering ---
417
+ function renderTable() {
418
+ const wide = isWide();
419
+ displayRows = buildDisplayRows(containers);
420
+ const items = [headerLine(wide), ...displayRows.map((r) => formatRow(r, wide))];
421
+ tableList.setItems(items);
422
+ selectedIdx = Math.max(1, Math.min(selectedIdx, items.length - 1));
423
+ tableList.select(selectedIdx);
424
+ safeRender();
425
+ }
426
+ function renderDetail() {
427
+ if (isWide())
428
+ return;
429
+ const row = displayRows[selectedIdx - 1]; // -1 for header line
430
+ if (!row || row.type === 'header') {
431
+ detailBox.setContent(' Select a container to see stats');
432
+ safeRender();
433
+ return;
434
+ }
435
+ const c = row.container;
436
+ const s = statsFor(c);
437
+ if (!s) {
438
+ detailBox.setContent(c.state === 'running'
439
+ ? ` {bold}${c.name}{/bold} Stats loading…`
440
+ : ` {bold}${c.name}{/bold} Container not running`);
441
+ safeRender();
442
+ return;
443
+ }
444
+ const cpu = parseFloat(s.cpuPercent) || 0;
445
+ const mem = parseFloat(s.memPercent) || 0;
446
+ const cpuC = cpu > 80 ? 'red' : cpu > 50 ? 'yellow' : 'green';
447
+ const memC = mem > 80 ? 'red' : mem > 50 ? 'yellow' : 'green';
448
+ detailBox.setContent([
449
+ ` {bold}${c.name}{/bold} (${c.image})`,
450
+ ` CPU: {${cpuC}-fg}${s.cpuPercent}{/${cpuC}-fg} MEM: {${memC}-fg}${s.memUsage} (${s.memPercent}){/${memC}-fg} PIDs: ${s.pids}`,
451
+ ` NET: ${s.netIO} I/O: ${s.blockIO}`,
452
+ ].join('\n'));
453
+ safeRender();
454
+ }
455
+ async function refresh() {
456
+ if (shellActive)
457
+ return;
458
+ try {
459
+ const [newContainers, newStats] = await Promise.all([
460
+ listContainers(true),
461
+ getContainerStats().catch(() => []),
462
+ ]);
463
+ containers = newContainers;
464
+ stats = newStats;
465
+ updateLayout();
466
+ renderTable();
467
+ renderDetail();
468
+ statusBar.setContent(activeStatusKeys());
469
+ safeRender();
470
+ }
471
+ catch (err) {
472
+ const msg = err instanceof Error ? err.message : String(err);
473
+ flashError(msg);
474
+ }
475
+ }
476
+ function selectedContainer() {
477
+ const row = displayRows[selectedIdx - 1];
478
+ return row?.type === 'container' ? row.container : undefined;
479
+ }
480
+ async function runAction(action, label) {
481
+ closePopup();
482
+ const c = selectedContainer();
483
+ if (!c)
484
+ return;
485
+ statusBar.setContent(` ${label} ${c.name}...`);
486
+ safeRender();
487
+ try {
488
+ await action(c.id);
489
+ await refresh();
490
+ }
491
+ catch (err) {
492
+ const msg = err instanceof Error ? err.message : String(err);
493
+ flashError(msg);
494
+ }
495
+ }
496
+ // screen.leave() / screen.enter() exist in neo-blessed but are missing from @types/blessed
497
+ const screenAny = screen;
498
+ // --- Shell ---
499
+ async function openShell() {
500
+ closePopup();
501
+ const c = selectedContainer();
502
+ if (!c || c.state !== 'running')
503
+ return;
504
+ statusBar.setContent(` Detecting shell for ${c.name}...`);
505
+ safeRender();
506
+ let shell;
507
+ try {
508
+ shell = await detectShell(c.id);
509
+ }
510
+ catch {
511
+ statusBar.setContent(` {red-fg}{bold}Error:{/bold} cannot detect shell{/red-fg}`);
512
+ safeRender();
513
+ return;
514
+ }
515
+ // Block all rendering and release stdin so docker exec gets full terminal control
516
+ shellActive = true;
517
+ if (refreshTimer) {
518
+ clearInterval(refreshTimer);
519
+ refreshTimer = null;
520
+ }
521
+ screenAny.leave();
522
+ process.stdin.pause();
523
+ process.stdout.write(`\x1b[2J\x1b[H`); // clear screen
524
+ process.stdout.write(`Connecting to ${c.name} (${shell})...\r\n`);
525
+ const ps = spawn('docker', ['exec', '-it', c.id, shell], {
526
+ stdio: 'inherit',
527
+ });
528
+ function restoreScreen() {
529
+ shellActive = false;
530
+ process.stdin.resume();
531
+ screenAny.enter();
532
+ // Immediately render old data so user doesn't see a black screen
533
+ screen.render();
534
+ // Then refresh in background
535
+ refreshTimer = setInterval(() => { void refresh(); }, 5000);
536
+ void refresh();
537
+ }
538
+ ps.on('exit', restoreScreen);
539
+ ps.on('error', (err) => {
540
+ restoreScreen();
541
+ statusBar.setContent(` {red-fg}{bold}Error:{/bold} ${err.message}{/red-fg}`);
542
+ screen.render();
543
+ setTimeout(() => { statusBar.setContent(activeStatusKeys()); screen.render(); }, 3000);
544
+ });
545
+ }
546
+ // --- Detail popup ---
547
+ function showDetailPopup(title, lines) {
548
+ closePopup();
549
+ popupOpen = true;
550
+ const maxH = screen.height - 2;
551
+ const wantH = lines.length + 4; // +2 border, +1 empty line, +1 hint
552
+ const popupH = Math.min(wantH, maxH);
553
+ const scrollable = wantH > maxH;
554
+ const hint = scrollable ? '{gray-fg}↑↓ Scroll Esc/Enter to close{/gray-fg}' : '{gray-fg}Esc/Enter to close{/gray-fg}';
555
+ const allLines = [...lines, '', hint];
556
+ const popup = blessed.box({
557
+ parent: screen,
558
+ label: ` ${title} `,
559
+ top: 'center',
560
+ left: 'center',
561
+ width: '70%',
562
+ height: popupH,
563
+ border: { type: 'line' },
564
+ style: { border: { fg: 'cyan' }, fg: 'white', bg: 'black' },
565
+ tags: true,
566
+ content: allLines.join('\n'),
567
+ padding: { left: 1, right: 1, top: 0, bottom: 0 },
568
+ scrollable,
569
+ keys: scrollable,
570
+ vi: scrollable,
571
+ mouse: scrollable,
572
+ scrollbar: scrollable ? { style: { bg: 'cyan' } } : undefined,
573
+ });
574
+ screen.render();
575
+ const close = () => {
576
+ popupOpen = false;
577
+ activePopup = null;
578
+ popup.detach();
579
+ refocusActiveList();
580
+ safeRender();
581
+ };
582
+ popup.key(['escape', 'enter', 'q', 'd'], close);
583
+ activePopup = popup;
584
+ popup.focus();
585
+ }
586
+ async function showContainerDetails() {
587
+ const c = selectedContainer();
588
+ if (!c)
589
+ return;
590
+ const s = statsFor(c);
591
+ const lines = [
592
+ `{bold}Name:{/bold} ${c.name}`,
593
+ `{bold}ID:{/bold} ${c.id}`,
594
+ `{bold}Image:{/bold} ${c.image}`,
595
+ `{bold}State:{/bold} ${c.state.toUpperCase()}`,
596
+ `{bold}Status:{/bold} ${c.status}`,
597
+ `{bold}Ports:{/bold} ${c.ports || '—'}`,
598
+ `{bold}Created:{/bold} ${c.created}`,
599
+ ];
600
+ if (c.size)
601
+ lines.push(`{bold}Size:{/bold} ${c.size}`);
602
+ if (c.composeProject) {
603
+ lines.push(`{bold}Compose:{/bold} ${c.composeProject} / ${c.composeService ?? '—'}`);
604
+ }
605
+ if (s) {
606
+ lines.push('');
607
+ lines.push(`{bold}CPU:{/bold} ${s.cpuPercent}`);
608
+ lines.push(`{bold}Memory:{/bold} ${s.memUsage} (${s.memPercent})`);
609
+ lines.push(`{bold}Net I/O:{/bold} ${s.netIO}`);
610
+ lines.push(`{bold}Block IO:{/bold} ${s.blockIO}`);
611
+ lines.push(`{bold}PIDs:{/bold} ${s.pids}`);
612
+ }
613
+ // Fetch inspect data (mounts + env)
614
+ try {
615
+ const info = await inspectContainer(c.id);
616
+ if (info.mounts.length > 0) {
617
+ lines.push('');
618
+ lines.push(`{bold}{underline}Volumes / Mounts{/underline}{/bold}`);
619
+ for (const m of info.mounts) {
620
+ const rw = m.rw ? 'rw' : 'ro';
621
+ lines.push(` ${m.type} ${m.source} → ${m.destination} (${rw})`);
622
+ }
623
+ }
624
+ if (info.env.length > 0) {
625
+ lines.push('');
626
+ lines.push(`{bold}{underline}Environment{/underline}{/bold}`);
627
+ for (const e of info.env) {
628
+ lines.push(` ${e}`);
629
+ }
630
+ }
631
+ }
632
+ catch {
633
+ lines.push('');
634
+ lines.push(`{red-fg}Could not fetch inspect data{/red-fg}`);
635
+ }
636
+ showDetailPopup('Container Details', lines);
637
+ }
638
+ function showImageDetails() {
639
+ const img = selectedImage();
640
+ if (!img)
641
+ return;
642
+ const lines = [
643
+ `{bold}Repository:{/bold} ${img.repository}`,
644
+ `{bold}Tag:{/bold} ${img.tag}`,
645
+ `{bold}ID:{/bold} ${img.id}`,
646
+ `{bold}Size:{/bold} ${img.size}`,
647
+ `{bold}Created:{/bold} ${img.created}`,
648
+ ];
649
+ showDetailPopup('Image Details', lines);
650
+ }
651
+ async function showVolumeDetails() {
652
+ const v = selectedVolume();
653
+ if (!v)
654
+ return;
655
+ const lines = [
656
+ `{bold}Name:{/bold} ${v.name}`,
657
+ `{bold}Driver:{/bold} ${v.driver}`,
658
+ `{bold}Scope:{/bold} ${v.scope}`,
659
+ `{bold}Mountpoint:{/bold} ${v.mountpoint}`,
660
+ ];
661
+ try {
662
+ const info = await inspectVolume(v.name);
663
+ if (info.createdAt)
664
+ lines.push(`{bold}Created:{/bold} ${info.createdAt}`);
665
+ const labelEntries = Object.entries(info.labels);
666
+ if (labelEntries.length > 0) {
667
+ lines.push('');
668
+ lines.push(`{bold}{underline}Labels{/underline}{/bold}`);
669
+ for (const [k, val] of labelEntries) {
670
+ lines.push(` ${k} = ${val}`);
671
+ }
672
+ }
673
+ const optEntries = Object.entries(info.options);
674
+ if (optEntries.length > 0) {
675
+ lines.push('');
676
+ lines.push(`{bold}{underline}Options{/underline}{/bold}`);
677
+ for (const [k, val] of optEntries) {
678
+ lines.push(` ${k} = ${val}`);
679
+ }
680
+ }
681
+ lines.push('');
682
+ if (info.usedBy.length > 0) {
683
+ lines.push(`{bold}{underline}Used by containers{/underline}{/bold}`);
684
+ for (const name of info.usedBy) {
685
+ lines.push(` ${name}`);
686
+ }
687
+ }
688
+ else {
689
+ lines.push(`{gray-fg}Not used by any container{/gray-fg}`);
690
+ }
691
+ }
692
+ catch {
693
+ lines.push('');
694
+ lines.push(`{red-fg}Could not fetch volume details{/red-fg}`);
695
+ }
696
+ showDetailPopup('Volume Details', lines);
697
+ }
698
+ // --- Logs viewer ---
699
+ function openLogs() {
700
+ const c = selectedContainer();
701
+ if (!c || c.state !== 'running')
702
+ return;
703
+ closePopup();
704
+ popupOpen = true;
705
+ const allLogLines = [];
706
+ let filterText = '';
707
+ let logProcess = null;
708
+ // Main popup box
709
+ const logPopup = blessed.box({
710
+ parent: screen,
711
+ label: ` Logs: ${c.name} `,
712
+ top: 1,
713
+ left: 1,
714
+ width: '100%-2',
715
+ height: '100%-2',
716
+ border: { type: 'line' },
717
+ style: { border: { fg: 'cyan' }, fg: 'white', bg: 'black' },
718
+ tags: true,
719
+ });
720
+ // Filter prompt
721
+ const filterLabel = blessed.box({
722
+ parent: logPopup,
723
+ top: 0,
724
+ left: 0,
725
+ width: 10,
726
+ height: 1,
727
+ tags: true,
728
+ content: ' {bold}Filter:{/bold}',
729
+ style: { fg: 'white', bg: 'black' },
730
+ });
731
+ const filterInput = blessed.textbox({
732
+ parent: logPopup,
733
+ top: 0,
734
+ left: 10,
735
+ width: '100%-12',
736
+ height: 1,
737
+ style: { fg: 'white', bg: 'black', focus: { fg: 'white', bg: 'black' } },
738
+ inputOnFocus: true,
739
+ });
740
+ // Separator
741
+ const separator = blessed.line({
742
+ parent: logPopup,
743
+ top: 1,
744
+ left: 0,
745
+ width: '100%-2',
746
+ orientation: 'horizontal',
747
+ style: { fg: 'cyan' },
748
+ });
749
+ // Log content area
750
+ const logBox = blessed.log({
751
+ parent: logPopup,
752
+ top: 2,
753
+ left: 0,
754
+ width: '100%-2',
755
+ height: '100%-4',
756
+ keys: true,
757
+ vi: true,
758
+ mouse: true,
759
+ scrollable: true,
760
+ scrollbar: { style: { bg: 'cyan' } },
761
+ tags: true,
762
+ style: { fg: 'white', bg: 'black' },
763
+ });
764
+ // Hint bar at bottom
765
+ const logHint = blessed.box({
766
+ parent: logPopup,
767
+ bottom: 0,
768
+ left: 0,
769
+ width: '100%-2',
770
+ height: 1,
771
+ tags: true,
772
+ style: { fg: 'gray', bg: 'black' },
773
+ content: ' {bold}/{/} Filter {bold}Esc{/} Close {bold}↑↓{/} Scroll',
774
+ });
775
+ function applyFilter() {
776
+ logBox;
777
+ // Clear and re-add filtered lines
778
+ logBox.setContent('');
779
+ const filtered = filterText
780
+ ? allLogLines.filter((l) => l.toLowerCase().includes(filterText.toLowerCase()))
781
+ : allLogLines;
782
+ for (const line of filtered) {
783
+ logBox.add(line);
784
+ }
785
+ logBox.setScrollPerc(100);
786
+ screen.render();
787
+ }
788
+ // Start docker logs
789
+ logProcess = spawn('docker', ['logs', '-f', '--tail', '500', c.id]);
790
+ function onData(data) {
791
+ const text = data.toString('utf-8');
792
+ const newLines = text.split('\n');
793
+ // Last element may be empty from trailing newline
794
+ if (newLines.length > 0 && newLines[newLines.length - 1] === '')
795
+ newLines.pop();
796
+ for (const line of newLines) {
797
+ allLogLines.push(line);
798
+ if (!filterText || line.toLowerCase().includes(filterText.toLowerCase())) {
799
+ logBox.add(line);
800
+ }
801
+ }
802
+ // Cap buffer at 5000 lines
803
+ while (allLogLines.length > 5000)
804
+ allLogLines.shift();
805
+ logBox.setScrollPerc(100);
806
+ screen.render();
807
+ }
808
+ logProcess.stdout?.on('data', onData);
809
+ logProcess.stderr?.on('data', onData);
810
+ function closeLogs() {
811
+ if (logProcess) {
812
+ logProcess.kill();
813
+ logProcess = null;
814
+ }
815
+ popupCleanup = null;
816
+ popupOpen = false;
817
+ activePopup = null;
818
+ logPopup.detach();
819
+ refocusActiveList();
820
+ safeRender();
821
+ }
822
+ popupCleanup = () => {
823
+ if (logProcess) {
824
+ logProcess.kill();
825
+ logProcess = null;
826
+ }
827
+ };
828
+ // '/' focuses the filter input
829
+ logBox.key(['/'], () => {
830
+ filterInput.focus();
831
+ screen.render();
832
+ });
833
+ logPopup.key(['/'], () => {
834
+ filterInput.focus();
835
+ screen.render();
836
+ });
837
+ // Escape from filter input: apply filter and go back to log view
838
+ filterInput.on('cancel', () => {
839
+ filterText = filterInput.getValue();
840
+ applyFilter();
841
+ logBox.focus();
842
+ });
843
+ // Enter/submit from filter input: apply filter and go back to log view
844
+ filterInput.on('submit', () => {
845
+ filterText = filterInput.getValue();
846
+ applyFilter();
847
+ logBox.focus();
848
+ });
849
+ // Escape from log box: close the popup
850
+ logBox.key(['escape', 'q'], closeLogs);
851
+ logPopup.key(['escape', 'q'], closeLogs);
852
+ logProcess.on('error', (err) => {
853
+ logBox.add(`{red-fg}Error: ${err.message}{/red-fg}`);
854
+ screen.render();
855
+ });
856
+ logProcess.on('exit', () => {
857
+ logBox.add('{gray-fg}--- Log stream ended ---{/gray-fg}');
858
+ screen.render();
859
+ });
860
+ activePopup = logPopup;
861
+ logBox.focus();
862
+ screen.render();
863
+ }
864
+ // --- Key bindings: containers tab ---
865
+ tableList.key(['s'], () => { void runAction(startContainer, 'Starting'); });
866
+ tableList.key(['S'], () => { void runAction(stopContainer, 'Stopping'); });
867
+ tableList.key(['r'], () => { void runAction(restartContainer, 'Restarting'); });
868
+ tableList.key(['delete'], () => {
869
+ const c = selectedContainer();
870
+ if (c && c.state === 'exited') {
871
+ void runAction((id) => removeContainer(id, false), 'Removing');
872
+ }
873
+ });
874
+ tableList.key(['d'], () => { void showContainerDetails(); });
875
+ tableList.key(['l'], () => { openLogs(); });
876
+ tableList.key(['h'], () => { void openShell(); });
877
+ tableList.key(['R'], () => { closePopup(); void refresh(); });
878
+ // --- Key bindings: images tab ---
879
+ imagesList.key(['delete'], () => {
880
+ closePopup();
881
+ const img = selectedImage();
882
+ if (!img)
883
+ return;
884
+ const name = img.repository !== '<none>' ? `${img.repository}:${img.tag}` : img.id.slice(0, 12);
885
+ statusBar.setContent(` Deleting ${name}...`);
886
+ safeRender();
887
+ void removeImage(img.id).then(() => refreshImages()).catch((err) => {
888
+ const msg = err instanceof Error ? err.message : String(err);
889
+ flashError(msg);
890
+ });
891
+ });
892
+ imagesList.key(['d'], () => { showImageDetails(); });
893
+ imagesList.key(['R'], () => { closePopup(); void refreshImages(); });
894
+ // --- Key bindings: volumes tab ---
895
+ volumesList.key(['delete'], () => {
896
+ closePopup();
897
+ const v = selectedVolume();
898
+ if (!v)
899
+ return;
900
+ statusBar.setContent(` Deleting volume ${v.name}...`);
901
+ safeRender();
902
+ void removeVolume(v.name).then(() => refreshVolumes()).catch((err) => {
903
+ const msg = err instanceof Error ? err.message : String(err);
904
+ flashError(msg);
905
+ });
906
+ });
907
+ volumesList.key(['d'], () => { void showVolumeDetails(); });
908
+ volumesList.key(['R'], () => { closePopup(); void refreshVolumes(); });
909
+ // --- Global key bindings ---
910
+ screen.key(['q', 'escape'], () => {
911
+ if (popupOpen)
912
+ return; // let the popup handle it
913
+ if (refreshTimer)
914
+ clearInterval(refreshTimer);
915
+ screen.destroy();
916
+ process.exit(0);
917
+ });
918
+ // Tab switching: Alt+1..Alt+N and Alt+<shortcut letter>
919
+ for (let i = 0; i < TABS.length; i++) {
920
+ screen.key([`M-${i + 1}`], () => switchTab(i));
921
+ screen.key([`M-${TABS[i].shortcutKey}`], () => switchTab(i));
922
+ }
923
+ screen.on('resize', () => {
924
+ renderTabBar();
925
+ if (TABS[activeTab].id === 'containers') {
926
+ updateLayout();
927
+ renderTable();
928
+ renderDetail();
929
+ }
930
+ });
931
+ // Update status bar on tab switch
932
+ tableList.on('focus', () => { statusBar.setContent(STATUS_KEYS); safeRender(); });
933
+ imagesList.on('focus', () => { statusBar.setContent(IMAGES_STATUS_KEYS); safeRender(); });
934
+ volumesList.on('focus', () => { statusBar.setContent(VOLUMES_STATUS_KEYS); safeRender(); });
935
+ tableList.focus();
936
+ renderTabBar();
937
+ updateLayout();
938
+ void refresh();
939
+ refreshTimer = setInterval(() => { void refresh(); }, 5000);
940
+ return screen;
941
+ }
942
+ //# sourceMappingURL=dashboard.js.map