@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.
- package/LICENSE +21 -0
- package/README.md +131 -0
- package/dist/bin/dockerino.d.ts +3 -0
- package/dist/bin/dockerino.d.ts.map +1 -0
- package/dist/bin/dockerino.js +3 -0
- package/dist/bin/dockerino.js.map +1 -0
- package/dist/src/docker/commands.d.ts +35 -0
- package/dist/src/docker/commands.d.ts.map +1 -0
- package/dist/src/docker/commands.js +166 -0
- package/dist/src/docker/commands.js.map +1 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +3 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/types.d.ts +36 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +2 -0
- package/dist/src/types.js.map +1 -0
- package/dist/src/ui/dashboard.d.ts +29 -0
- package/dist/src/ui/dashboard.d.ts.map +1 -0
- package/dist/src/ui/dashboard.js +942 -0
- package/dist/src/ui/dashboard.js.map +1 -0
- package/package.json +50 -0
|
@@ -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
|