recomposable 1.0.2 → 1.1.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/README.md +78 -11
- package/dist/index.d.ts +40 -0
- package/dist/index.js +1418 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/docker.d.ts +19 -0
- package/dist/lib/docker.js +332 -0
- package/dist/lib/docker.js.map +1 -0
- package/dist/lib/renderer.d.ts +22 -0
- package/dist/lib/renderer.js +526 -0
- package/dist/lib/renderer.js.map +1 -0
- package/dist/lib/state.d.ts +7 -0
- package/dist/lib/state.js +81 -0
- package/dist/lib/state.js.map +1 -0
- package/dist/lib/types.d.ts +183 -0
- package/dist/lib/types.js +6 -0
- package/dist/lib/types.js.map +1 -0
- package/package.json +19 -5
- package/screenshots/exec-inline-view.png +0 -0
- package/screenshots/exec-view.png +0 -0
- package/screenshots/list-view.png +0 -0
- package/screenshots/logs-view.png +0 -0
- package/index.js +0 -662
- package/lib/docker.js +0 -123
- package/lib/renderer.js +0 -315
- package/lib/state.js +0 -52
package/index.js
DELETED
|
@@ -1,662 +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, tailLogs, getContainerId, tailContainerLogs, fetchContainerLogs } = 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 };
|
|
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
|
-
// --- Rendering ---
|
|
131
|
-
|
|
132
|
-
function render(state) {
|
|
133
|
-
let output = clearScreen();
|
|
134
|
-
if (state.mode === MODE.LIST) {
|
|
135
|
-
output += renderListView(state);
|
|
136
|
-
} else if (state.mode === MODE.LOGS) {
|
|
137
|
-
output += renderLogView(state);
|
|
138
|
-
}
|
|
139
|
-
process.stdout.write(output);
|
|
140
|
-
}
|
|
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
|
-
|
|
165
|
-
// --- Actions ---
|
|
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
|
-
|
|
211
|
-
function doRebuild(state) {
|
|
212
|
-
const entry = selectedEntry(state);
|
|
213
|
-
if (!entry) return;
|
|
214
|
-
|
|
215
|
-
const sk = statusKey(entry.file, entry.service);
|
|
216
|
-
if (state.rebuilding.has(sk)) return;
|
|
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
|
-
|
|
224
|
-
const child = rebuildService(entry.file, entry.service);
|
|
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);
|
|
245
|
-
render(state);
|
|
246
|
-
|
|
247
|
-
child.on('close', () => {
|
|
248
|
-
state.rebuilding.delete(sk);
|
|
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);
|
|
259
|
-
if (state.mode === MODE.LIST) render(state);
|
|
260
|
-
});
|
|
261
|
-
}
|
|
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
|
-
|
|
295
|
-
function doRestart(state) {
|
|
296
|
-
const entry = selectedEntry(state);
|
|
297
|
-
if (!entry) return;
|
|
298
|
-
|
|
299
|
-
const sk = statusKey(entry.file, entry.service);
|
|
300
|
-
if (state.restarting.has(sk) || state.rebuilding.has(sk)) return;
|
|
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
|
-
|
|
308
|
-
const child = restartService(entry.file, entry.service);
|
|
309
|
-
state.restarting.set(sk, child);
|
|
310
|
-
|
|
311
|
-
state.bottomLogLines.set(sk, { action: 'restarting', service: entry.service, lines: [] });
|
|
312
|
-
render(state);
|
|
313
|
-
|
|
314
|
-
child.on('close', () => {
|
|
315
|
-
state.restarting.delete(sk);
|
|
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);
|
|
326
|
-
if (state.mode === MODE.LIST) render(state);
|
|
327
|
-
});
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
function enterLogs(state) {
|
|
331
|
-
const entry = selectedEntry(state);
|
|
332
|
-
if (!entry) return;
|
|
333
|
-
|
|
334
|
-
if (logFetchTimer) {
|
|
335
|
-
clearTimeout(logFetchTimer);
|
|
336
|
-
logFetchTimer = null;
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
state.mode = MODE.LOGS;
|
|
340
|
-
state.logLines = [];
|
|
341
|
-
state.logScrollOffset = 0;
|
|
342
|
-
state.logAutoScroll = true;
|
|
343
|
-
|
|
344
|
-
const child = tailLogs(entry.file, entry.service, state.config.logTailLines);
|
|
345
|
-
state.logChild = child;
|
|
346
|
-
|
|
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
|
-
};
|
|
368
|
-
|
|
369
|
-
child.stdout.on('data', onData);
|
|
370
|
-
child.stderr.on('data', onData);
|
|
371
|
-
child.on('close', () => {
|
|
372
|
-
if (state.logChild === child) {
|
|
373
|
-
state.logChild = null;
|
|
374
|
-
}
|
|
375
|
-
});
|
|
376
|
-
|
|
377
|
-
render(state);
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
function exitLogs(state) {
|
|
381
|
-
if (state.logChild) {
|
|
382
|
-
state.logChild.kill('SIGTERM');
|
|
383
|
-
state.logChild = null;
|
|
384
|
-
}
|
|
385
|
-
state.logLines = [];
|
|
386
|
-
state.mode = MODE.LIST;
|
|
387
|
-
pollStatuses(state);
|
|
388
|
-
render(state);
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
// --- Input Handling ---
|
|
392
|
-
|
|
393
|
-
function handleKeypress(state, key) {
|
|
394
|
-
// Ctrl+C always quits
|
|
395
|
-
if (key === '\x03') {
|
|
396
|
-
cleanup(state);
|
|
397
|
-
process.exit(0);
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
if (state.mode === MODE.LOGS) {
|
|
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':
|
|
411
|
-
cleanup(state);
|
|
412
|
-
process.exit(0);
|
|
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;
|
|
443
|
-
}
|
|
444
|
-
return;
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
// LIST mode
|
|
448
|
-
switch (key) {
|
|
449
|
-
case 'j':
|
|
450
|
-
case '\x1b[B': // Arrow Down
|
|
451
|
-
moveCursor(state, 1);
|
|
452
|
-
updateSelectedLogs(state);
|
|
453
|
-
render(state);
|
|
454
|
-
break;
|
|
455
|
-
case 'k':
|
|
456
|
-
case '\x1b[A': // Arrow Up
|
|
457
|
-
moveCursor(state, -1);
|
|
458
|
-
updateSelectedLogs(state);
|
|
459
|
-
render(state);
|
|
460
|
-
break;
|
|
461
|
-
case 'r':
|
|
462
|
-
doRebuild(state);
|
|
463
|
-
break;
|
|
464
|
-
case 's':
|
|
465
|
-
doRestart(state);
|
|
466
|
-
break;
|
|
467
|
-
case 'f':
|
|
468
|
-
case '\r': // Enter
|
|
469
|
-
enterLogs(state);
|
|
470
|
-
break;
|
|
471
|
-
case 'l':
|
|
472
|
-
state.showBottomLogs = !state.showBottomLogs;
|
|
473
|
-
render(state);
|
|
474
|
-
break;
|
|
475
|
-
case 'q':
|
|
476
|
-
cleanup(state);
|
|
477
|
-
process.exit(0);
|
|
478
|
-
break;
|
|
479
|
-
case 'G': // vim: go to bottom
|
|
480
|
-
state.cursor = state.flatList.length - 1;
|
|
481
|
-
updateSelectedLogs(state);
|
|
482
|
-
render(state);
|
|
483
|
-
break;
|
|
484
|
-
case 'g': // gg handled via double-tap buffer below
|
|
485
|
-
break;
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
// --- Arrow key sequence buffering ---
|
|
490
|
-
|
|
491
|
-
function createInputHandler(state) {
|
|
492
|
-
let buf = '';
|
|
493
|
-
let gPending = false;
|
|
494
|
-
|
|
495
|
-
return function onData(data) {
|
|
496
|
-
const str = data.toString();
|
|
497
|
-
|
|
498
|
-
// Handle escape sequences (arrow keys)
|
|
499
|
-
buf += str;
|
|
500
|
-
|
|
501
|
-
while (buf.length > 0) {
|
|
502
|
-
// Check for escape sequences
|
|
503
|
-
if (buf === '\x1b') {
|
|
504
|
-
// Could be start of escape sequence — wait for more
|
|
505
|
-
setTimeout(() => {
|
|
506
|
-
if (buf === '\x1b') {
|
|
507
|
-
handleKeypress(state, '\x1b');
|
|
508
|
-
buf = '';
|
|
509
|
-
}
|
|
510
|
-
}, 50);
|
|
511
|
-
return;
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
if (buf.startsWith('\x1b[A')) {
|
|
515
|
-
handleKeypress(state, '\x1b[A');
|
|
516
|
-
buf = buf.slice(3);
|
|
517
|
-
continue;
|
|
518
|
-
}
|
|
519
|
-
if (buf.startsWith('\x1b[B')) {
|
|
520
|
-
handleKeypress(state, '\x1b[B');
|
|
521
|
-
buf = buf.slice(3);
|
|
522
|
-
continue;
|
|
523
|
-
}
|
|
524
|
-
if (buf.startsWith('\x1b[')) {
|
|
525
|
-
// Unknown escape sequence — skip it
|
|
526
|
-
buf = buf.slice(buf.length);
|
|
527
|
-
continue;
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
// Single character
|
|
531
|
-
const ch = buf[0];
|
|
532
|
-
buf = buf.slice(1);
|
|
533
|
-
|
|
534
|
-
// Handle gg (go to top)
|
|
535
|
-
if (ch === 'g') {
|
|
536
|
-
if (gPending) {
|
|
537
|
-
gPending = false;
|
|
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
|
-
}
|
|
546
|
-
render(state);
|
|
547
|
-
continue;
|
|
548
|
-
}
|
|
549
|
-
gPending = true;
|
|
550
|
-
setTimeout(() => {
|
|
551
|
-
if (gPending) {
|
|
552
|
-
gPending = false;
|
|
553
|
-
// Single g — ignore
|
|
554
|
-
}
|
|
555
|
-
}, 300);
|
|
556
|
-
continue;
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
gPending = false;
|
|
560
|
-
handleKeypress(state, ch);
|
|
561
|
-
}
|
|
562
|
-
};
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
// --- Cleanup ---
|
|
566
|
-
|
|
567
|
-
function cleanup(state) {
|
|
568
|
-
if (state.logChild) {
|
|
569
|
-
state.logChild.kill('SIGTERM');
|
|
570
|
-
state.logChild = null;
|
|
571
|
-
}
|
|
572
|
-
for (const [, child] of state.rebuilding) {
|
|
573
|
-
child.kill('SIGTERM');
|
|
574
|
-
}
|
|
575
|
-
state.rebuilding.clear();
|
|
576
|
-
for (const [, child] of state.restarting) {
|
|
577
|
-
child.kill('SIGTERM');
|
|
578
|
-
}
|
|
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
|
-
}
|
|
591
|
-
if (state.pollTimer) {
|
|
592
|
-
clearInterval(state.pollTimer);
|
|
593
|
-
}
|
|
594
|
-
process.stdout.write('\x1b[r' + showCursor() + '\x1b[0m');
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
// --- Main ---
|
|
598
|
-
|
|
599
|
-
function main() {
|
|
600
|
-
const config = loadConfig();
|
|
601
|
-
const state = createState(config);
|
|
602
|
-
|
|
603
|
-
// Discover services
|
|
604
|
-
state.groups = discoverServices(config);
|
|
605
|
-
state.flatList = buildFlatList(state.groups);
|
|
606
|
-
|
|
607
|
-
if (state.flatList.length === 0) {
|
|
608
|
-
process.stderr.write('No services found in any compose file.\n');
|
|
609
|
-
process.exit(1);
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
// Initial status poll
|
|
613
|
-
pollStatuses(state);
|
|
614
|
-
|
|
615
|
-
// Setup terminal
|
|
616
|
-
if (process.stdin.isTTY) {
|
|
617
|
-
process.stdin.setRawMode(true);
|
|
618
|
-
}
|
|
619
|
-
process.stdin.resume();
|
|
620
|
-
process.stdin.setEncoding('utf8');
|
|
621
|
-
process.stdin.on('data', createInputHandler(state));
|
|
622
|
-
|
|
623
|
-
// Initial log pattern scan
|
|
624
|
-
pollLogCounts(state);
|
|
625
|
-
|
|
626
|
-
// Start log tail for initially selected container and render
|
|
627
|
-
updateSelectedLogs(state);
|
|
628
|
-
render(state);
|
|
629
|
-
|
|
630
|
-
// Poll loop
|
|
631
|
-
state.pollTimer = setInterval(() => {
|
|
632
|
-
if (state.mode === MODE.LIST) {
|
|
633
|
-
pollStatuses(state);
|
|
634
|
-
render(state);
|
|
635
|
-
}
|
|
636
|
-
}, config.pollInterval);
|
|
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
|
-
|
|
645
|
-
// Terminal resize
|
|
646
|
-
process.stdout.on('resize', () => {
|
|
647
|
-
render(state);
|
|
648
|
-
});
|
|
649
|
-
|
|
650
|
-
// Cleanup on exit
|
|
651
|
-
process.on('exit', () => cleanup(state));
|
|
652
|
-
process.on('SIGINT', () => {
|
|
653
|
-
cleanup(state);
|
|
654
|
-
process.exit(0);
|
|
655
|
-
});
|
|
656
|
-
process.on('SIGTERM', () => {
|
|
657
|
-
cleanup(state);
|
|
658
|
-
process.exit(0);
|
|
659
|
-
});
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
main();
|