recomposable 1.1.0 → 1.1.2

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/index.js DELETED
@@ -1,958 +0,0 @@
1
- #!/usr/bin/env node
2
- 'use strict';
3
-
4
- const fs = require('fs');
5
- const path = require('path');
6
- const { listServices, getStatuses, rebuildService, restartService, stopService, startService, tailLogs, getContainerId, tailContainerLogs, fetchContainerLogs, fetchContainerStats, parseStatsLine } = require('./lib/docker');
7
- const { MODE, createState, statusKey, buildFlatList, moveCursor, selectedEntry } = require('./lib/state');
8
- const { clearScreen, showCursor, renderListView, renderLogView } = require('./lib/renderer');
9
-
10
- // --- Config ---
11
-
12
- function loadConfig() {
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
-
15
- // Load from recomposable.json in current working directory
16
- const configPath = path.join(process.cwd(), 'recomposable.json');
17
- if (fs.existsSync(configPath)) {
18
- Object.assign(defaults, JSON.parse(fs.readFileSync(configPath, 'utf8')));
19
- }
20
-
21
- // CLI overrides: -f <file> can be repeated
22
- const args = process.argv.slice(2);
23
- const cliFiles = [];
24
- for (let i = 0; i < args.length; i++) {
25
- if (args[i] === '-f' && args[i + 1]) {
26
- cliFiles.push(args[++i]);
27
- }
28
- }
29
- if (cliFiles.length > 0) {
30
- defaults.composeFiles = cliFiles;
31
- }
32
-
33
- if (defaults.composeFiles.length === 0) {
34
- process.stderr.write('No compose files configured. Add them to recomposable.json or pass -f <file>.\n');
35
- process.exit(1);
36
- }
37
-
38
- return defaults;
39
- }
40
-
41
- // --- Service Discovery ---
42
-
43
- function discoverServices(config) {
44
- const groups = [];
45
- for (const file of config.composeFiles) {
46
- const resolved = path.resolve(file);
47
- const label = path.basename(file, path.extname(file)).replace(/^docker-compose\.?/, '') || path.basename(file);
48
- let services = [];
49
- let error = null;
50
- try {
51
- services = listServices(resolved);
52
- } catch (e) {
53
- error = e.message.split('\n')[0].substring(0, 60);
54
- }
55
- groups.push({ file: resolved, label, services, error });
56
- }
57
- return groups;
58
- }
59
-
60
- // --- Status Polling ---
61
-
62
- function pollStatuses(state) {
63
- for (const group of state.groups) {
64
- if (group.error) continue;
65
- const statuses = getStatuses(group.file);
66
- for (const [svc, st] of statuses) {
67
- state.statuses.set(statusKey(group.file, svc), st);
68
- }
69
- }
70
- }
71
-
72
- // --- Log Pattern Scanning ---
73
-
74
- let logScanActive = false;
75
-
76
- function pollLogCounts(state) {
77
- if (logScanActive) return;
78
- const scanPatterns = state.config.logScanPatterns || [];
79
- if (scanPatterns.length === 0) return;
80
- const tailLines = state.config.logScanLines || 1000;
81
-
82
- const toScan = [];
83
- for (const group of state.groups) {
84
- if (group.error) continue;
85
- for (const service of group.services) {
86
- const sk = statusKey(group.file, service);
87
- const st = state.statuses.get(sk);
88
- if (!st || st.state !== 'running' || !st.id) continue;
89
- toScan.push({ sk, containerId: st.id });
90
- }
91
- }
92
-
93
- if (toScan.length === 0) return;
94
- logScanActive = true;
95
- let remaining = toScan.length;
96
-
97
- for (const { sk, containerId } of toScan) {
98
- const child = fetchContainerLogs(containerId, tailLines);
99
- let output = '';
100
- child.stdout.on('data', (d) => { output += d.toString(); });
101
- child.stderr.on('data', (d) => { output += d.toString(); });
102
- child.on('close', () => {
103
- const counts = new Map();
104
- for (const pattern of scanPatterns) {
105
- let count = 0;
106
- let idx = 0;
107
- while ((idx = output.indexOf(pattern, idx)) !== -1) {
108
- count++;
109
- idx += pattern.length;
110
- }
111
- counts.set(pattern, count);
112
- }
113
- state.logCounts.set(sk, counts);
114
- remaining--;
115
- if (remaining === 0) {
116
- logScanActive = false;
117
- if (state.mode === MODE.LIST) throttledRender(state);
118
- }
119
- });
120
- child.on('error', () => {
121
- remaining--;
122
- if (remaining === 0) {
123
- logScanActive = false;
124
- if (state.mode === MODE.LIST) throttledRender(state);
125
- }
126
- });
127
- }
128
- }
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
-
204
- // --- Rendering ---
205
-
206
- function render(state) {
207
- let output = clearScreen();
208
- if (state.mode === MODE.LIST) {
209
- output += renderListView(state);
210
- } else if (state.mode === MODE.LOGS) {
211
- output += renderLogView(state);
212
- }
213
- process.stdout.write(output);
214
- }
215
-
216
- function stripAnsi(str) {
217
- return str.replace(/\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[^[\]]/g, '');
218
- }
219
-
220
- let lastRenderTime = 0;
221
- let pendingRender = null;
222
- let logFetchTimer = null;
223
-
224
- function throttledRender(state) {
225
- const now = Date.now();
226
- const elapsed = now - lastRenderTime;
227
- if (elapsed >= 150) {
228
- lastRenderTime = now;
229
- render(state);
230
- } else if (!pendingRender) {
231
- pendingRender = setTimeout(() => {
232
- pendingRender = null;
233
- lastRenderTime = Date.now();
234
- render(state);
235
- }, 150 - elapsed);
236
- }
237
- }
238
-
239
- // --- Actions ---
240
-
241
- function updateSelectedLogs(state) {
242
- const entry = selectedEntry(state);
243
- if (!entry) return;
244
-
245
- const sk = statusKey(entry.file, entry.service);
246
-
247
- // Same container already selected, nothing to do
248
- if (state.selectedLogKey === sk) return;
249
-
250
- // Clear bottom search when switching services
251
- state.bottomSearchQuery = '';
252
- state.bottomSearchActive = false;
253
-
254
- // Cancel any pending debounced log fetch
255
- if (logFetchTimer) {
256
- clearTimeout(logFetchTimer);
257
- logFetchTimer = null;
258
- }
259
-
260
- // Clean up previous selected container's passive log tail
261
- if (state.selectedLogKey) {
262
- const oldInfo = state.bottomLogLines.get(state.selectedLogKey);
263
- if (oldInfo && (oldInfo.action === 'logs' || oldInfo.action === 'started')) {
264
- if (!state.rebuilding.has(state.selectedLogKey) && !state.restarting.has(state.selectedLogKey)) {
265
- state.bottomLogLines.delete(state.selectedLogKey);
266
- if (state.bottomLogTails.has(state.selectedLogKey)) {
267
- state.bottomLogTails.get(state.selectedLogKey).kill('SIGTERM');
268
- state.bottomLogTails.delete(state.selectedLogKey);
269
- }
270
- }
271
- }
272
- }
273
-
274
- state.selectedLogKey = sk;
275
-
276
- // If this container already has active action logs (rebuild/restart/started), keep those
277
- if (state.bottomLogLines.has(sk)) return;
278
-
279
- // Set up empty log entry immediately so the UI shows the container name
280
- state.bottomLogLines.set(sk, { action: 'logs', service: entry.service, lines: [] });
281
-
282
- // Debounce the expensive log fetch (getContainerId is a blocking execFileSync)
283
- logFetchTimer = setTimeout(() => {
284
- logFetchTimer = null;
285
- startBottomLogTail(state, sk, entry.file, entry.service);
286
- }, 500);
287
- }
288
-
289
- function doRebuild(state) {
290
- const entry = selectedEntry(state);
291
- if (!entry) return;
292
-
293
- const sk = statusKey(entry.file, entry.service);
294
- if (state.rebuilding.has(sk)) return;
295
-
296
- // Kill any existing startup log tail for this service
297
- if (state.bottomLogTails.has(sk)) {
298
- state.bottomLogTails.get(sk).kill('SIGTERM');
299
- state.bottomLogTails.delete(sk);
300
- }
301
-
302
- const child = rebuildService(entry.file, entry.service, { noCache: state.noCache });
303
- state.rebuilding.set(sk, child);
304
-
305
- state.bottomLogLines.set(sk, { action: 'rebuilding', service: entry.service, lines: [] });
306
-
307
- let lineBuf = '';
308
- const onData = (data) => {
309
- const info = state.bottomLogLines.get(sk);
310
- if (!info) return;
311
- lineBuf += data.toString();
312
- const parts = lineBuf.split(/\r?\n|\r/);
313
- lineBuf = parts.pop();
314
- const newLines = parts.filter(l => l.trim().length > 0).map(stripAnsi).filter(Boolean);
315
- if (newLines.length === 0) return;
316
- info.lines.push(...newLines);
317
- const maxLines = state.config.bottomLogCount || 10;
318
- if (info.lines.length > maxLines) info.lines = info.lines.slice(-maxLines);
319
- if (state.mode === MODE.LIST) throttledRender(state);
320
- };
321
-
322
- child.stdout.on('data', onData);
323
- child.stderr.on('data', onData);
324
- render(state);
325
-
326
- child.on('close', () => {
327
- state.rebuilding.delete(sk);
328
- state.containerStatsHistory.delete(sk);
329
- state.containerStats.delete(sk);
330
- pollStatuses(state);
331
-
332
- // Show container application logs after rebuild+start
333
- const info = state.bottomLogLines.get(sk);
334
- if (info) {
335
- info.action = 'started';
336
- info.lines = [];
337
- }
338
-
339
- startBottomLogTail(state, sk, entry.file, entry.service);
340
- if (state.mode === MODE.LIST) render(state);
341
- });
342
- }
343
-
344
- function startBottomLogTail(state, sk, file, service) {
345
- // Kill any existing tail for this service
346
- if (state.bottomLogTails.has(sk)) {
347
- state.bottomLogTails.get(sk).kill('SIGTERM');
348
- state.bottomLogTails.delete(sk);
349
- }
350
-
351
- // Get container ID and use docker logs directly (avoids compose buffering)
352
- const containerId = getContainerId(file, service);
353
- if (!containerId) return;
354
-
355
- const maxLines = state.config.bottomLogCount || 10;
356
- const logChild = tailContainerLogs(containerId, maxLines);
357
- state.bottomLogTails.set(sk, logChild);
358
-
359
- let buf = '';
360
- const onData = (data) => {
361
- const info = state.bottomLogLines.get(sk);
362
- if (!info) return;
363
- buf += data.toString();
364
- const parts = buf.split(/\r?\n|\r/);
365
- buf = parts.pop();
366
- const newLines = parts.filter(l => l.trim().length > 0).map(stripAnsi).filter(Boolean);
367
- if (newLines.length === 0) return;
368
- info.lines.push(...newLines);
369
- if (info.lines.length > maxLines) info.lines = info.lines.slice(-maxLines);
370
- if (state.mode === MODE.LIST) throttledRender(state);
371
- };
372
-
373
- logChild.stdout.on('data', onData);
374
- logChild.stderr.on('data', onData);
375
- }
376
-
377
- function doRestart(state) {
378
- const entry = selectedEntry(state);
379
- if (!entry) return;
380
-
381
- const sk = statusKey(entry.file, entry.service);
382
- if (state.restarting.has(sk) || state.rebuilding.has(sk)) return;
383
-
384
- // Kill any existing startup log tail for this service
385
- if (state.bottomLogTails.has(sk)) {
386
- state.bottomLogTails.get(sk).kill('SIGTERM');
387
- state.bottomLogTails.delete(sk);
388
- }
389
-
390
- const child = restartService(entry.file, entry.service);
391
- state.restarting.set(sk, child);
392
-
393
- state.bottomLogLines.set(sk, { action: 'restarting', service: entry.service, lines: [] });
394
- render(state);
395
-
396
- child.on('close', () => {
397
- state.restarting.delete(sk);
398
- state.containerStatsHistory.delete(sk);
399
- state.containerStats.delete(sk);
400
- pollStatuses(state);
401
-
402
- // Show container application logs after restart
403
- const info = state.bottomLogLines.get(sk);
404
- if (info) {
405
- info.action = 'started';
406
- info.lines = [];
407
- }
408
-
409
- startBottomLogTail(state, sk, entry.file, entry.service);
410
- if (state.mode === MODE.LIST) render(state);
411
- });
412
- }
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
-
475
- function enterLogs(state) {
476
- const entry = selectedEntry(state);
477
- if (!entry) return;
478
-
479
- if (logFetchTimer) {
480
- clearTimeout(logFetchTimer);
481
- logFetchTimer = null;
482
- }
483
-
484
- state.mode = MODE.LOGS;
485
- state.logLines = [];
486
- state.logScrollOffset = 0;
487
- state.logAutoScroll = true;
488
- state.logSearchQuery = '';
489
- state.logSearchActive = false;
490
- state.logSearchMatches = [];
491
- state.logSearchMatchIdx = -1;
492
-
493
- const child = tailLogs(entry.file, entry.service, state.config.logTailLines);
494
- state.logChild = child;
495
-
496
- let lineBuf = '';
497
- const onData = (data) => {
498
- lineBuf += data.toString();
499
- const parts = lineBuf.split(/\r?\n|\r/);
500
- lineBuf = parts.pop();
501
- if (parts.length === 0) return;
502
- for (const line of parts) {
503
- state.logLines.push(stripAnsi(line));
504
- }
505
- // Cap buffer at 10000 lines
506
- if (state.logLines.length > 10000) {
507
- const excess = state.logLines.length - 10000;
508
- state.logLines.splice(0, excess);
509
- if (!state.logAutoScroll) {
510
- state.logScrollOffset = Math.max(0, state.logScrollOffset - excess);
511
- }
512
- }
513
- if (state.logAutoScroll) {
514
- throttledRender(state);
515
- }
516
- };
517
-
518
- child.stdout.on('data', onData);
519
- child.stderr.on('data', onData);
520
- child.on('close', () => {
521
- if (state.logChild === child) {
522
- state.logChild = null;
523
- }
524
- });
525
-
526
- render(state);
527
- }
528
-
529
- function exitLogs(state) {
530
- if (state.logChild) {
531
- state.logChild.kill('SIGTERM');
532
- state.logChild = null;
533
- }
534
- state.logLines = [];
535
- state.mode = MODE.LIST;
536
- pollStatuses(state);
537
- render(state);
538
- }
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
-
585
- // --- Input Handling ---
586
-
587
- function handleKeypress(state, key) {
588
- // Ctrl+C always quits
589
- if (key === '\x03') {
590
- cleanup(state);
591
- process.exit(0);
592
- }
593
-
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
-
618
- const { rows = 24 } = process.stdout;
619
- const pageSize = Math.max(1, Math.floor(rows / 2));
620
- const maxOffset = Math.max(0, state.logLines.length - 1);
621
-
622
- switch (key) {
623
- case 'f':
624
- case '\x1b':
625
- exitLogs(state);
626
- break;
627
- case 'q':
628
- cleanup(state);
629
- process.exit(0);
630
- break;
631
- case 'k':
632
- case '\x1b[A':
633
- state.logAutoScroll = false;
634
- state.logScrollOffset = Math.min(maxOffset, state.logScrollOffset + 1);
635
- render(state);
636
- break;
637
- case 'j':
638
- case '\x1b[B':
639
- if (state.logScrollOffset > 0) {
640
- state.logScrollOffset--;
641
- if (state.logScrollOffset === 0) state.logAutoScroll = true;
642
- }
643
- render(state);
644
- break;
645
- case 'G':
646
- state.logScrollOffset = 0;
647
- state.logAutoScroll = true;
648
- render(state);
649
- break;
650
- case '\x15': // Ctrl+U - page up
651
- state.logAutoScroll = false;
652
- state.logScrollOffset = Math.min(maxOffset, state.logScrollOffset + pageSize);
653
- render(state);
654
- break;
655
- case '\x04': // Ctrl+D - page down
656
- state.logScrollOffset = Math.max(0, state.logScrollOffset - pageSize);
657
- if (state.logScrollOffset === 0) state.logAutoScroll = true;
658
- render(state);
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);
690
- }
691
- return;
692
- }
693
-
694
- // LIST mode
695
- switch (key) {
696
- case 'j':
697
- case '\x1b[B': // Arrow Down
698
- moveCursor(state, 1);
699
- updateSelectedLogs(state);
700
- render(state);
701
- break;
702
- case 'k':
703
- case '\x1b[A': // Arrow Up
704
- moveCursor(state, -1);
705
- updateSelectedLogs(state);
706
- render(state);
707
- break;
708
- case 'b':
709
- doRebuild(state);
710
- break;
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);
730
- break;
731
- case 'f':
732
- case '\r': // Enter
733
- enterLogs(state);
734
- break;
735
- case 'l':
736
- state.showBottomLogs = !state.showBottomLogs;
737
- render(state);
738
- break;
739
- case 'q':
740
- cleanup(state);
741
- process.exit(0);
742
- break;
743
- case 'G': // vim: go to bottom
744
- state.cursor = state.flatList.length - 1;
745
- updateSelectedLogs(state);
746
- render(state);
747
- break;
748
- case 'g': // gg handled via double-tap buffer below
749
- break;
750
- case '/':
751
- if (state.showBottomLogs) {
752
- state.bottomSearchActive = true;
753
- state.bottomSearchQuery = '';
754
- render(state);
755
- }
756
- break;
757
- }
758
- }
759
-
760
- // --- Arrow key sequence buffering ---
761
-
762
- function createInputHandler(state) {
763
- let buf = '';
764
- let gPending = false;
765
-
766
- return function onData(data) {
767
- const str = data.toString();
768
-
769
- // Handle escape sequences (arrow keys)
770
- buf += str;
771
-
772
- while (buf.length > 0) {
773
- // Check for escape sequences
774
- if (buf === '\x1b') {
775
- // Could be start of escape sequence — wait for more
776
- setTimeout(() => {
777
- if (buf === '\x1b') {
778
- handleKeypress(state, '\x1b');
779
- buf = '';
780
- }
781
- }, 50);
782
- return;
783
- }
784
-
785
- if (buf.startsWith('\x1b[A')) {
786
- handleKeypress(state, '\x1b[A');
787
- buf = buf.slice(3);
788
- continue;
789
- }
790
- if (buf.startsWith('\x1b[B')) {
791
- handleKeypress(state, '\x1b[B');
792
- buf = buf.slice(3);
793
- continue;
794
- }
795
- if (buf.startsWith('\x1b[')) {
796
- // Unknown escape sequence — skip it
797
- buf = buf.slice(buf.length);
798
- continue;
799
- }
800
-
801
- // Single character
802
- const ch = buf[0];
803
- buf = buf.slice(1);
804
-
805
- // In search input mode, send all chars directly
806
- if (state.logSearchActive || state.bottomSearchActive) {
807
- handleKeypress(state, ch);
808
- continue;
809
- }
810
-
811
- // Handle gg (go to top)
812
- if (ch === 'g') {
813
- if (gPending) {
814
- gPending = false;
815
- if (state.mode === MODE.LIST) {
816
- state.cursor = 0;
817
- state.scrollOffset = 0;
818
- updateSelectedLogs(state);
819
- } else if (state.mode === MODE.LOGS) {
820
- state.logAutoScroll = false;
821
- state.logScrollOffset = Math.max(0, state.logLines.length - 1);
822
- }
823
- render(state);
824
- continue;
825
- }
826
- gPending = true;
827
- setTimeout(() => {
828
- if (gPending) {
829
- gPending = false;
830
- // Single g — ignore
831
- }
832
- }, 300);
833
- continue;
834
- }
835
-
836
- gPending = false;
837
- handleKeypress(state, ch);
838
- }
839
- };
840
- }
841
-
842
- // --- Cleanup ---
843
-
844
- function cleanup(state) {
845
- if (state.logChild) {
846
- state.logChild.kill('SIGTERM');
847
- state.logChild = null;
848
- }
849
- for (const [, child] of state.rebuilding) {
850
- child.kill('SIGTERM');
851
- }
852
- state.rebuilding.clear();
853
- for (const [, child] of state.restarting) {
854
- child.kill('SIGTERM');
855
- }
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();
865
- for (const [, child] of state.bottomLogTails) {
866
- child.kill('SIGTERM');
867
- }
868
- state.bottomLogTails.clear();
869
- if (logFetchTimer) {
870
- clearTimeout(logFetchTimer);
871
- logFetchTimer = null;
872
- }
873
- if (state.logScanTimer) {
874
- clearInterval(state.logScanTimer);
875
- }
876
- if (state.pollTimer) {
877
- clearInterval(state.pollTimer);
878
- }
879
- if (state.statsTimer) {
880
- clearInterval(state.statsTimer);
881
- }
882
- process.stdout.write('\x1b[r' + showCursor() + '\x1b[0m');
883
- }
884
-
885
- // --- Main ---
886
-
887
- function main() {
888
- const config = loadConfig();
889
- const state = createState(config);
890
-
891
- // Discover services
892
- state.groups = discoverServices(config);
893
- state.flatList = buildFlatList(state.groups);
894
-
895
- if (state.flatList.length === 0) {
896
- process.stderr.write('No services found in any compose file.\n');
897
- process.exit(1);
898
- }
899
-
900
- // Initial status poll
901
- pollStatuses(state);
902
-
903
- // Setup terminal
904
- if (process.stdin.isTTY) {
905
- process.stdin.setRawMode(true);
906
- }
907
- process.stdin.resume();
908
- process.stdin.setEncoding('utf8');
909
- process.stdin.on('data', createInputHandler(state));
910
-
911
- // Initial log pattern scan
912
- pollLogCounts(state);
913
-
914
- // Start log tail for initially selected container and render
915
- updateSelectedLogs(state);
916
- render(state);
917
-
918
- // Poll loop
919
- state.pollTimer = setInterval(() => {
920
- if (state.mode === MODE.LIST) {
921
- pollStatuses(state);
922
- render(state);
923
- }
924
- }, config.pollInterval);
925
-
926
- // Log pattern scan loop
927
- state.logScanTimer = setInterval(() => {
928
- if (state.mode === MODE.LIST) {
929
- pollLogCounts(state);
930
- }
931
- }, config.logScanInterval || 10000);
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
-
941
- // Terminal resize
942
- process.stdout.on('resize', () => {
943
- render(state);
944
- });
945
-
946
- // Cleanup on exit
947
- process.on('exit', () => cleanup(state));
948
- process.on('SIGINT', () => {
949
- cleanup(state);
950
- process.exit(0);
951
- });
952
- process.on('SIGTERM', () => {
953
- cleanup(state);
954
- process.exit(0);
955
- });
956
- }
957
-
958
- main();