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 +328 -18
- package/lib/docker.js +60 -4
- package/lib/renderer.js +178 -34
- package/lib/state.js +8 -0
- package/package.json +1 -1
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,
|
|
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
|
-
|
|
334
|
+
if (logFetchTimer) {
|
|
335
|
+
clearTimeout(logFetchTimer);
|
|
336
|
+
logFetchTimer = null;
|
|
337
|
+
}
|
|
125
338
|
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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 '
|
|
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.
|
|
257
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|
|
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
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
` ${BOLD}${FG_CYAN}
|
|
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
|
|
89
|
-
const
|
|
90
|
-
|
|
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 =
|
|
97
|
-
buf.push(` ${FG_GRAY}${'─'.repeat(Math.max(0, columns - 2))}${RESET}
|
|
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(
|
|
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} ${
|
|
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 -
|
|
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
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
}
|