recomposable 1.0.0 → 1.0.1

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 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 } = require('./lib/docker');
6
+ const { listServices, getStatuses, rebuildService, restartService, tailLogs, getContainerId, tailContainerLogs, fetchContainerLogs } = require('./lib/docker');
7
7
  const { MODE, createState, statusKey, buildFlatList, moveCursor, selectedEntry } = require('./lib/state');
8
- const { clearScreen, showCursor, renderListView, renderLogHeader } = require('./lib/renderer');
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 };
13
+ const defaults = { composeFiles: [], pollInterval: 3000, logTailLines: 100, logScanPatterns: ['WRN]', 'ERR]'], logScanLines: 1000, logScanInterval: 10000 };
14
14
 
15
15
  // Load from recomposable.json in current working directory
16
16
  const configPath = path.join(process.cwd(), 'recomposable.json');
@@ -69,18 +69,145 @@ function pollStatuses(state) {
69
69
  }
70
70
  }
71
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
+
72
130
  // --- Rendering ---
73
131
 
74
132
  function render(state) {
75
133
  let output = clearScreen();
76
134
  if (state.mode === MODE.LIST) {
77
135
  output += renderListView(state);
136
+ } else if (state.mode === MODE.LOGS) {
137
+ output += renderLogView(state);
78
138
  }
79
139
  process.stdout.write(output);
80
140
  }
81
141
 
142
+ function stripAnsi(str) {
143
+ return str.replace(/\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[^[\]]/g, '');
144
+ }
145
+
146
+ let lastRenderTime = 0;
147
+ let pendingRender = null;
148
+ let logFetchTimer = null;
149
+
150
+ function throttledRender(state) {
151
+ const now = Date.now();
152
+ const elapsed = now - lastRenderTime;
153
+ if (elapsed >= 150) {
154
+ lastRenderTime = now;
155
+ render(state);
156
+ } else if (!pendingRender) {
157
+ pendingRender = setTimeout(() => {
158
+ pendingRender = null;
159
+ lastRenderTime = Date.now();
160
+ render(state);
161
+ }, 150 - elapsed);
162
+ }
163
+ }
164
+
82
165
  // --- Actions ---
83
166
 
