recomposable 1.0.1 → 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/README.md +63 -20
- package/index.js +305 -9
- package/lib/docker.js +118 -10
- package/lib/renderer.js +150 -18
- package/lib/state.js +11 -0
- package/package.json +3 -2
- package/screenshots/list-view.png +0 -0
- package/screenshots/logs-view.png +0 -0
package/README.md
CHANGED
|
@@ -1,19 +1,13 @@
|
|
|
1
|
-
```
|
|
2
|
-
__ __ _ _ _ _ ___
|
|
3
|
-
\ \ / /| || | /_\ | | | __| .
|
|
4
|
-
\ \/\/ / | __ |/ _ \| |__| _| ":"
|
|
5
|
-
\_/\_/ |_||_/_/ \_|____|___| ___:____ |"\/"|
|
|
6
|
-
,' `. \ /
|
|
7
|
-
docker compose manager | O \___/ |
|
|
8
|
-
~^~^~^~^~^~^~^~^~^~^~^~
|
|
9
|
-
```
|
|
10
|
-
|
|
11
1
|
# recomposable
|
|
12
2
|
|
|
13
3
|
A lightweight Docker Compose TUI manager with vim keybindings. Monitor service status, restart or rebuild containers, and tail logs — all from your terminal.
|
|
14
4
|
|
|
5
|
+
Eliminate switching between countless terminal tabs or windows to rebuild you docker compose containers.
|
|
6
|
+
|
|
15
7
|
Zero dependencies. Pure Node.js.
|
|
16
8
|
|
|
9
|
+

|
|
10
|
+
|
|
17
11
|
## Install
|
|
18
12
|
|
|
19
13
|
```bash
|
|
@@ -40,6 +34,23 @@ EOF
|
|
|
40
34
|
recomposable
|
|
41
35
|
```
|
|
42
36
|
|
|
37
|
+
## Features
|
|
38
|
+
|
|
39
|
+
- **Multi-file support** — manage services across multiple compose files, grouped by file
|
|
40
|
+
- **Live status** — polls container state, health, build and restart times
|
|
41
|
+
- **CPU/Memory monitoring** — live CPU% and memory usage per container, with configurable color thresholds
|
|
42
|
+
- **Port mappings** — shows published ports for each service
|
|
43
|
+
- **Log pattern scanning** — counts WRN/ERR (configurable) occurrences across all services
|
|
44
|
+
- **Inline log panel** — tail logs for the selected service without leaving the list view, with search (`/`)
|
|
45
|
+
- **Full log view** — scrollable full-screen log viewer with live auto-scroll and search (`/`, `n`/`N`)
|
|
46
|
+
- **Start / Stop / Restart / Rebuild** — full container lifecycle management per service
|
|
47
|
+
- **No cache mode** — toggle to force a full clean rebuild (`--no-cache` + `--force-recreate`), off by default
|
|
48
|
+
- **Vim keybindings** — navigate with `j`/`k`, `G`/`gg`, and more
|
|
49
|
+
|
|
50
|
+
## Full Log View
|
|
51
|
+
|
|
52
|
+

|
|
53
|
+
|
|
43
54
|
## Adding Compose Files
|
|
44
55
|
|
|
45
56
|
Create a `recomposable.json` file in your project root:
|
|
@@ -48,9 +59,7 @@ Create a `recomposable.json` file in your project root:
|
|
|
48
59
|
{
|
|
49
60
|
"composeFiles": [
|
|
50
61
|
"docker-compose.yml"
|
|
51
|
-
]
|
|
52
|
-
"pollInterval": 3000,
|
|
53
|
-
"logTailLines": 100
|
|
62
|
+
]
|
|
54
63
|
}
|
|
55
64
|
```
|
|
56
65
|
|
|
@@ -81,22 +90,52 @@ recomposable -f docker-compose.yml -f docker-compose.prod.yml
|
|
|
81
90
|
|---|---|---|
|
|
82
91
|
| `composeFiles` | `[]` | Array of docker-compose file paths (relative to `recomposable.json`) |
|
|
83
92
|
| `pollInterval` | `3000` | Status polling interval in milliseconds |
|
|
84
|
-
| `logTailLines` | `100` | Number of log lines to show when entering log view |
|
|
93
|
+
| `logTailLines` | `100` | Number of log lines to show when entering full log view |
|
|
94
|
+
| `logScanPatterns` | `["WRN]", "ERR]"]` | Patterns to count in container logs |
|
|
95
|
+
| `logScanLines` | `1000` | Number of log lines to scan for pattern counts |
|
|
96
|
+
| `logScanInterval` | `10000` | Pattern scanning interval in milliseconds |
|
|
97
|
+
| `bottomLogCount` | `10` | Number of log lines shown in the inline log panel |
|
|
98
|
+
| `statsInterval` | `5000` | CPU/memory polling interval in milliseconds |
|
|
99
|
+
| `statsBufferSize` | `6` | Number of samples for rolling average (e.g. 6 x 5s = 30s window) |
|
|
100
|
+
| `cpuWarnThreshold` | `50` | CPU % above which the column turns yellow |
|
|
101
|
+
| `cpuDangerThreshold` | `100` | CPU % above which the column turns red |
|
|
102
|
+
| `memWarnThreshold` | `512` | Memory in MB above which the column turns yellow |
|
|
103
|
+
| `memDangerThreshold` | `1024` | Memory in MB above which the column turns red |
|
|
85
104
|
|
|
86
105
|
## Keybindings
|
|
87
106
|
|
|
107
|
+
### List view
|
|
108
|
+
|
|
88
109
|
| Key | Action |
|
|
89
110
|
|---|---|
|
|
90
111
|
| `j` / `Down` | Move cursor down |
|
|
91
112
|
| `k` / `Up` | Move cursor up |
|
|
92
|
-
| `s` |
|
|
93
|
-
| `
|
|
94
|
-
| `
|
|
95
|
-
| `
|
|
113
|
+
| `s` | Start (if stopped) or restart (if running) |
|
|
114
|
+
| `p` | Stop selected service |
|
|
115
|
+
| `b` | Rebuild selected service (`up -d --build`) |
|
|
116
|
+
| `n` | Toggle no-cache mode (rebuild with `--no-cache` + `--force-recreate`) |
|
|
117
|
+
| `f` / `Enter` | Full-screen log view for selected service |
|
|
118
|
+
| `l` | Toggle inline log panel |
|
|
119
|
+
| `/` | Search in inline log panel |
|
|
96
120
|
| `G` | Jump to bottom |
|
|
97
121
|
| `gg` | Jump to top |
|
|
98
122
|
| `q` | Quit |
|
|
99
|
-
|
|
123
|
+
|
|
124
|
+
### Full log view
|
|
125
|
+
|
|
126
|
+
| Key | Action |
|
|
127
|
+
|---|---|
|
|
128
|
+
| `j` / `Down` | Scroll down |
|
|
129
|
+
| `k` / `Up` | Scroll up |
|
|
130
|
+
| `Ctrl+D` | Page down |
|
|
131
|
+
| `Ctrl+U` | Page up |
|
|
132
|
+
| `G` | Jump to bottom (live mode) |
|
|
133
|
+
| `gg` | Jump to top |
|
|
134
|
+
| `/` | Search logs |
|
|
135
|
+
| `n` | Next search match |
|
|
136
|
+
| `N` | Previous search match |
|
|
137
|
+
| `Esc` / `f` | Exit log view |
|
|
138
|
+
| `q` | Quit |
|
|
100
139
|
|
|
101
140
|
## Status Icons
|
|
102
141
|
|
|
@@ -104,9 +143,13 @@ recomposable -f docker-compose.yml -f docker-compose.prod.yml
|
|
|
104
143
|
|---|---|
|
|
105
144
|
| Green circle | Running (healthy) |
|
|
106
145
|
| Red circle | Running (unhealthy) |
|
|
107
|
-
| Yellow circle | Rebuilding / Restarting |
|
|
146
|
+
| Yellow circle | Rebuilding / Restarting / Starting / Stopping |
|
|
108
147
|
| Gray circle | Stopped |
|
|
109
148
|
|
|
149
|
+
## Todo
|
|
150
|
+
|
|
151
|
+
- Log search should search all container logs, not only the lines currently tailed by the tool
|
|
152
|
+
|
|
110
153
|
## Requirements
|
|
111
154
|
|
|
112
155
|
- Node.js >= 16
|
package/index.js
CHANGED
|
@@ -3,14 +3,14 @@
|
|
|
3
3
|
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const path = require('path');
|
|
6
|
-
const { listServices, getStatuses, rebuildService, restartService, tailLogs, getContainerId, tailContainerLogs, fetchContainerLogs } = require('./lib/docker');
|
|
6
|
+
const { listServices, getStatuses, rebuildService, restartService, stopService, startService, tailLogs, getContainerId, tailContainerLogs, fetchContainerLogs, fetchContainerStats, parseStatsLine } = require('./lib/docker');
|
|
7
7
|
const { MODE, createState, statusKey, buildFlatList, moveCursor, selectedEntry } = require('./lib/state');
|
|
8
8
|
const { clearScreen, showCursor, renderListView, renderLogView } = require('./lib/renderer');
|
|
9
9
|
|
|
10
10
|
// --- Config ---
|
|
11
11
|
|
|
12
12
|
function loadConfig() {
|
|
13
|
-
const defaults = { composeFiles: [], pollInterval: 3000, logTailLines: 100, logScanPatterns: ['WRN]', 'ERR]'], logScanLines: 1000, logScanInterval: 10000 };
|
|
13
|
+
const defaults = { composeFiles: [], pollInterval: 3000, logTailLines: 100, logScanPatterns: ['WRN]', 'ERR]'], logScanLines: 1000, logScanInterval: 10000, statsInterval: 5000, statsBufferSize: 6, bottomLogCount: 10, cpuWarnThreshold: 50, cpuDangerThreshold: 100, memWarnThreshold: 512, memDangerThreshold: 1024 };
|
|
14
14
|
|
|
15
15
|
// Load from recomposable.json in current working directory
|
|
16
16
|
const configPath = path.join(process.cwd(), 'recomposable.json');
|
|
@@ -127,6 +127,80 @@ function pollLogCounts(state) {
|
|
|
127
127
|
}
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
// --- Stats Polling ---
|
|
131
|
+
|
|
132
|
+
let statsPollActive = false;
|
|
133
|
+
|
|
134
|
+
function pollContainerStats(state) {
|
|
135
|
+
if (statsPollActive) return;
|
|
136
|
+
|
|
137
|
+
const idToKey = new Map();
|
|
138
|
+
for (const group of state.groups) {
|
|
139
|
+
if (group.error) continue;
|
|
140
|
+
for (const service of group.services) {
|
|
141
|
+
const sk = statusKey(group.file, service);
|
|
142
|
+
const st = state.statuses.get(sk);
|
|
143
|
+
if (!st || st.state !== 'running' || !st.id) continue;
|
|
144
|
+
idToKey.set(st.id, sk);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const ids = [...idToKey.keys()];
|
|
149
|
+
if (ids.length === 0) return;
|
|
150
|
+
|
|
151
|
+
statsPollActive = true;
|
|
152
|
+
const child = fetchContainerStats(ids);
|
|
153
|
+
let output = '';
|
|
154
|
+
child.stdout.on('data', (d) => { output += d.toString(); });
|
|
155
|
+
child.stderr.on('data', () => {});
|
|
156
|
+
child.on('close', () => {
|
|
157
|
+
statsPollActive = false;
|
|
158
|
+
const bufferSize = state.config.statsBufferSize || 6;
|
|
159
|
+
|
|
160
|
+
for (const line of output.trim().split('\n')) {
|
|
161
|
+
if (!line.trim()) continue;
|
|
162
|
+
const parsed = parseStatsLine(line);
|
|
163
|
+
if (!parsed) continue;
|
|
164
|
+
|
|
165
|
+
// Find the statusKey for this container ID
|
|
166
|
+
let sk = null;
|
|
167
|
+
for (const [id, key] of idToKey) {
|
|
168
|
+
if (parsed.id.startsWith(id) || id.startsWith(parsed.id)) {
|
|
169
|
+
sk = key;
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (!sk) continue;
|
|
174
|
+
|
|
175
|
+
// Update circular buffer
|
|
176
|
+
if (!state.containerStatsHistory.has(sk)) {
|
|
177
|
+
state.containerStatsHistory.set(sk, { cpu: new Array(bufferSize).fill(0), mem: new Array(bufferSize).fill(0), idx: 0, count: 0 });
|
|
178
|
+
}
|
|
179
|
+
const hist = state.containerStatsHistory.get(sk);
|
|
180
|
+
hist.cpu[hist.idx] = parsed.cpuPercent;
|
|
181
|
+
hist.mem[hist.idx] = parsed.memUsageBytes;
|
|
182
|
+
hist.idx = (hist.idx + 1) % bufferSize;
|
|
183
|
+
hist.count = Math.min(hist.count + 1, bufferSize);
|
|
184
|
+
|
|
185
|
+
// Compute rolling average
|
|
186
|
+
let cpuSum = 0, memSum = 0;
|
|
187
|
+
for (let i = 0; i < hist.count; i++) {
|
|
188
|
+
cpuSum += hist.cpu[i];
|
|
189
|
+
memSum += hist.mem[i];
|
|
190
|
+
}
|
|
191
|
+
state.containerStats.set(sk, {
|
|
192
|
+
cpuPercent: cpuSum / hist.count,
|
|
193
|
+
memUsageBytes: memSum / hist.count,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (state.mode === MODE.LIST) throttledRender(state);
|
|
198
|
+
});
|
|
199
|
+
child.on('error', () => {
|
|
200
|
+
statsPollActive = false;
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
130
204
|
// --- Rendering ---
|
|
131
205
|
|
|
132
206
|
function render(state) {
|
|
@@ -173,6 +247,10 @@ function updateSelectedLogs(state) {
|
|
|
173
247
|
// Same container already selected, nothing to do
|
|
174
248
|
if (state.selectedLogKey === sk) return;
|
|
175
249
|
|
|
250
|
+
// Clear bottom search when switching services
|
|
251
|
+
state.bottomSearchQuery = '';
|
|
252
|
+
state.bottomSearchActive = false;
|
|
253
|
+
|
|
176
254
|
// Cancel any pending debounced log fetch
|
|
177
255
|
if (logFetchTimer) {
|
|
178
256
|
clearTimeout(logFetchTimer);
|
|
@@ -221,7 +299,7 @@ function doRebuild(state) {
|
|
|
221
299
|
state.bottomLogTails.delete(sk);
|
|
222
300
|
}
|
|
223
301
|
|
|
224
|
-
const child = rebuildService(entry.file, entry.service);
|
|
302
|
+
const child = rebuildService(entry.file, entry.service, { noCache: state.noCache });
|
|
225
303
|
state.rebuilding.set(sk, child);
|
|
226
304
|
|
|
227
305
|
state.bottomLogLines.set(sk, { action: 'rebuilding', service: entry.service, lines: [] });
|
|
@@ -236,7 +314,8 @@ function doRebuild(state) {
|
|
|
236
314
|
const newLines = parts.filter(l => l.trim().length > 0).map(stripAnsi).filter(Boolean);
|
|
237
315
|
if (newLines.length === 0) return;
|
|
238
316
|
info.lines.push(...newLines);
|
|
239
|
-
|
|
317
|
+
const maxLines = state.config.bottomLogCount || 10;
|
|
318
|
+
if (info.lines.length > maxLines) info.lines = info.lines.slice(-maxLines);
|
|
240
319
|
if (state.mode === MODE.LIST) throttledRender(state);
|
|
241
320
|
};
|
|
242
321
|
|
|
@@ -246,6 +325,8 @@ function doRebuild(state) {
|
|
|
246
325
|
|
|
247
326
|
child.on('close', () => {
|
|
248
327
|
state.rebuilding.delete(sk);
|
|
328
|
+
state.containerStatsHistory.delete(sk);
|
|
329
|
+
state.containerStats.delete(sk);
|
|
249
330
|
pollStatuses(state);
|
|
250
331
|
|
|
251
332
|
// Show container application logs after rebuild+start
|
|
@@ -271,7 +352,8 @@ function startBottomLogTail(state, sk, file, service) {
|
|
|
271
352
|
const containerId = getContainerId(file, service);
|
|
272
353
|
if (!containerId) return;
|
|
273
354
|
|
|
274
|
-
const
|
|
355
|
+
const maxLines = state.config.bottomLogCount || 10;
|
|
356
|
+
const logChild = tailContainerLogs(containerId, maxLines);
|
|
275
357
|
state.bottomLogTails.set(sk, logChild);
|
|
276
358
|
|
|
277
359
|
let buf = '';
|
|
@@ -284,7 +366,7 @@ function startBottomLogTail(state, sk, file, service) {
|
|
|
284
366
|
const newLines = parts.filter(l => l.trim().length > 0).map(stripAnsi).filter(Boolean);
|
|
285
367
|
if (newLines.length === 0) return;
|
|
286
368
|
info.lines.push(...newLines);
|
|
287
|
-
if (info.lines.length >
|
|
369
|
+
if (info.lines.length > maxLines) info.lines = info.lines.slice(-maxLines);
|
|
288
370
|
if (state.mode === MODE.LIST) throttledRender(state);
|
|
289
371
|
};
|
|
290
372
|
|
|
@@ -313,6 +395,8 @@ function doRestart(state) {
|
|
|
313
395
|
|
|
314
396
|
child.on('close', () => {
|
|
315
397
|
state.restarting.delete(sk);
|
|
398
|
+
state.containerStatsHistory.delete(sk);
|
|
399
|
+
state.containerStats.delete(sk);
|
|
316
400
|
pollStatuses(state);
|
|
317
401
|
|
|
318
402
|
// Show container application logs after restart
|
|
@@ -327,6 +411,67 @@ function doRestart(state) {
|
|
|
327
411
|
});
|
|
328
412
|
}
|
|
329
413
|
|
|
414
|
+
function doStop(state) {
|
|
415
|
+
const entry = selectedEntry(state);
|
|
416
|
+
if (!entry) return;
|
|
417
|
+
|
|
418
|
+
const sk = statusKey(entry.file, entry.service);
|
|
419
|
+
if (state.stopping.has(sk) || state.rebuilding.has(sk) || state.restarting.has(sk)) return;
|
|
420
|
+
|
|
421
|
+
// Only stop running containers
|
|
422
|
+
const st = state.statuses.get(sk);
|
|
423
|
+
if (!st || st.state !== 'running') return;
|
|
424
|
+
|
|
425
|
+
// Kill any existing log tail for this service
|
|
426
|
+
if (state.bottomLogTails.has(sk)) {
|
|
427
|
+
state.bottomLogTails.get(sk).kill('SIGTERM');
|
|
428
|
+
state.bottomLogTails.delete(sk);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const child = stopService(entry.file, entry.service);
|
|
432
|
+
state.stopping.set(sk, child);
|
|
433
|
+
state.bottomLogLines.set(sk, { action: 'stopping', service: entry.service, lines: [] });
|
|
434
|
+
render(state);
|
|
435
|
+
|
|
436
|
+
child.on('close', () => {
|
|
437
|
+
state.stopping.delete(sk);
|
|
438
|
+
state.bottomLogLines.delete(sk);
|
|
439
|
+
pollStatuses(state);
|
|
440
|
+
if (state.mode === MODE.LIST) render(state);
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function doStart(state) {
|
|
445
|
+
const entry = selectedEntry(state);
|
|
446
|
+
if (!entry) return;
|
|
447
|
+
|
|
448
|
+
const sk = statusKey(entry.file, entry.service);
|
|
449
|
+
if (state.starting.has(sk) || state.rebuilding.has(sk) || state.restarting.has(sk) || state.stopping.has(sk)) return;
|
|
450
|
+
|
|
451
|
+
// Only start stopped/exited containers
|
|
452
|
+
const st = state.statuses.get(sk);
|
|
453
|
+
if (st && st.state === 'running') return;
|
|
454
|
+
|
|
455
|
+
const child = startService(entry.file, entry.service);
|
|
456
|
+
state.starting.set(sk, child);
|
|
457
|
+
state.bottomLogLines.set(sk, { action: 'starting', service: entry.service, lines: [] });
|
|
458
|
+
render(state);
|
|
459
|
+
|
|
460
|
+
child.on('close', () => {
|
|
461
|
+
state.starting.delete(sk);
|
|
462
|
+
pollStatuses(state);
|
|
463
|
+
|
|
464
|
+
const info = state.bottomLogLines.get(sk);
|
|
465
|
+
if (info) {
|
|
466
|
+
info.action = 'started';
|
|
467
|
+
info.lines = [];
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
startBottomLogTail(state, sk, entry.file, entry.service);
|
|
471
|
+
if (state.mode === MODE.LIST) render(state);
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
|
|
330
475
|
function enterLogs(state) {
|
|
331
476
|
const entry = selectedEntry(state);
|
|
332
477
|
if (!entry) return;
|
|
@@ -340,6 +485,10 @@ function enterLogs(state) {
|
|
|
340
485
|
state.logLines = [];
|
|
341
486
|
state.logScrollOffset = 0;
|
|
342
487
|
state.logAutoScroll = true;
|
|
488
|
+
state.logSearchQuery = '';
|
|
489
|
+
state.logSearchActive = false;
|
|
490
|
+
state.logSearchMatches = [];
|
|
491
|
+
state.logSearchMatchIdx = -1;
|
|
343
492
|
|
|
344
493
|
const child = tailLogs(entry.file, entry.service, state.config.logTailLines);
|
|
345
494
|
state.logChild = child;
|
|
@@ -388,6 +537,51 @@ function exitLogs(state) {
|
|
|
388
537
|
render(state);
|
|
389
538
|
}
|
|
390
539
|
|
|
540
|
+
// --- Log Search ---
|
|
541
|
+
|
|
542
|
+
function executeLogSearch(state) {
|
|
543
|
+
const query = state.logSearchQuery;
|
|
544
|
+
state.logSearchMatches = [];
|
|
545
|
+
state.logSearchMatchIdx = -1;
|
|
546
|
+
if (!query) return;
|
|
547
|
+
|
|
548
|
+
const lowerQuery = query.toLowerCase();
|
|
549
|
+
for (let i = 0; i < state.logLines.length; i++) {
|
|
550
|
+
if (state.logLines[i].toLowerCase().includes(lowerQuery)) {
|
|
551
|
+
state.logSearchMatches.push(i);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (state.logSearchMatches.length > 0) {
|
|
556
|
+
state.logSearchMatchIdx = 0;
|
|
557
|
+
scrollToLogLine(state, state.logSearchMatches[0]);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function scrollToLogLine(state, lineIdx) {
|
|
562
|
+
const { rows = 24 } = process.stdout;
|
|
563
|
+
const headerHeight = 9; // logo + separator + legend + info line
|
|
564
|
+
const availableRows = Math.max(1, rows - headerHeight);
|
|
565
|
+
const totalLines = state.logLines.length;
|
|
566
|
+
|
|
567
|
+
// logScrollOffset is lines from bottom
|
|
568
|
+
state.logScrollOffset = Math.max(0, totalLines - lineIdx - Math.floor(availableRows / 2));
|
|
569
|
+
state.logAutoScroll = state.logScrollOffset === 0;
|
|
570
|
+
render(state);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function jumpToNextMatch(state) {
|
|
574
|
+
if (state.logSearchMatches.length === 0) return;
|
|
575
|
+
state.logSearchMatchIdx = (state.logSearchMatchIdx + 1) % state.logSearchMatches.length;
|
|
576
|
+
scrollToLogLine(state, state.logSearchMatches[state.logSearchMatchIdx]);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function jumpToPrevMatch(state) {
|
|
580
|
+
if (state.logSearchMatches.length === 0) return;
|
|
581
|
+
state.logSearchMatchIdx = (state.logSearchMatchIdx - 1 + state.logSearchMatches.length) % state.logSearchMatches.length;
|
|
582
|
+
scrollToLogLine(state, state.logSearchMatches[state.logSearchMatchIdx]);
|
|
583
|
+
}
|
|
584
|
+
|
|
391
585
|
// --- Input Handling ---
|
|
392
586
|
|
|
393
587
|
function handleKeypress(state, key) {
|
|
@@ -398,6 +592,29 @@ function handleKeypress(state, key) {
|
|
|
398
592
|
}
|
|
399
593
|
|
|
400
594
|
if (state.mode === MODE.LOGS) {
|
|
595
|
+
// Search input mode — capture keypresses into the search query
|
|
596
|
+
if (state.logSearchActive) {
|
|
597
|
+
if (key === '\x1b') {
|
|
598
|
+
// ESC cancels search
|
|
599
|
+
state.logSearchActive = false;
|
|
600
|
+
state.logSearchQuery = '';
|
|
601
|
+
render(state);
|
|
602
|
+
} else if (key === '\r') {
|
|
603
|
+
// Enter executes search
|
|
604
|
+
state.logSearchActive = false;
|
|
605
|
+
executeLogSearch(state);
|
|
606
|
+
render(state);
|
|
607
|
+
} else if (key === '\x7f' || key === '\b') {
|
|
608
|
+
// Backspace
|
|
609
|
+
state.logSearchQuery = state.logSearchQuery.slice(0, -1);
|
|
610
|
+
render(state);
|
|
611
|
+
} else if (key.length === 1 && key >= ' ') {
|
|
612
|
+
state.logSearchQuery += key;
|
|
613
|
+
render(state);
|
|
614
|
+
}
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
|
|
401
618
|
const { rows = 24 } = process.stdout;
|
|
402
619
|
const pageSize = Math.max(1, Math.floor(rows / 2));
|
|
403
620
|
const maxOffset = Math.max(0, state.logLines.length - 1);
|
|
@@ -440,6 +657,36 @@ function handleKeypress(state, key) {
|
|
|
440
657
|
if (state.logScrollOffset === 0) state.logAutoScroll = true;
|
|
441
658
|
render(state);
|
|
442
659
|
break;
|
|
660
|
+
case '/':
|
|
661
|
+
state.logSearchActive = true;
|
|
662
|
+
state.logSearchQuery = '';
|
|
663
|
+
render(state);
|
|
664
|
+
break;
|
|
665
|
+
case 'n':
|
|
666
|
+
jumpToNextMatch(state);
|
|
667
|
+
break;
|
|
668
|
+
case 'N':
|
|
669
|
+
jumpToPrevMatch(state);
|
|
670
|
+
break;
|
|
671
|
+
}
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// LIST mode — bottom panel search input
|
|
676
|
+
if (state.bottomSearchActive) {
|
|
677
|
+
if (key === '\x1b') {
|
|
678
|
+
state.bottomSearchActive = false;
|
|
679
|
+
state.bottomSearchQuery = '';
|
|
680
|
+
render(state);
|
|
681
|
+
} else if (key === '\r') {
|
|
682
|
+
state.bottomSearchActive = false;
|
|
683
|
+
render(state);
|
|
684
|
+
} else if (key === '\x7f' || key === '\b') {
|
|
685
|
+
state.bottomSearchQuery = state.bottomSearchQuery.slice(0, -1);
|
|
686
|
+
render(state);
|
|
687
|
+
} else if (key.length === 1 && key >= ' ') {
|
|
688
|
+
state.bottomSearchQuery += key;
|
|
689
|
+
render(state);
|
|
443
690
|
}
|
|
444
691
|
return;
|
|
445
692
|
}
|
|
@@ -458,11 +705,28 @@ function handleKeypress(state, key) {
|
|
|
458
705
|
updateSelectedLogs(state);
|
|
459
706
|
render(state);
|
|
460
707
|
break;
|
|
461
|
-
case '
|
|
708
|
+
case 'b':
|
|
462
709
|
doRebuild(state);
|
|
463
710
|
break;
|
|
464
|
-
case 's':
|
|
465
|
-
|
|
711
|
+
case 's': {
|
|
712
|
+
const sEntry = selectedEntry(state);
|
|
713
|
+
if (sEntry) {
|
|
714
|
+
const sSk = statusKey(sEntry.file, sEntry.service);
|
|
715
|
+
const sSt = state.statuses.get(sSk);
|
|
716
|
+
if (sSt && sSt.state === 'running') {
|
|
717
|
+
doRestart(state);
|
|
718
|
+
} else {
|
|
719
|
+
doStart(state);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
break;
|
|
723
|
+
}
|
|
724
|
+
case 'p':
|
|
725
|
+
doStop(state);
|
|
726
|
+
break;
|
|
727
|
+
case 'n':
|
|
728
|
+
state.noCache = !state.noCache;
|
|
729
|
+
render(state);
|
|
466
730
|
break;
|
|
467
731
|
case 'f':
|
|
468
732
|
case '\r': // Enter
|
|
@@ -483,6 +747,13 @@ function handleKeypress(state, key) {
|
|
|
483
747
|
break;
|
|
484
748
|
case 'g': // gg handled via double-tap buffer below
|
|
485
749
|
break;
|
|
750
|
+
case '/':
|
|
751
|
+
if (state.showBottomLogs) {
|
|
752
|
+
state.bottomSearchActive = true;
|
|
753
|
+
state.bottomSearchQuery = '';
|
|
754
|
+
render(state);
|
|
755
|
+
}
|
|
756
|
+
break;
|
|
486
757
|
}
|
|
487
758
|
}
|
|
488
759
|
|
|
@@ -531,6 +802,12 @@ function createInputHandler(state) {
|
|
|
531
802
|
const ch = buf[0];
|
|
532
803
|
buf = buf.slice(1);
|
|
533
804
|
|
|
805
|
+
// In search input mode, send all chars directly
|
|
806
|
+
if (state.logSearchActive || state.bottomSearchActive) {
|
|
807
|
+
handleKeypress(state, ch);
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
810
|
+
|
|
534
811
|
// Handle gg (go to top)
|
|
535
812
|
if (ch === 'g') {
|
|
536
813
|
if (gPending) {
|
|
@@ -577,6 +854,14 @@ function cleanup(state) {
|
|
|
577
854
|
child.kill('SIGTERM');
|
|
578
855
|
}
|
|
579
856
|
state.restarting.clear();
|
|
857
|
+
for (const [, child] of state.stopping) {
|
|
858
|
+
child.kill('SIGTERM');
|
|
859
|
+
}
|
|
860
|
+
state.stopping.clear();
|
|
861
|
+
for (const [, child] of state.starting) {
|
|
862
|
+
child.kill('SIGTERM');
|
|
863
|
+
}
|
|
864
|
+
state.starting.clear();
|
|
580
865
|
for (const [, child] of state.bottomLogTails) {
|
|
581
866
|
child.kill('SIGTERM');
|
|
582
867
|
}
|
|
@@ -591,6 +876,9 @@ function cleanup(state) {
|
|
|
591
876
|
if (state.pollTimer) {
|
|
592
877
|
clearInterval(state.pollTimer);
|
|
593
878
|
}
|
|
879
|
+
if (state.statsTimer) {
|
|
880
|
+
clearInterval(state.statsTimer);
|
|
881
|
+
}
|
|
594
882
|
process.stdout.write('\x1b[r' + showCursor() + '\x1b[0m');
|
|
595
883
|
}
|
|
596
884
|
|
|
@@ -642,6 +930,14 @@ function main() {
|
|
|
642
930
|
}
|
|
643
931
|
}, config.logScanInterval || 10000);
|
|
644
932
|
|
|
933
|
+
// Stats polling loop
|
|
934
|
+
pollContainerStats(state);
|
|
935
|
+
state.statsTimer = setInterval(() => {
|
|
936
|
+
if (state.mode === MODE.LIST) {
|
|
937
|
+
pollContainerStats(state);
|
|
938
|
+
}
|
|
939
|
+
}, config.statsInterval || 5000);
|
|
940
|
+
|
|
645
941
|
// Terminal resize
|
|
646
942
|
process.stdout.on('resize', () => {
|
|
647
943
|
render(state);
|
package/lib/docker.js
CHANGED
|
@@ -27,10 +27,14 @@ function getStatuses(file) {
|
|
|
27
27
|
let containers;
|
|
28
28
|
|
|
29
29
|
// docker compose ps outputs NDJSON (one object per line) or a JSON array
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
try {
|
|
31
|
+
if (trimmed.startsWith('[')) {
|
|
32
|
+
containers = JSON.parse(trimmed);
|
|
33
|
+
} else {
|
|
34
|
+
containers = trimmed.split('\n').filter(Boolean).map(line => JSON.parse(line));
|
|
35
|
+
}
|
|
36
|
+
} catch {
|
|
37
|
+
return new Map();
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
const idToService = new Map();
|
|
@@ -41,7 +45,31 @@ function getStatuses(file) {
|
|
|
41
45
|
const health = (c.Health || '').toLowerCase();
|
|
42
46
|
const createdAt = c.CreatedAt || null;
|
|
43
47
|
const id = c.ID || null;
|
|
44
|
-
|
|
48
|
+
|
|
49
|
+
// Extract published ports
|
|
50
|
+
let ports = [];
|
|
51
|
+
if (Array.isArray(c.Publishers)) {
|
|
52
|
+
for (const p of c.Publishers) {
|
|
53
|
+
if (p.PublishedPort && p.PublishedPort > 0) {
|
|
54
|
+
ports.push({ published: p.PublishedPort, target: p.TargetPort });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
} else if (c.Ports) {
|
|
58
|
+
// Fallback: parse from Ports string like "0.0.0.0:3000->3000/tcp"
|
|
59
|
+
const portMatches = c.Ports.matchAll(/(\d+)->(\d+)/g);
|
|
60
|
+
for (const m of portMatches) {
|
|
61
|
+
ports.push({ published: parseInt(m[1], 10), target: parseInt(m[2], 10) });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// Deduplicate by published port
|
|
65
|
+
const seen = new Set();
|
|
66
|
+
ports = ports.filter(p => {
|
|
67
|
+
if (seen.has(p.published)) return false;
|
|
68
|
+
seen.add(p.published);
|
|
69
|
+
return true;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
statuses.set(name, { state, health, createdAt, startedAt: null, id: id || null, ports });
|
|
45
73
|
if (id) idToService.set(id, name);
|
|
46
74
|
}
|
|
47
75
|
|
|
@@ -72,13 +100,49 @@ function getStatuses(file) {
|
|
|
72
100
|
return statuses;
|
|
73
101
|
}
|
|
74
102
|
|
|
75
|
-
function rebuildService(file, service) {
|
|
103
|
+
function rebuildService(file, service, opts = {}) {
|
|
76
104
|
const cwd = path.dirname(path.resolve(file));
|
|
77
|
-
const
|
|
78
|
-
const
|
|
105
|
+
const resolvedFile = path.resolve(file);
|
|
106
|
+
const spawnOpts = {
|
|
79
107
|
cwd, stdio: ['ignore', 'pipe', 'pipe'], detached: false,
|
|
80
108
|
env: { ...process.env, BUILDKIT_PROGRESS: 'plain' },
|
|
81
|
-
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
if (opts.noCache) {
|
|
112
|
+
// Chain: build --no-cache then up --force-recreate (skip all caches)
|
|
113
|
+
// Use safe spawn (no shell) to avoid command injection
|
|
114
|
+
const { EventEmitter } = require('events');
|
|
115
|
+
const { PassThrough } = require('stream');
|
|
116
|
+
const emitter = new EventEmitter();
|
|
117
|
+
const stdout = new PassThrough();
|
|
118
|
+
const stderr = new PassThrough();
|
|
119
|
+
emitter.stdout = stdout;
|
|
120
|
+
emitter.stderr = stderr;
|
|
121
|
+
|
|
122
|
+
const buildChild = spawn('docker', ['compose', '-f', resolvedFile, 'build', '--no-cache', service], spawnOpts);
|
|
123
|
+
buildChild.stdout.pipe(stdout, { end: false });
|
|
124
|
+
buildChild.stderr.pipe(stderr, { end: false });
|
|
125
|
+
|
|
126
|
+
buildChild.on('close', (code) => {
|
|
127
|
+
if (code !== 0) {
|
|
128
|
+
stdout.end();
|
|
129
|
+
stderr.end();
|
|
130
|
+
emitter.emit('close', code);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const upChild = spawn('docker', ['compose', '-f', resolvedFile, 'up', '-d', '--force-recreate', service], spawnOpts);
|
|
134
|
+
upChild.stdout.pipe(stdout);
|
|
135
|
+
upChild.stderr.pipe(stderr);
|
|
136
|
+
upChild.on('close', (upCode) => emitter.emit('close', upCode));
|
|
137
|
+
emitter.kill = (sig) => upChild.kill(sig);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
emitter.kill = (sig) => buildChild.kill(sig);
|
|
141
|
+
return emitter;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const args = ['compose', '-f', resolvedFile, 'up', '-d', '--build', service];
|
|
145
|
+
const child = spawn('docker', args, spawnOpts);
|
|
82
146
|
return child;
|
|
83
147
|
}
|
|
84
148
|
|
|
@@ -120,4 +184,48 @@ function restartService(file, service) {
|
|
|
120
184
|
return child;
|
|
121
185
|
}
|
|
122
186
|
|
|
123
|
-
|
|
187
|
+
function stopService(file, service) {
|
|
188
|
+
const cwd = path.dirname(path.resolve(file));
|
|
189
|
+
const args = ['compose', '-f', path.resolve(file), 'stop', service];
|
|
190
|
+
const child = spawn('docker', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], detached: false });
|
|
191
|
+
return child;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function startService(file, service) {
|
|
195
|
+
const cwd = path.dirname(path.resolve(file));
|
|
196
|
+
const args = ['compose', '-f', path.resolve(file), 'start', service];
|
|
197
|
+
const child = spawn('docker', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], detached: false });
|
|
198
|
+
return child;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function fetchContainerStats(containerIds) {
|
|
202
|
+
const args = ['stats', '--no-stream', '--format', '{{json .}}', ...containerIds];
|
|
203
|
+
const child = spawn('docker', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
204
|
+
return child;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function parseMemString(str) {
|
|
208
|
+
if (!str) return 0;
|
|
209
|
+
const match = str.match(/^([\d.]+)\s*(B|KiB|MiB|GiB|TiB|kB|MB|GB|TB)$/i);
|
|
210
|
+
if (!match) return 0;
|
|
211
|
+
const val = parseFloat(match[1]);
|
|
212
|
+
const unit = match[2].toLowerCase();
|
|
213
|
+
const multipliers = { b: 1, kib: 1024, mib: 1024 * 1024, gib: 1024 * 1024 * 1024, tib: 1024 * 1024 * 1024 * 1024, kb: 1000, mb: 1e6, gb: 1e9, tb: 1e12 };
|
|
214
|
+
return val * (multipliers[unit] || 1);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function parseStatsLine(jsonStr) {
|
|
218
|
+
try {
|
|
219
|
+
const obj = JSON.parse(jsonStr);
|
|
220
|
+
const cpuPercent = parseFloat((obj.CPUPerc || '').replace('%', '')) || 0;
|
|
221
|
+
const memUsageStr = (obj.MemUsage || '').split('/')[0].trim();
|
|
222
|
+
const memUsageBytes = parseMemString(memUsageStr);
|
|
223
|
+
const id = obj.ID || '';
|
|
224
|
+
const name = obj.Name || '';
|
|
225
|
+
return { id, name, cpuPercent, memUsageBytes };
|
|
226
|
+
} catch {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
module.exports = { listServices, getStatuses, rebuildService, restartService, stopService, startService, tailLogs, getContainerId, tailContainerLogs, fetchContainerLogs, fetchContainerStats, parseStatsLine, parseMemString };
|
package/lib/renderer.js
CHANGED
|
@@ -76,8 +76,8 @@ function showCursor() {
|
|
|
76
76
|
return `${ESC}?25h`;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
function statusIcon(status, isRebuilding, isRestarting) {
|
|
80
|
-
if (isRebuilding || isRestarting) return `${FG_YELLOW}\u25CF${RESET}`;
|
|
79
|
+
function statusIcon(status, isRebuilding, isRestarting, isStopping, isStarting) {
|
|
80
|
+
if (isRebuilding || isRestarting || isStopping || isStarting) return `${FG_YELLOW}\u25CF${RESET}`;
|
|
81
81
|
if (!status) return `${FG_GRAY}\u25CB${RESET}`;
|
|
82
82
|
|
|
83
83
|
const { state, health } = status;
|
|
@@ -89,7 +89,9 @@ function statusIcon(status, isRebuilding, isRestarting) {
|
|
|
89
89
|
return `${FG_GRAY}\u25CB${RESET}`;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
function statusText(status, isRebuilding, isRestarting) {
|
|
92
|
+
function statusText(status, isRebuilding, isRestarting, isStopping, isStarting) {
|
|
93
|
+
if (isStopping) return `${FG_YELLOW}STOPPING...${RESET}`;
|
|
94
|
+
if (isStarting) return `${FG_YELLOW}STARTING...${RESET}`;
|
|
93
95
|
if (isRestarting) return `${FG_YELLOW}RESTARTING...${RESET}`;
|
|
94
96
|
if (isRebuilding) return `${FG_YELLOW}REBUILDING...${RESET}`;
|
|
95
97
|
if (!status) return `${FG_GRAY}stopped${RESET}`;
|
|
@@ -109,8 +111,15 @@ function statusText(status, isRebuilding, isRestarting) {
|
|
|
109
111
|
return `${DIM}${text}${RESET}`;
|
|
110
112
|
}
|
|
111
113
|
|
|
114
|
+
function formatMem(bytes) {
|
|
115
|
+
if (bytes <= 0) return '-';
|
|
116
|
+
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)}K`;
|
|
117
|
+
if (bytes < 1024 * 1024 * 1024) return `${Math.round(bytes / (1024 * 1024))}M`;
|
|
118
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}G`;
|
|
119
|
+
}
|
|
120
|
+
|
|
112
121
|
function renderLegend(opts = {}) {
|
|
113
|
-
const { logPanelActive = false, fullLogsActive = false, logsScrollMode = false } = opts;
|
|
122
|
+
const { logPanelActive = false, fullLogsActive = false, logsScrollMode = false, noCacheActive = false } = opts;
|
|
114
123
|
const item = (text, active) => {
|
|
115
124
|
if (active) return `${BG_HIGHLIGHT} ${text} ${RESET}`;
|
|
116
125
|
return `${DIM}${text}${RESET}`;
|
|
@@ -121,12 +130,16 @@ function renderLegend(opts = {}) {
|
|
|
121
130
|
item('[j/k] scroll', false),
|
|
122
131
|
item('[G] bottom', false),
|
|
123
132
|
item('[gg] top', false),
|
|
133
|
+
item('[/] search', false),
|
|
134
|
+
item('[n/N] next/prev', false),
|
|
124
135
|
item('[Q]uit', false),
|
|
125
136
|
].join(' ');
|
|
126
137
|
}
|
|
127
138
|
return [
|
|
128
|
-
item('[
|
|
129
|
-
item('[S]restart', false),
|
|
139
|
+
item('Re[B]uild', false),
|
|
140
|
+
item('[S]tart/restart', false),
|
|
141
|
+
item('Sto[P]', false),
|
|
142
|
+
item('[N]o cache', noCacheActive),
|
|
130
143
|
item('[F]ull logs', fullLogsActive),
|
|
131
144
|
item('[L]og panel', logPanelActive),
|
|
132
145
|
item('[Q]uit', false),
|
|
@@ -142,7 +155,7 @@ function renderListView(state) {
|
|
|
142
155
|
for (const line of LOGO) {
|
|
143
156
|
buf.push(line);
|
|
144
157
|
}
|
|
145
|
-
const help = renderLegend({ logPanelActive: state.showBottomLogs });
|
|
158
|
+
const help = renderLegend({ logPanelActive: state.showBottomLogs, noCacheActive: state.noCache });
|
|
146
159
|
buf.push(` ${FG_GRAY}${'─'.repeat(Math.max(0, columns - 2))}${RESET}`);
|
|
147
160
|
buf.push(` ${help}`);
|
|
148
161
|
|
|
@@ -157,10 +170,53 @@ function renderListView(state) {
|
|
|
157
170
|
const info = state.bottomLogLines.get(sk);
|
|
158
171
|
if (info) {
|
|
159
172
|
bottomBuf.push(` ${FG_GRAY}${'─'.repeat(Math.max(0, columns - 2))}${RESET}`);
|
|
160
|
-
const actionColor = info.action === 'rebuilding' || info.action === 'restarting' ? FG_YELLOW : FG_GREEN;
|
|
161
|
-
|
|
173
|
+
const actionColor = info.action === 'rebuilding' || info.action === 'restarting' || info.action === 'stopping' || info.action === 'starting' ? FG_YELLOW : FG_GREEN;
|
|
174
|
+
let headerLine = ` ${actionColor}${info.action} ${BOLD}${info.service}${RESET}`;
|
|
175
|
+
// Show search info
|
|
176
|
+
const bq = state.bottomSearchQuery || '';
|
|
177
|
+
if (bq && !state.bottomSearchActive) {
|
|
178
|
+
const matchCount = info.lines.filter(l => l.toLowerCase().includes(bq.toLowerCase())).length;
|
|
179
|
+
headerLine += matchCount > 0
|
|
180
|
+
? ` ${DIM}search: "${bq}" (${matchCount} match${matchCount !== 1 ? 'es' : ''})${RESET}`
|
|
181
|
+
: ` ${FG_RED}search: "${bq}" (no matches)${RESET}`;
|
|
182
|
+
}
|
|
183
|
+
bottomBuf.push(headerLine);
|
|
184
|
+
|
|
185
|
+
const searchQuery = bq && !state.bottomSearchActive ? bq : '';
|
|
186
|
+
|
|
162
187
|
for (const line of info.lines) {
|
|
163
|
-
|
|
188
|
+
let coloredLine = line.substring(0, columns - 4);
|
|
189
|
+
// Highlight search query
|
|
190
|
+
if (searchQuery) {
|
|
191
|
+
const lowerLine = coloredLine.toLowerCase();
|
|
192
|
+
const lowerQ = searchQuery.toLowerCase();
|
|
193
|
+
if (lowerLine.includes(lowerQ)) {
|
|
194
|
+
let result = '';
|
|
195
|
+
let pos = 0;
|
|
196
|
+
while (pos < coloredLine.length) {
|
|
197
|
+
const idx = lowerLine.indexOf(lowerQ, pos);
|
|
198
|
+
if (idx === -1) { result += coloredLine.substring(pos); break; }
|
|
199
|
+
result += coloredLine.substring(pos, idx);
|
|
200
|
+
result += `${REVERSE}${FG_YELLOW}${coloredLine.substring(idx, idx + searchQuery.length)}${RESET}${FG_GRAY}`;
|
|
201
|
+
pos = idx + searchQuery.length;
|
|
202
|
+
}
|
|
203
|
+
coloredLine = result;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
// Highlight log scan patterns
|
|
207
|
+
for (let pi = 0; pi < patterns.length; pi++) {
|
|
208
|
+
const p = patterns[pi];
|
|
209
|
+
if (coloredLine.includes(p)) {
|
|
210
|
+
const color = PATTERN_COLORS[pi % PATTERN_COLORS.length];
|
|
211
|
+
coloredLine = coloredLine.split(p).join(`${color}${p}${RESET}${FG_GRAY}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
bottomBuf.push(` ${FG_GRAY}${coloredLine}${RESET}`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Search prompt
|
|
218
|
+
if (state.bottomSearchActive) {
|
|
219
|
+
bottomBuf.push(`${BOLD}/${RESET}${state.bottomSearchQuery}${BOLD}_${RESET}`);
|
|
164
220
|
}
|
|
165
221
|
}
|
|
166
222
|
}
|
|
@@ -187,6 +243,7 @@ function renderListView(state) {
|
|
|
187
243
|
}
|
|
188
244
|
let colHeader = `${DIM} ${'SERVICE'.padEnd(24)} ${'STATUS'.padEnd(22)} ${'BUILT'.padEnd(12)} ${'RESTARTED'.padEnd(12)}`;
|
|
189
245
|
for (const p of patterns) colHeader += patternLabel(p).padStart(5) + ' ';
|
|
246
|
+
colHeader += ` ${'CPU/MEM'.padStart(16)} ${'PORTS'.padEnd(14)}`;
|
|
190
247
|
lines.push({ type: 'colheader', text: colHeader + RESET });
|
|
191
248
|
}
|
|
192
249
|
|
|
@@ -194,10 +251,42 @@ function renderListView(state) {
|
|
|
194
251
|
const st = state.statuses.get(sk);
|
|
195
252
|
const rebuilding = state.rebuilding.has(sk);
|
|
196
253
|
const restarting = state.restarting.has(sk);
|
|
197
|
-
const
|
|
198
|
-
const
|
|
254
|
+
const stopping = state.stopping.has(sk);
|
|
255
|
+
const starting = state.starting.has(sk);
|
|
256
|
+
const icon = statusIcon(st, rebuilding, restarting, stopping, starting);
|
|
257
|
+
const stext = statusText(st, rebuilding, restarting, stopping, starting);
|
|
199
258
|
const name = entry.service.padEnd(24);
|
|
200
259
|
const statusPadded = padVisible(stext, 22);
|
|
260
|
+
|
|
261
|
+
// CPU/MEM column
|
|
262
|
+
let cpuMemStr;
|
|
263
|
+
const stats = state.containerStats ? state.containerStats.get(sk) : null;
|
|
264
|
+
if (stats && st && st.state === 'running') {
|
|
265
|
+
const cpu = stats.cpuPercent;
|
|
266
|
+
const mem = stats.memUsageBytes;
|
|
267
|
+
const cpuWarn = state.config.cpuWarnThreshold || 50;
|
|
268
|
+
const cpuDanger = state.config.cpuDangerThreshold || 100;
|
|
269
|
+
const memWarn = (state.config.memWarnThreshold || 512) * 1024 * 1024;
|
|
270
|
+
const memDanger = (state.config.memDangerThreshold || 1024) * 1024 * 1024;
|
|
271
|
+
let color = DIM;
|
|
272
|
+
if (cpu > cpuDanger || mem > memDanger) color = FG_RED;
|
|
273
|
+
else if (cpu > cpuWarn || mem > memWarn) color = FG_YELLOW;
|
|
274
|
+
const cpuText = cpu.toFixed(1) + '%';
|
|
275
|
+
const memText = formatMem(mem);
|
|
276
|
+
cpuMemStr = padVisible(`${color}${cpuText} / ${memText}${RESET}`, 16);
|
|
277
|
+
} else {
|
|
278
|
+
cpuMemStr = padVisible(`${DIM}-${RESET}`, 16);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Ports column
|
|
282
|
+
let portsStr;
|
|
283
|
+
if (st && st.ports && st.ports.length > 0) {
|
|
284
|
+
const portsText = st.ports.map(p => p.published).join(' ');
|
|
285
|
+
portsStr = padVisible(`${DIM}${portsText}${RESET}`, 14);
|
|
286
|
+
} else {
|
|
287
|
+
portsStr = padVisible(`${DIM}-${RESET}`, 14);
|
|
288
|
+
}
|
|
289
|
+
|
|
201
290
|
const built = padVisible(relativeTime(st ? st.createdAt : null), 12);
|
|
202
291
|
const restarted = padVisible(relativeTime(st ? st.startedAt : null), 12);
|
|
203
292
|
const pointer = i === state.cursor ? `${REVERSE}` : '';
|
|
@@ -214,7 +303,7 @@ function renderListView(state) {
|
|
|
214
303
|
|
|
215
304
|
lines.push({
|
|
216
305
|
type: 'service',
|
|
217
|
-
text: `${pointer} ${icon} ${FG_WHITE}${name}${RESET} ${statusPadded} ${built} ${restarted}${countsStr}${endPointer}`,
|
|
306
|
+
text: `${pointer} ${icon} ${FG_WHITE}${name}${RESET} ${statusPadded} ${built} ${restarted}${countsStr} ${cpuMemStr} ${portsStr}${endPointer}`,
|
|
218
307
|
flatIdx: i,
|
|
219
308
|
});
|
|
220
309
|
}
|
|
@@ -270,6 +359,25 @@ function truncateLine(str, maxWidth) {
|
|
|
270
359
|
return str;
|
|
271
360
|
}
|
|
272
361
|
|
|
362
|
+
function highlightSearchInLine(line, query) {
|
|
363
|
+
if (!query) return line;
|
|
364
|
+
const lowerLine = line.toLowerCase();
|
|
365
|
+
const lowerQuery = query.toLowerCase();
|
|
366
|
+
let result = '';
|
|
367
|
+
let pos = 0;
|
|
368
|
+
while (pos < line.length) {
|
|
369
|
+
const idx = lowerLine.indexOf(lowerQuery, pos);
|
|
370
|
+
if (idx === -1) {
|
|
371
|
+
result += line.substring(pos);
|
|
372
|
+
break;
|
|
373
|
+
}
|
|
374
|
+
result += line.substring(pos, idx);
|
|
375
|
+
result += `${REVERSE}${FG_YELLOW}${line.substring(idx, idx + query.length)}${RESET}`;
|
|
376
|
+
pos = idx + query.length;
|
|
377
|
+
}
|
|
378
|
+
return result;
|
|
379
|
+
}
|
|
380
|
+
|
|
273
381
|
function renderLogView(state) {
|
|
274
382
|
const { columns = 80, rows = 24 } = process.stdout;
|
|
275
383
|
const buf = [];
|
|
@@ -284,13 +392,24 @@ function renderLogView(state) {
|
|
|
284
392
|
const serviceName = entry ? entry.service : '???';
|
|
285
393
|
const totalLines = state.logLines.length;
|
|
286
394
|
|
|
395
|
+
let statusLine = ` ${FG_GREEN}full logs ${BOLD}${serviceName}${RESET}`;
|
|
287
396
|
const scrollStatus = state.logAutoScroll
|
|
288
397
|
? `${FG_GREEN}live${RESET}`
|
|
289
398
|
: `${FG_YELLOW}paused ${DIM}line ${Math.max(1, totalLines - state.logScrollOffset)} / ${totalLines}${RESET}`;
|
|
290
|
-
|
|
399
|
+
statusLine += ` ${scrollStatus}`;
|
|
291
400
|
|
|
401
|
+
// Show search match count
|
|
402
|
+
if (state.logSearchQuery && state.logSearchMatches.length > 0) {
|
|
403
|
+
statusLine += ` ${DIM}match ${state.logSearchMatchIdx + 1}/${state.logSearchMatches.length}${RESET}`;
|
|
404
|
+
} else if (state.logSearchQuery && state.logSearchMatches.length === 0) {
|
|
405
|
+
statusLine += ` ${FG_RED}no matches${RESET}`;
|
|
406
|
+
}
|
|
407
|
+
buf.push(statusLine);
|
|
408
|
+
|
|
409
|
+
// Reserve 1 row for search prompt if active
|
|
410
|
+
const bottomReserved = state.logSearchActive ? 1 : 0;
|
|
292
411
|
const headerHeight = buf.length;
|
|
293
|
-
const availableRows = Math.max(1, rows - headerHeight);
|
|
412
|
+
const availableRows = Math.max(1, rows - headerHeight - bottomReserved);
|
|
294
413
|
|
|
295
414
|
let endLine;
|
|
296
415
|
if (state.logAutoScroll || state.logScrollOffset === 0) {
|
|
@@ -300,15 +419,28 @@ function renderLogView(state) {
|
|
|
300
419
|
}
|
|
301
420
|
const startLine = Math.max(0, endLine - availableRows);
|
|
302
421
|
|
|
422
|
+
const searchQuery = state.logSearchQuery || '';
|
|
423
|
+
const matchSet = searchQuery ? new Set(state.logSearchMatches) : null;
|
|
424
|
+
|
|
303
425
|
for (let i = startLine; i < endLine; i++) {
|
|
304
|
-
|
|
426
|
+
let line = state.logLines[i];
|
|
427
|
+
if (matchSet && matchSet.has(i)) {
|
|
428
|
+
line = highlightSearchInLine(line, searchQuery);
|
|
429
|
+
}
|
|
430
|
+
buf.push(truncateLine(line, columns));
|
|
305
431
|
}
|
|
306
432
|
|
|
307
|
-
// Pad to fill screen
|
|
308
|
-
|
|
433
|
+
// Pad to fill screen
|
|
434
|
+
const targetRows = rows - bottomReserved;
|
|
435
|
+
for (let i = buf.length; i < targetRows; i++) {
|
|
309
436
|
buf.push('');
|
|
310
437
|
}
|
|
311
438
|
|
|
439
|
+
// Search prompt at the bottom
|
|
440
|
+
if (state.logSearchActive) {
|
|
441
|
+
buf.push(`${BOLD}/${RESET}${state.logSearchQuery}${BOLD}_${RESET}`);
|
|
442
|
+
}
|
|
443
|
+
|
|
312
444
|
return buf.join('\n');
|
|
313
445
|
}
|
|
314
446
|
|
package/lib/state.js
CHANGED
|
@@ -11,8 +11,13 @@ function createState(config) {
|
|
|
11
11
|
statuses: new Map(), // "file::service" -> { state, health }
|
|
12
12
|
rebuilding: new Map(), // "file::service" -> childProcess
|
|
13
13
|
restarting: new Map(), // "file::service" -> childProcess
|
|
14
|
+
stopping: new Map(), // "file::service" -> childProcess
|
|
15
|
+
starting: new Map(), // "file::service" -> childProcess
|
|
16
|
+
containerStats: new Map(), // statusKey -> { cpuPercent, memUsageBytes }
|
|
17
|
+
containerStatsHistory: new Map(), // statusKey -> { cpu: number[], mem: number[], idx, count }
|
|
14
18
|
logChild: null,
|
|
15
19
|
scrollOffset: 0,
|
|
20
|
+
noCache: false,
|
|
16
21
|
showBottomLogs: true,
|
|
17
22
|
bottomLogLines: new Map(), // statusKey -> { action, service, lines: [] }
|
|
18
23
|
bottomLogTails: new Map(), // statusKey -> childProcess (log tail after restart)
|
|
@@ -21,6 +26,12 @@ function createState(config) {
|
|
|
21
26
|
logLines: [], // buffered log lines for full log view
|
|
22
27
|
logScrollOffset: 0, // lines from bottom (0 = at bottom)
|
|
23
28
|
logAutoScroll: true, // auto-scroll to bottom on new data
|
|
29
|
+
logSearchQuery: '',
|
|
30
|
+
logSearchActive: false, // true when typing search query
|
|
31
|
+
logSearchMatches: [], // array of line indices
|
|
32
|
+
logSearchMatchIdx: -1, // current match position
|
|
33
|
+
bottomSearchQuery: '',
|
|
34
|
+
bottomSearchActive: false, // true when typing search in bottom panel
|
|
24
35
|
config,
|
|
25
36
|
};
|
|
26
37
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "recomposable",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Docker Compose TUI manager with vim keybindings — monitor, restart, rebuild, and tail logs for your services",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"index.js",
|
|
11
|
-
"lib/"
|
|
11
|
+
"lib/",
|
|
12
|
+
"screenshots/"
|
|
12
13
|
],
|
|
13
14
|
"keywords": [
|
|
14
15
|
"docker",
|
|
Binary file
|
|
Binary file
|