167
+ function updateSelectedLogs(state) {
168
+ const entry = selectedEntry(state);
169
+ if (!entry) return;
170
+
171
+ const sk = statusKey(entry.file, entry.service);
172
+
173
+ // Same container already selected, nothing to do
174
+ if (state.selectedLogKey === sk) return;
175
+
176
+ // Cancel any pending debounced log fetch
177
+ if (logFetchTimer) {
178
+ clearTimeout(logFetchTimer);
179
+ logFetchTimer = null;
180
+ }
181
+
182
+ // Clean up previous selected container's passive log tail
183
+ if (state.selectedLogKey) {
184
+ const oldInfo = state.bottomLogLines.get(state.selectedLogKey);
185
+ if (oldInfo && (oldInfo.action === 'logs' || oldInfo.action === 'started')) {
186
+ if (!state.rebuilding.has(state.selectedLogKey) && !state.restarting.has(state.selectedLogKey)) {
187
+ state.bottomLogLines.delete(state.selectedLogKey);
188
+ if (state.bottomLogTails.has(state.selectedLogKey)) {
189
+ state.bottomLogTails.get(state.selectedLogKey).kill('SIGTERM');
190
+ state.bottomLogTails.delete(state.selectedLogKey);
191
+ }
192
+ }
193
+ }
194
+ }
195
+
196
+ state.selectedLogKey = sk;
197
+
198
+ // If this container already has active action logs (rebuild/restart/started), keep those
199
+ if (state.bottomLogLines.has(sk)) return;
200
+
201
+ // Set up empty log entry immediately so the UI shows the container name
202
+ state.bottomLogLines.set(sk, { action: 'logs', service: entry.service, lines: [] });
203
+
204
+ // Debounce the expensive log fetch (getContainerId is a blocking execFileSync)
205
+ logFetchTimer = setTimeout(() => {
206
+ logFetchTimer = null;
207
+ startBottomLogTail(state, sk, entry.file, entry.service);
208
+ }, 500);
209
+ }
210
+
84
211
  function doRebuild(state) {
85
212
  const entry = selectedEntry(state);
86
213
  if (!entry) return;
@@ -88,17 +215,83 @@ function doRebuild(state) {
88
215
  const sk = statusKey(entry.file, entry.service);
89
216
  if (state.rebuilding.has(sk)) return;
90
217
 
218
+ // Kill any existing startup log tail for this service
219
+ if (state.bottomLogTails.has(sk)) {
220
+ state.bottomLogTails.get(sk).kill('SIGTERM');
221
+ state.bottomLogTails.delete(sk);
222
+ }
223
+
91
224
  const child = rebuildService(entry.file, entry.service);
92
225
  state.rebuilding.set(sk, child);
226
+
227
+ state.bottomLogLines.set(sk, { action: 'rebuilding', service: entry.service, lines: [] });
228
+
229
+ let lineBuf = '';
230
+ const onData = (data) => {
231
+ const info = state.bottomLogLines.get(sk);
232
+ if (!info) return;
233
+ lineBuf += data.toString();
234
+ const parts = lineBuf.split(/\r?\n|\r/);
235
+ lineBuf = parts.pop();
236
+ const newLines = parts.filter(l => l.trim().length > 0).map(stripAnsi).filter(Boolean);
237
+ if (newLines.length === 0) return;
238
+ info.lines.push(...newLines);
239
+ if (info.lines.length > 10) info.lines = info.lines.slice(-10);
240
+ if (state.mode === MODE.LIST) throttledRender(state);
241
+ };
242
+
243
+ child.stdout.on('data', onData);
244
+ child.stderr.on('data', onData);
93
245
  render(state);
94
246
 
95
247
  child.on('close', () => {
96
248
  state.rebuilding.delete(sk);
97
249
  pollStatuses(state);
250
+
251
+ // Show container application logs after rebuild+start
252
+ const info = state.bottomLogLines.get(sk);
253
+ if (info) {
254
+ info.action = 'started';
255
+ info.lines = [];
256
+ }
257
+
258
+ startBottomLogTail(state, sk, entry.file, entry.service);
98
259
  if (state.mode === MODE.LIST) render(state);
99
260
  });
100
261
  }
101
262
 
263
+ function startBottomLogTail(state, sk, file, service) {
264
+ // Kill any existing tail for this service
265
+ if (state.bottomLogTails.has(sk)) {
266
+ state.bottomLogTails.get(sk).kill('SIGTERM');
267
+ state.bottomLogTails.delete(sk);
268
+ }
269
+
270
+ // Get container ID and use docker logs directly (avoids compose buffering)
271
+ const containerId = getContainerId(file, service);
272
+ if (!containerId) return;
273
+
274
+ const logChild = tailContainerLogs(containerId, 10);
275
+ state.bottomLogTails.set(sk, logChild);
276
+
277
+ let buf = '';
278
+ const onData = (data) => {
279
+ const info = state.bottomLogLines.get(sk);
280
+ if (!info) return;
281
+ buf += data.toString();
282
+ const parts = buf.split(/\r?\n|\r/);
283
+ buf = parts.pop();
284
+ const newLines = parts.filter(l => l.trim().length > 0).map(stripAnsi).filter(Boolean);
285
+ if (newLines.length === 0) return;
286
+ info.lines.push(...newLines);
287
+ if (info.lines.length > 10) info.lines = info.lines.slice(-10);
288
+ if (state.mode === MODE.LIST) throttledRender(state);
289
+ };
290
+
291
+ logChild.stdout.on('data', onData);
292
+ logChild.stderr.on('data', onData);
293
+ }
294
+
102
295
  function doRestart(state) {
103
296
  const entry = selectedEntry(state);
104
297
  if (!entry) return;
@@ -106,13 +299,30 @@ function doRestart(state) {
106
299
  const sk = statusKey(entry.file, entry.service);
107
300
  if (state.restarting.has(sk) || state.rebuilding.has(sk)) return;
108
301
 
302
+ // Kill any existing startup log tail for this service
303
+ if (state.bottomLogTails.has(sk)) {
304
+ state.bottomLogTails.get(sk).kill('SIGTERM');
305
+ state.bottomLogTails.delete(sk);
306
+ }
307
+
109
308
  const child = restartService(entry.file, entry.service);
110
309
  state.restarting.set(sk, child);
310
+
311
+ state.bottomLogLines.set(sk, { action: 'restarting', service: entry.service, lines: [] });
111
312
  render(state);
112
313
 
113
314
  child.on('close', () => {
114
315
  state.restarting.delete(sk);
115
316
  pollStatuses(state);
317
+
318
+ // Show container application logs after restart
319
+ const info = state.bottomLogLines.get(sk);
320
+ if (info) {
321
+ info.action = 'started';
322
+ info.lines = [];
323
+ }
324
+
325
+ startBottomLogTail(state, sk, entry.file, entry.service);
116
326
  if (state.mode === MODE.LIST) render(state);
117
327
  });
118
328
  }
@@ -121,22 +331,50 @@ function enterLogs(state) {
121
331
  const entry = selectedEntry(state);
122
332
  if (!entry) return;
123
333
 
124
- state.mode = MODE.LOGS;
334
+ if (logFetchTimer) {
335
+ clearTimeout(logFetchTimer);
336
+ logFetchTimer = null;
337
+ }
125
338
 
126
- // Clear screen and show log header
127
- process.stdout.write(clearScreen() + renderLogHeader(entry.service) + '\n');
339
+ state.mode = MODE.LOGS;
340
+ state.logLines = [];
341
+ state.logScrollOffset = 0;
342
+ state.logAutoScroll = true;
128
343
 
129
344
  const child = tailLogs(entry.file, entry.service, state.config.logTailLines);
130
345
  state.logChild = child;
131
346
 
132
- child.stdout.pipe(process.stdout);
133
- child.stderr.pipe(process.stdout);
347
+ let lineBuf = '';
348
+ const onData = (data) => {
349
+ lineBuf += data.toString();
350
+ const parts = lineBuf.split(/\r?\n|\r/);
351
+ lineBuf = parts.pop();
352
+ if (parts.length === 0) return;
353
+ for (const line of parts) {
354
+ state.logLines.push(stripAnsi(line));
355
+ }
356
+ // Cap buffer at 10000 lines
357
+ if (state.logLines.length > 10000) {
358
+ const excess = state.logLines.length - 10000;
359
+ state.logLines.splice(0, excess);
360
+ if (!state.logAutoScroll) {
361
+ state.logScrollOffset = Math.max(0, state.logScrollOffset - excess);
362
+ }
363
+ }
364
+ if (state.logAutoScroll) {
365
+ throttledRender(state);
366
+ }
367
+ };
134
368
 
369
+ child.stdout.on('data', onData);
370
+ child.stderr.on('data', onData);
135
371
  child.on('close', () => {
136
372
  if (state.logChild === child) {
137
373
  state.logChild = null;
138
374
  }
139
375
  });
376
+
377
+ render(state);
140
378
  }
141
379
 
142
380
  function exitLogs(state) {
@@ -144,6 +382,7 @@ function exitLogs(state) {
144
382
  state.logChild.kill('SIGTERM');
145
383
  state.logChild = null;
146
384
  }
385
+ state.logLines = [];
147
386
  state.mode = MODE.LIST;
148
387
  pollStatuses(state);
149
388
  render(state);
@@ -159,12 +398,48 @@ function handleKeypress(state, key) {
159
398
  }
160
399
 
161
400
  if (state.mode === MODE.LOGS) {
162
- if (key === 'l' || key === '\x1b' || key === 'q') {
163
- if (key === 'q') {
401
+ const { rows = 24 } = process.stdout;
402
+ const pageSize = Math.max(1, Math.floor(rows / 2));
403
+ const maxOffset = Math.max(0, state.logLines.length - 1);
404
+
405
+ switch (key) {
406
+ case 'f':
407
+ case '\x1b':
408
+ exitLogs(state);
409
+ break;
410
+ case 'q':
164
411
  cleanup(state);
165
412
  process.exit(0);
166
- }
167
- exitLogs(state);
413
+ break;
414
+ case 'k':
415
+ case '\x1b[A':
416
+ state.logAutoScroll = false;
417
+ state.logScrollOffset = Math.min(maxOffset, state.logScrollOffset + 1);
418
+ render(state);
419
+ break;
420
+ case 'j':
421
+ case '\x1b[B':
422
+ if (state.logScrollOffset > 0) {
423
+ state.logScrollOffset--;
424
+ if (state.logScrollOffset === 0) state.logAutoScroll = true;
425
+ }
426
+ render(state);
427
+ break;
428
+ case 'G':
429
+ state.logScrollOffset = 0;
430
+ state.logAutoScroll = true;
431
+ render(state);
432
+ break;
433
+ case '\x15': // Ctrl+U - page up
434
+ state.logAutoScroll = false;
435
+ state.logScrollOffset = Math.min(maxOffset, state.logScrollOffset + pageSize);
436
+ render(state);
437
+ break;
438
+ case '\x04': // Ctrl+D - page down
439
+ state.logScrollOffset = Math.max(0, state.logScrollOffset - pageSize);
440
+ if (state.logScrollOffset === 0) state.logAutoScroll = true;
441
+ render(state);
442
+ break;
168
443
  }
169
444
  return;
170
445
  }
@@ -174,11 +449,13 @@ function handleKeypress(state, key) {
174
449
  case 'j':
175
450
  case '\x1b[B': // Arrow Down
176
451
  moveCursor(state, 1);
452
+ updateSelectedLogs(state);
177
453
  render(state);
178
454
  break;
179
455
  case 'k':
180
456
  case '\x1b[A': // Arrow Up
181
457
  moveCursor(state, -1);
458
+ updateSelectedLogs(state);
182
459
  render(state);
183
460
  break;
184
461
  case 'r':
@@ -187,16 +464,21 @@ function handleKeypress(state, key) {
187
464
  case 's':
188
465
  doRestart(state);
189
466
  break;
190
- case 'l':
467
+ case 'f':
191
468
  case '\r': // Enter
192
469
  enterLogs(state);
193
470
  break;
471
+ case 'l':
472
+ state.showBottomLogs = !state.showBottomLogs;
473
+ render(state);
474
+ break;
194
475
  case 'q':
195
476
  cleanup(state);
196
477
  process.exit(0);
197
478
  break;
198
479
  case 'G': // vim: go to bottom
199
480
  state.cursor = state.flatList.length - 1;
481
+ updateSelectedLogs(state);
200
482
  render(state);
201
483
  break;
202
484
  case 'g': // gg handled via double-tap buffer below
@@ -253,8 +535,14 @@ function createInputHandler(state) {
253
535
  if (ch === 'g') {
254
536
  if (gPending) {
255
537
  gPending = false;
256
- state.cursor = 0;
257
- state.scrollOffset = 0;
538
+ if (state.mode === MODE.LIST) {
539
+ state.cursor = 0;
540
+ state.scrollOffset = 0;
541
+ updateSelectedLogs(state);
542
+ } else if (state.mode === MODE.LOGS) {
543
+ state.logAutoScroll = false;
544
+ state.logScrollOffset = Math.max(0, state.logLines.length - 1);
545
+ }
258
546
  render(state);
259
547
  continue;
260
548
  }
@@ -289,10 +577,21 @@ function cleanup(state) {
289
577
  child.kill('SIGTERM');
290
578
  }
291
579
  state.restarting.clear();
580
+ for (const [, child] of state.bottomLogTails) {
581
+ child.kill('SIGTERM');
582
+ }
583
+ state.bottomLogTails.clear();
584
+ if (logFetchTimer) {
585
+ clearTimeout(logFetchTimer);
586
+ logFetchTimer = null;
587
+ }
588
+ if (state.logScanTimer) {
589
+ clearInterval(state.logScanTimer);
590
+ }
292
591
  if (state.pollTimer) {
293
592
  clearInterval(state.pollTimer);
294
593
  }
295
- process.stdout.write(showCursor() + '\x1b[0m');
594
+ process.stdout.write('\x1b[r' + showCursor() + '\x1b[0m');
296
595
  }
297
596
 
298
597
  // --- Main ---
@@ -321,7 +620,11 @@ function main() {
321
620
  process.stdin.setEncoding('utf8');
322
621
  process.stdin.on('data', createInputHandler(state));
323
622
 
324
- // Render
623
+ // Initial log pattern scan
624
+ pollLogCounts(state);
625
+
626
+ // Start log tail for initially selected container and render
627
+ updateSelectedLogs(state);
325
628
  render(state);
326
629
 
327
630
  // Poll loop
@@ -332,9 +635,16 @@ function main() {
332
635
  }
333
636
  }, config.pollInterval);
334
637
 
638
+ // Log pattern scan loop
639
+ state.logScanTimer = setInterval(() => {
640
+ if (state.mode === MODE.LIST) {
641
+ pollLogCounts(state);
642
+ }
643
+ }, config.logScanInterval || 10000);
644
+
335
645
  // Terminal resize
336
646
  process.stdout.on('resize', () => {
337
- if (state.mode === MODE.LIST) render(state);
647
+ render(state);
338
648
  });
339
649
 
340
650
  // Cleanup on exit
package/lib/docker.js CHANGED
@@ -33,11 +33,40 @@ function getStatuses(file) {
33
33
  containers = trimmed.split('\n').filter(Boolean).map(line => JSON.parse(line));
34
34
  }
35
35
 
36
+ const idToService = new Map();
37
+
36
38
  for (const c of containers) {
37
39
  const name = c.Service || c.Name;
38
40
  const state = (c.State || '').toLowerCase();
39
41
  const health = (c.Health || '').toLowerCase();
40
- statuses.set(name, { state, health });
42
+ const createdAt = c.CreatedAt || null;
43
+ const id = c.ID || null;
44
+ statuses.set(name, { state, health, createdAt, startedAt: null, id: id || null });
45
+ if (id) idToService.set(id, name);
46
+ }
47
+
48
+ // Batch docker inspect to get startedAt timestamps
49
+ const ids = [...idToService.keys()];
50
+ if (ids.length > 0) {
51
+ try {
52
+ const inspectOut = execFileSync('docker', ['inspect', ...ids], {
53
+ encoding: 'utf8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe']
54
+ });
55
+ const inspected = JSON.parse(inspectOut);
56
+ for (const info of inspected) {
57
+ for (const [id, svc] of idToService) {
58
+ if (info.Id && info.Id.startsWith(id)) {
59
+ const status = statuses.get(svc);
60
+ if (status && info.State) {
61
+ status.startedAt = info.State.StartedAt || null;
62
+ }
63
+ break;
64
+ }
65
+ }
66
+ }
67
+ } catch {
68
+ // Ignore inspect errors
69
+ }
41
70
  }
42
71
 
43
72
  return statuses;
@@ -46,7 +75,10 @@ function getStatuses(file) {
46
75
  function rebuildService(file, service) {
47
76
  const cwd = path.dirname(path.resolve(file));
48
77
  const args = ['compose', '-f', path.resolve(file), 'up', '-d', '--build', service];
49
- const child = spawn('docker', args, { cwd, stdio: 'ignore', detached: false });
78
+ const child = spawn('docker', args, {
79
+ cwd, stdio: ['ignore', 'pipe', 'pipe'], detached: false,
80
+ env: { ...process.env, BUILDKIT_PROGRESS: 'plain' },
81
+ });
50
82
  return child;
51
83
  }
52
84
 
@@ -57,11 +89,35 @@ function tailLogs(file, service, tailLines) {
57
89
  return child;
58
90
  }
59
91
 
92
+ function getContainerId(file, service) {
93
+ const cwd = path.dirname(path.resolve(file));
94
+ const args = ['compose', '-f', path.resolve(file), 'ps', '-q', service];
95
+ try {
96
+ const out = execFileSync('docker', args, { cwd, encoding: 'utf8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] });
97
+ return out.trim() || null;
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ function tailContainerLogs(containerId, tailLines) {
104
+ const args = ['logs', '-f', '--tail', String(tailLines), containerId];
105
+ const child = spawn('docker', args, { stdio: ['ignore', 'pipe', 'pipe'] });
106
+ return child;
107
+ }
108
+
109
+ function fetchContainerLogs(containerId, tailLines) {
110
+ const child = spawn('docker', ['logs', '--tail', String(tailLines), containerId], {
111
+ stdio: ['ignore', 'pipe', 'pipe'],
112
+ });
113
+ return child;
114
+ }
115
+
60
116
  function restartService(file, service) {
61
117
  const cwd = path.dirname(path.resolve(file));
62
118
  const args = ['compose', '-f', path.resolve(file), 'restart', service];
63
- const child = spawn('docker', args, { cwd, stdio: 'ignore', detached: false });
119
+ const child = spawn('docker', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], detached: false });
64
120
  return child;
65
121
  }
66
122
 
67
- module.exports = { listServices, getStatuses, rebuildService, restartService, tailLogs };
123
+ module.exports = { listServices, getStatuses, rebuildService, restartService, tailLogs, getContainerId, tailContainerLogs, fetchContainerLogs };
package/lib/renderer.js CHANGED
@@ -14,21 +14,16 @@ const FG_GRAY = `${ESC}90m`;
14
14
  const FG_CYAN = `${ESC}36m`;
15
15
  const FG_WHITE = `${ESC}37m`;
16
16
 
17
- const LOGO_L = [
18
- ` ${BOLD}${FG_CYAN}__ __ _ _ _ _ ___${RESET}`,
19
- ` ${BOLD}${FG_CYAN}\\ \\ / /| || | /_\\ | | | __|${RESET}`,
20
- ` ${BOLD}${FG_CYAN} \\ \\/\\/ / | __ |/ _ \\| |__| _|${RESET}`,
21
- ` ${BOLD}${FG_CYAN} \\_/\\_/ |_||_/_/ \\_|____|___|${RESET}`,
17
+ const ITALIC = `${ESC}3m`;
18
+ const BG_HIGHLIGHT = `${ESC}48;5;237m`;
19
+
20
+ const LOGO = [
21
+ ` ${ITALIC}${BOLD}${FG_CYAN}┌─┐┌─┐┌─┐┌─┐┌┬┐┌─┐┌─┐┌─┐┌─┐┌┐ ┬ ┌─┐${RESET}`,
22
+ ` ${ITALIC}${BOLD}${FG_CYAN}├┬┘├┤ │ │ ││││├─┘│ │└─┐├─┤├┴┐│ ├┤${RESET}`,
23
+ ` ${ITALIC}${BOLD}${FG_CYAN}┴└─└─┘└─┘└─┘┴ ┴┴ └─┘└─┘┴ ┴└─┘┴─┘└─┘${RESET}`,
22
24
  ``,
23
25
  ` ${DIM}docker compose manager${RESET}`,
24
- ];
25
- const LOGO_R = [
26
- `${FG_CYAN} .${RESET}`,
27
- `${FG_CYAN} ":"${RESET}`,
28
- `${FG_CYAN} ___:____ |"\\/"|${RESET}`,
29
- `${FG_CYAN} ,' \`. \\ /${RESET}`,
30
- `${FG_CYAN} | O \\___/ |${RESET}`,
31
- `${FG_CYAN}~^~^~^~^~^~^~^~^~^~^~^~${RESET}`,
26
+ ``,
32
27
  ];
33
28
 
34
29
  function visLen(str) {
@@ -40,6 +35,39 @@ function padVisible(str, width) {
40
35
  return str + ' '.repeat(pad);
41
36
  }
42
37
 
38
+ function padVisibleStart(str, width) {
39
+ const pad = Math.max(0, width - visLen(str));
40
+ return ' '.repeat(pad) + str;
41
+ }
42
+
43
+ const PATTERN_COLORS = [FG_YELLOW, FG_RED, FG_CYAN, FG_WHITE];
44
+
45
+ function patternLabel(pattern) {
46
+ return pattern.replace(/^[\[\(\{<]/, '').replace(/[\]\)\}>]$/, '');
47
+ }
48
+
49
+ function parseTimestamp(ts) {
50
+ if (!ts) return null;
51
+ // Strip trailing timezone abbreviation (e.g., "UTC", "CET")
52
+ const cleaned = ts.replace(/ [A-Z]{2,5}$/, '');
53
+ const d = new Date(cleaned);
54
+ return isNaN(d.getTime()) ? null : d;
55
+ }
56
+
57
+ function relativeTime(ts) {
58
+ const date = parseTimestamp(ts);
59
+ if (!date) return `${FG_GRAY}-${RESET}`;
60
+ const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
61
+ if (seconds < 0) return `${FG_GRAY}-${RESET}`;
62
+ if (seconds < 60) return `${DIM}${seconds}s ago${RESET}`;
63
+ const minutes = Math.floor(seconds / 60);
64
+ if (minutes < 60) return `${DIM}${minutes}m ago${RESET}`;
65
+ const hours = Math.floor(minutes / 60);
66
+ if (hours < 24) return `${DIM}${hours}h ago${RESET}`;
67
+ const days = Math.floor(hours / 24);
68
+ return `${DIM}${days}d ago${RESET}`;
69
+ }
70
+
43
71
  function clearScreen() {
44
72
  return `${ESC}2J${ESC}H${ESC}?25l`;
45
73
  }
@@ -81,21 +109,63 @@ function statusText(status, isRebuilding, isRestarting) {
81
109
  return `${DIM}${text}${RESET}`;
82
110
  }
83
111
 
112
+ function renderLegend(opts = {}) {
113
+ const { logPanelActive = false, fullLogsActive = false, logsScrollMode = false } = opts;
114
+ const item = (text, active) => {
115
+ if (active) return `${BG_HIGHLIGHT} ${text} ${RESET}`;
116
+ return `${DIM}${text}${RESET}`;
117
+ };
118
+ if (logsScrollMode) {
119
+ return [
120
+ item('[Esc] back', false),
121
+ item('[j/k] scroll', false),
122
+ item('[G] bottom', false),
123
+ item('[gg] top', false),
124
+ item('[Q]uit', false),
125
+ ].join(' ');
126
+ }
127
+ return [
128
+ item('[R]ebuild', false),
129
+ item('[S]restart', false),
130
+ item('[F]ull logs', fullLogsActive),
131
+ item('[L]og panel', logPanelActive),
132
+ item('[Q]uit', false),
133
+ ].join(' ');
134
+ }
135
+
84
136
  function renderListView(state) {
85
137
  const { columns = 80, rows = 24 } = process.stdout;
138
+ const patterns = state.config.logScanPatterns || [];
86
139
  const buf = [];
87
140
 
88
- // Logo: text left, whale art right
89
- const logoGap = 6;
90
- const leftWidth = 32;
91
- for (let i = 0; i < LOGO_L.length; i++) {
92
- const left = padVisible(LOGO_L[i] || '', leftWidth);
93
- const right = LOGO_R[i] || '';
94
- buf.push(` ${left}${' '.repeat(logoGap)}${right}`);
141
+ // Logo
142
+ for (const line of LOGO) {
143
+ buf.push(line);
95
144
  }
96
- const help = `${DIM}[S]restart [R]ebuild [L]ogs [Q]uit${RESET}`;
97
- buf.push(` ${FG_GRAY}${'─'.repeat(Math.max(0, columns - 2))}${RESET} ${help}`);
98
- buf.push('');
145
+ const help = renderLegend({ logPanelActive: state.showBottomLogs });
146
+ buf.push(` ${FG_GRAY}${'─'.repeat(Math.max(0, columns - 2))}${RESET}`);
147
+ buf.push(` ${help}`);
148
+
149
+ const headerHeight = buf.length;
150
+
151
+ // Build bottom panel content — show logs for the currently selected container
152
+ const bottomBuf = [];
153
+ if (state.showBottomLogs) {
154
+ const selEntry = state.flatList[state.cursor];
155
+ if (selEntry) {
156
+ const sk = statusKey(selEntry.file, selEntry.service);
157
+ const info = state.bottomLogLines.get(sk);
158
+ if (info) {
159
+ 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
+ bottomBuf.push(` ${actionColor}${info.action} ${BOLD}${info.service}${RESET}`);
162
+ for (const line of info.lines) {
163
+ bottomBuf.push(` ${FG_GRAY}${line.substring(0, columns - 4)}${RESET}`);
164
+ }
165
+ }
166
+ }
167
+ }
168
+ const bottomHeight = bottomBuf.length;
99
169
 
100
170
  // Build all display lines
101
171
  const lines = [];
@@ -115,6 +185,9 @@ function renderListView(state) {
115
185
  } else {
116
186
  lines.push({ type: 'header', text: label });
117
187
  }
188
+ let colHeader = `${DIM} ${'SERVICE'.padEnd(24)} ${'STATUS'.padEnd(22)} ${'BUILT'.padEnd(12)} ${'RESTARTED'.padEnd(12)}`;
189
+ for (const p of patterns) colHeader += patternLabel(p).padStart(5) + ' ';
190
+ lines.push({ type: 'colheader', text: colHeader + RESET });
118
191
  }
119
192
 
120
193
  const sk = statusKey(entry.file, entry.service);
@@ -123,20 +196,31 @@ function renderListView(state) {
123
196
  const restarting = state.restarting.has(sk);
124
197
  const icon = statusIcon(st, rebuilding, restarting);
125
198
  const stext = statusText(st, rebuilding, restarting);
126
- const name = entry.service.padEnd(30);
199
+ const name = entry.service.padEnd(24);
200
+ const statusPadded = padVisible(stext, 22);
201
+ const built = padVisible(relativeTime(st ? st.createdAt : null), 12);
202
+ const restarted = padVisible(relativeTime(st ? st.startedAt : null), 12);
127
203
  const pointer = i === state.cursor ? `${REVERSE}` : '';
128
204
  const endPointer = i === state.cursor ? `${RESET}` : '';
129
205
 
206
+ let countsStr = '';
207
+ const logCounts = state.logCounts.get(sk);
208
+ for (let pi = 0; pi < patterns.length; pi++) {
209
+ const count = logCounts ? (logCounts.get(patterns[pi]) || 0) : 0;
210
+ const color = count > 0 ? PATTERN_COLORS[pi % PATTERN_COLORS.length] : DIM;
211
+ const countText = count > 0 ? `${color}${count}${RESET}` : `${color}-${RESET}`;
212
+ countsStr += padVisibleStart(countText, 5) + ' ';
213
+ }
214
+
130
215
  lines.push({
131
216
  type: 'service',
132
- text: `${pointer} ${icon} ${FG_WHITE}${name}${RESET} ${stext}${endPointer}`,
217
+ text: `${pointer} ${icon} ${FG_WHITE}${name}${RESET} ${statusPadded} ${built} ${restarted}${countsStr}${endPointer}`,
133
218
  flatIdx: i,
134
219
  });
135
220
  }
136
221
 
137
222
  // Scrolling
138
- const availableRows = rows - (LOGO_L.length + 3); // logo + ruler + blank + bottom margin
139
- const serviceLines = lines.filter(l => l.type === 'service');
223
+ const availableRows = Math.max(3, rows - headerHeight - bottomHeight);
140
224
 
141
225
  // Find line index of cursor
142
226
  const cursorLineIdx = lines.findIndex(l => l.type === 'service' && l.flatIdx === state.cursor);
@@ -154,18 +238,78 @@ function renderListView(state) {
154
238
  buf.push(line.text || '');
155
239
  }
156
240
 
241
+ // Pad to push bottom panel to the bottom of the terminal
242
+ const usedLines = buf.length + bottomHeight;
243
+ const paddingNeeded = Math.max(0, rows - usedLines);
244
+ for (let i = 0; i < paddingNeeded; i++) {
245
+ buf.push('');
246
+ }
247
+
248
+ // Bottom panel
249
+ buf.push(...bottomBuf);
250
+
157
251
  return buf.join('\n');
158
252
  }
159
253
 
160
- function renderLogHeader(serviceName) {
161
- const { columns = 80 } = process.stdout;
162
- const title = `${BOLD}${FG_CYAN} whale${RESET} ${FG_GRAY}>${RESET} ${BOLD}${serviceName}${RESET} ${DIM}logs${RESET}`;
163
- const help = `${DIM}[L] or [Esc] back${RESET}`;
164
- const pad = Math.max(0, columns - serviceName.length - 21 - 17);
254
+ function truncateLine(str, maxWidth) {
255
+ let visPos = 0;
256
+ let rawPos = 0;
257
+ while (rawPos < str.length) {
258
+ if (str[rawPos] === '\x1b') {
259
+ const match = str.substring(rawPos).match(/^\x1b\[[0-9;?]*[a-zA-Z]/);
260
+ if (match) { rawPos += match[0].length; continue; }
261
+ const oscMatch = str.substring(rawPos).match(/^\x1b\][^\x07]*\x07/);
262
+ if (oscMatch) { rawPos += oscMatch[0].length; continue; }
263
+ }
264
+ if (visPos >= maxWidth) {
265
+ return str.substring(0, rawPos) + RESET;
266
+ }
267
+ visPos++;
268
+ rawPos++;
269
+ }
270
+ return str;
271
+ }
272
+
273
+ function renderLogView(state) {
274
+ const { columns = 80, rows = 24 } = process.stdout;
165
275
  const buf = [];
166
- buf.push(title + ' '.repeat(pad) + help);
276
+
277
+ for (const line of LOGO) {
278
+ buf.push(line);
279
+ }
167
280
  buf.push(` ${FG_GRAY}${'─'.repeat(Math.max(0, columns - 2))}${RESET}`);
281
+ buf.push(` ${renderLegend({ logsScrollMode: true })}`);
282
+
283
+ const entry = state.flatList[state.cursor];
284
+ const serviceName = entry ? entry.service : '???';
285
+ const totalLines = state.logLines.length;
286
+
287
+ const scrollStatus = state.logAutoScroll
288
+ ? `${FG_GREEN}live${RESET}`
289
+ : `${FG_YELLOW}paused ${DIM}line ${Math.max(1, totalLines - state.logScrollOffset)} / ${totalLines}${RESET}`;
290
+ buf.push(` ${FG_GREEN}full logs ${BOLD}${serviceName}${RESET} ${scrollStatus}`);
291
+
292
+ const headerHeight = buf.length;
293
+ const availableRows = Math.max(1, rows - headerHeight);
294
+
295
+ let endLine;
296
+ if (state.logAutoScroll || state.logScrollOffset === 0) {
297
+ endLine = totalLines;
298
+ } else {
299
+ endLine = Math.max(0, totalLines - state.logScrollOffset);
300
+ }
301
+ const startLine = Math.max(0, endLine - availableRows);
302
+
303
+ for (let i = startLine; i < endLine; i++) {
304
+ buf.push(truncateLine(state.logLines[i], columns));
305
+ }
306
+
307
+ // Pad to fill screen (prevents ghost content from previous render)
308
+ for (let i = buf.length; i < rows; i++) {
309
+ buf.push('');
310
+ }
311
+
168
312
  return buf.join('\n');
169
313
  }
170
314
 
171
- module.exports = { clearScreen, showCursor, renderListView, renderLogHeader };
315
+ module.exports = { clearScreen, showCursor, renderListView, renderLogView };
package/lib/state.js CHANGED
@@ -13,6 +13,14 @@ function createState(config) {
13
13
  restarting: new Map(), // "file::service" -> childProcess
14
14
  logChild: null,
15
15
  scrollOffset: 0,
16
+ showBottomLogs: true,
17
+ bottomLogLines: new Map(), // statusKey -> { action, service, lines: [] }
18
+ bottomLogTails: new Map(), // statusKey -> childProcess (log tail after restart)
19
+ selectedLogKey: null, // statusKey of cursor-selected container for log tailing
20
+ logCounts: new Map(), // statusKey -> Map<pattern, count>
21
+ logLines: [], // buffered log lines for full log view
22
+ logScrollOffset: 0, // lines from bottom (0 = at bottom)
23
+ logAutoScroll: true, // auto-scroll to bottom on new data
16
24
  config,
17
25
  };
18
26
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "recomposable",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
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": {