recomposable 1.1.0 → 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 +33 -0
- 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/index.js +0 -958
- package/lib/docker.js +0 -231
- package/lib/renderer.js +0 -447
- package/lib/state.js +0 -63
package/index.js
DELETED
|
@@ -1,958 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
'use strict';
|
|
3
|
-
|
|
4
|
-
const fs = require('fs');
|
|
5
|
-
const path = require('path');
|
|
6
|
-
const { listServices, getStatuses, rebuildService, restartService, stopService, startService, tailLogs, getContainerId, tailContainerLogs, fetchContainerLogs, fetchContainerStats, parseStatsLine } = require('./lib/docker');
|
|
7
|
-
const { MODE, createState, statusKey, buildFlatList, moveCursor, selectedEntry } = require('./lib/state');
|
|
8
|
-
const { clearScreen, showCursor, renderListView, renderLogView } = require('./lib/renderer');
|
|
9
|
-
|
|
10
|
-
// --- Config ---
|
|
11
|
-
|
|
12
|
-
function loadConfig() {
|
|
13
|
-
const defaults = { composeFiles: [], pollInterval: 3000, logTailLines: 100, logScanPatterns: ['WRN]', 'ERR]'], logScanLines: 1000, logScanInterval: 10000, statsInterval: 5000, statsBufferSize: 6, bottomLogCount: 10, cpuWarnThreshold: 50, cpuDangerThreshold: 100, memWarnThreshold: 512, memDangerThreshold: 1024 };
|
|
14
|
-
|
|
15
|
-
// Load from recomposable.json in current working directory
|
|
16
|
-
const configPath = path.join(process.cwd(), 'recomposable.json');
|
|
17
|
-
if (fs.existsSync(configPath)) {
|
|
18
|
-
Object.assign(defaults, JSON.parse(fs.readFileSync(configPath, 'utf8')));
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
// CLI overrides: -f <file> can be repeated
|
|
22
|
-
const args = process.argv.slice(2);
|
|
23
|
-
const cliFiles = [];
|
|
24
|
-
for (let i = 0; i < args.length; i++) {
|
|
25
|
-
if (args[i] === '-f' && args[i + 1]) {
|
|
26
|
-
cliFiles.push(args[++i]);
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
if (cliFiles.length > 0) {
|
|
30
|
-
defaults.composeFiles = cliFiles;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
if (defaults.composeFiles.length === 0) {
|
|
34
|
-
process.stderr.write('No compose files configured. Add them to recomposable.json or pass -f <file>.\n');
|
|
35
|
-
process.exit(1);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
return defaults;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// --- Service Discovery ---
|
|
42
|
-
|
|
43
|
-
function discoverServices(config) {
|
|
44
|
-
const groups = [];
|
|
45
|
-
for (const file of config.composeFiles) {
|
|
46
|
-
const resolved = path.resolve(file);
|
|
47
|
-
const label = path.basename(file, path.extname(file)).replace(/^docker-compose\.?/, '') || path.basename(file);
|
|
48
|
-
let services = [];
|
|
49
|
-
let error = null;
|
|
50
|
-
try {
|
|
51
|
-
services = listServices(resolved);
|
|
52
|
-
} catch (e) {
|
|
53
|
-
error = e.message.split('\n')[0].substring(0, 60);
|
|
54
|
-
}
|
|
55
|
-
groups.push({ file: resolved, label, services, error });
|
|
56
|
-
}
|
|
57
|
-
return groups;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
// --- Status Polling ---
|
|
61
|
-
|
|
62
|
-
function pollStatuses(state) {
|
|
63
|
-
for (const group of state.groups) {
|
|
64
|
-
if (group.error) continue;
|
|
65
|
-
const statuses = getStatuses(group.file);
|
|
66
|
-
for (const [svc, st] of statuses) {
|
|
67
|
-
state.statuses.set(statusKey(group.file, svc), st);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// --- Log Pattern Scanning ---
|
|
73
|
-
|
|
74
|
-
let logScanActive = false;
|
|
75
|
-
|
|
76
|
-
function pollLogCounts(state) {
|
|
77
|
-
if (logScanActive) return;
|
|
78
|
-
const scanPatterns = state.config.logScanPatterns || [];
|
|
79
|
-
if (scanPatterns.length === 0) return;
|
|
80
|
-
const tailLines = state.config.logScanLines || 1000;
|
|
81
|
-
|
|
82
|
-
const toScan = [];
|
|
83
|
-
for (const group of state.groups) {
|
|
84
|
-
if (group.error) continue;
|
|
85
|
-
for (const service of group.services) {
|
|
86
|
-
const sk = statusKey(group.file, service);
|
|
87
|
-
const st = state.statuses.get(sk);
|
|
88
|
-
if (!st || st.state !== 'running' || !st.id) continue;
|
|
89
|
-
toScan.push({ sk, containerId: st.id });
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
if (toScan.length === 0) return;
|
|
94
|
-
logScanActive = true;
|
|
95
|
-
let remaining = toScan.length;
|
|
96
|
-
|
|
97
|
-
for (const { sk, containerId } of toScan) {
|
|
98
|
-
const child = fetchContainerLogs(containerId, tailLines);
|
|
99
|
-
let output = '';
|
|
100
|
-
child.stdout.on('data', (d) => { output += d.toString(); });
|
|
101
|
-
child.stderr.on('data', (d) => { output += d.toString(); });
|
|
102
|
-
child.on('close', () => {
|
|
103
|
-
const counts = new Map();
|
|
104
|
-
for (const pattern of scanPatterns) {
|
|
105
|
-
let count = 0;
|
|
106
|
-
let idx = 0;
|
|
107
|
-
while ((idx = output.indexOf(pattern, idx)) !== -1) {
|
|
108
|
-
count++;
|
|
109
|
-
idx += pattern.length;
|
|
110
|
-
}
|
|
111
|
-
counts.set(pattern, count);
|
|
112
|
-
}
|
|
113
|
-
state.logCounts.set(sk, counts);
|
|
114
|
-
remaining--;
|
|
115
|
-
if (remaining === 0) {
|
|
116
|
-
logScanActive = false;
|
|
117
|
-
if (state.mode === MODE.LIST) throttledRender(state);
|
|
118
|
-
}
|
|
119
|
-
});
|
|
120
|
-
child.on('error', () => {
|
|
121
|
-
remaining--;
|
|
122
|
-
if (remaining === 0) {
|
|
123
|
-
logScanActive = false;
|
|
124
|
-
if (state.mode === MODE.LIST) throttledRender(state);
|
|
125
|
-
}
|
|
126
|
-
});
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// --- Stats Polling ---
|
|
131
|
-
|
|
132
|
-
let statsPollActive = false;
|
|
133
|
-
|
|
134
|
-
function pollContainerStats(state) {
|
|
135
|
-
if (statsPollActive) return;
|
|
136
|
-
|
|
137
|
-
const idToKey = new Map();
|
|
138
|
-
for (const group of state.groups) {
|
|
139
|
-
if (group.error) continue;
|
|
140
|
-
for (const service of group.services) {
|
|
141
|
-
const sk = statusKey(group.file, service);
|
|
142
|
-
const st = state.statuses.get(sk);
|
|
143
|
-
if (!st || st.state !== 'running' || !st.id) continue;
|
|
144
|
-
idToKey.set(st.id, sk);
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
const ids = [...idToKey.keys()];
|
|
149
|
-
if (ids.length === 0) return;
|
|
150
|
-
|
|
151
|
-
statsPollActive = true;
|
|
152
|
-
const child = fetchContainerStats(ids);
|
|
153
|
-
let output = '';
|
|
154
|
-
child.stdout.on('data', (d) => { output += d.toString(); });
|
|
155
|
-
child.stderr.on('data', () => {});
|
|
156
|
-
child.on('close', () => {
|
|
157
|
-
statsPollActive = false;
|
|
158
|
-
const bufferSize = state.config.statsBufferSize || 6;
|
|
159
|
-
|
|
160
|
-
for (const line of output.trim().split('\n')) {
|
|
161
|
-
if (!line.trim()) continue;
|
|
162
|
-
const parsed = parseStatsLine(line);
|
|
163
|
-
if (!parsed) continue;
|
|
164
|
-
|
|
165
|
-
// Find the statusKey for this container ID
|
|
166
|
-
let sk = null;
|
|
167
|
-
for (const [id, key] of idToKey) {
|
|
168
|
-
if (parsed.id.startsWith(id) || id.startsWith(parsed.id)) {
|
|
169
|
-
sk = key;
|
|
170
|
-
break;
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
if (!sk) continue;
|
|
174
|
-
|
|
175
|
-
// Update circular buffer
|
|
176
|
-
if (!state.containerStatsHistory.has(sk)) {
|
|
177
|
-
state.containerStatsHistory.set(sk, { cpu: new Array(bufferSize).fill(0), mem: new Array(bufferSize).fill(0), idx: 0, count: 0 });
|
|
178
|
-
}
|
|
179
|
-
const hist = state.containerStatsHistory.get(sk);
|
|
180
|
-
hist.cpu[hist.idx] = parsed.cpuPercent;
|
|
181
|
-
hist.mem[hist.idx] = parsed.memUsageBytes;
|
|
182
|
-
hist.idx = (hist.idx + 1) % bufferSize;
|
|
183
|
-
hist.count = Math.min(hist.count + 1, bufferSize);
|
|
184
|
-
|
|
185
|
-
// Compute rolling average
|
|
186
|
-
let cpuSum = 0, memSum = 0;
|
|
187
|
-
for (let i = 0; i < hist.count; i++) {
|
|
188
|
-
cpuSum += hist.cpu[i];
|
|
189
|
-
memSum += hist.mem[i];
|
|
190
|
-
}
|
|
191
|
-
state.containerStats.set(sk, {
|
|
192
|
-
cpuPercent: cpuSum / hist.count,
|
|
193
|
-
memUsageBytes: memSum / hist.count,
|
|
194
|
-
});
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
if (state.mode === MODE.LIST) throttledRender(state);
|
|
198
|
-
});
|
|
199
|
-
child.on('error', () => {
|
|
200
|
-
statsPollActive = false;
|
|
201
|
-
});
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// --- Rendering ---
|
|
205
|
-
|
|
206
|
-
function render(state) {
|
|
207
|
-
let output = clearScreen();
|
|
208
|
-
if (state.mode === MODE.LIST) {
|
|
209
|
-
output += renderListView(state);
|
|
210
|
-
} else if (state.mode === MODE.LOGS) {
|
|
211
|
-
output += renderLogView(state);
|
|
212
|
-
}
|
|
213
|
-
process.stdout.write(output);
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function stripAnsi(str) {
|
|
217
|
-
return str.replace(/\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[^[\]]/g, '');
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
let lastRenderTime = 0;
|
|
221
|
-
let pendingRender = null;
|
|
222
|
-
let logFetchTimer = null;
|
|
223
|
-
|
|
224
|
-
function throttledRender(state) {
|
|
225
|
-
const now = Date.now();
|
|
226
|
-
const elapsed = now - lastRenderTime;
|
|
227
|
-
if (elapsed >= 150) {
|
|
228
|
-
lastRenderTime = now;
|
|
229
|
-
render(state);
|
|
230
|
-
} else if (!pendingRender) {
|
|
231
|
-
pendingRender = setTimeout(() => {
|
|
232
|
-
pendingRender = null;
|
|
233
|
-
lastRenderTime = Date.now();
|
|
234
|
-
render(state);
|
|
235
|
-
}, 150 - elapsed);
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// --- Actions ---
|
|
240
|
-
|
|
241
|
-
function updateSelectedLogs(state) {
|
|
242
|
-
const entry = selectedEntry(state);
|
|
243
|
-
if (!entry) return;
|
|
244
|
-
|
|
245
|
-
const sk = statusKey(entry.file, entry.service);
|
|
246
|
-
|
|
247
|
-
// Same container already selected, nothing to do
|
|
248
|
-
if (state.selectedLogKey === sk) return;
|
|
249
|
-
|
|
250
|
-
// Clear bottom search when switching services
|
|
251
|
-
state.bottomSearchQuery = '';
|
|
252
|
-
state.bottomSearchActive = false;
|
|
253
|
-
|
|
254
|
-
// Cancel any pending debounced log fetch
|
|
255
|
-
if (logFetchTimer) {
|
|
256
|
-
clearTimeout(logFetchTimer);
|
|
257
|
-
logFetchTimer = null;
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
// Clean up previous selected container's passive log tail
|
|
261
|
-
if (state.selectedLogKey) {
|
|
262
|
-
const oldInfo = state.bottomLogLines.get(state.selectedLogKey);
|
|
263
|
-
if (oldInfo && (oldInfo.action === 'logs' || oldInfo.action === 'started')) {
|
|
264
|
-
if (!state.rebuilding.has(state.selectedLogKey) && !state.restarting.has(state.selectedLogKey)) {
|
|
265
|
-
state.bottomLogLines.delete(state.selectedLogKey);
|
|
266
|
-
if (state.bottomLogTails.has(state.selectedLogKey)) {
|
|
267
|
-
state.bottomLogTails.get(state.selectedLogKey).kill('SIGTERM');
|
|
268
|
-
state.bottomLogTails.delete(state.selectedLogKey);
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
state.selectedLogKey = sk;
|
|
275
|
-
|
|
276
|
-
// If this container already has active action logs (rebuild/restart/started), keep those
|
|
277
|
-
if (state.bottomLogLines.has(sk)) return;
|
|
278
|
-
|
|
279
|
-
// Set up empty log entry immediately so the UI shows the container name
|
|
280
|
-
state.bottomLogLines.set(sk, { action: 'logs', service: entry.service, lines: [] });
|
|
281
|
-
|
|
282
|
-
// Debounce the expensive log fetch (getContainerId is a blocking execFileSync)
|
|
283
|
-
logFetchTimer = setTimeout(() => {
|
|
284
|
-
logFetchTimer = null;
|
|
285
|
-
startBottomLogTail(state, sk, entry.file, entry.service);
|
|
286
|
-
}, 500);
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
function doRebuild(state) {
|
|
290
|
-
const entry = selectedEntry(state);
|
|
291
|
-
if (!entry) return;
|
|
292
|
-
|
|
293
|
-
const sk = statusKey(entry.file, entry.service);
|
|
294
|
-
if (state.rebuilding.has(sk)) return;
|
|
295
|
-
|
|
296
|
-
// Kill any existing startup log tail for this service
|
|
297
|
-
if (state.bottomLogTails.has(sk)) {
|
|
298
|
-
state.bottomLogTails.get(sk).kill('SIGTERM');
|
|
299
|
-
state.bottomLogTails.delete(sk);
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
const child = rebuildService(entry.file, entry.service, { noCache: state.noCache });
|
|
303
|
-
state.rebuilding.set(sk, child);
|
|
304
|
-
|
|
305
|
-
state.bottomLogLines.set(sk, { action: 'rebuilding', service: entry.service, lines: [] });
|
|
306
|
-
|
|
307
|
-
let lineBuf = '';
|
|
308
|
-
const onData = (data) => {
|
|
309
|
-
const info = state.bottomLogLines.get(sk);
|
|
310
|
-
if (!info) return;
|
|
311
|
-
lineBuf += data.toString();
|
|
312
|
-
const parts = lineBuf.split(/\r?\n|\r/);
|
|
313
|
-
lineBuf = parts.pop();
|
|
314
|
-
const newLines = parts.filter(l => l.trim().length > 0).map(stripAnsi).filter(Boolean);
|
|
315
|
-
if (newLines.length === 0) return;
|
|
316
|
-
info.lines.push(...newLines);
|
|
317
|
-
const maxLines = state.config.bottomLogCount || 10;
|
|
318
|
-
if (info.lines.length > maxLines) info.lines = info.lines.slice(-maxLines);
|
|
319
|
-
if (state.mode === MODE.LIST) throttledRender(state);
|
|
320
|
-
};
|
|
321
|
-
|
|
322
|
-
child.stdout.on('data', onData);
|
|
323
|
-
child.stderr.on('data', onData);
|
|
324
|
-
render(state);
|
|
325
|
-
|
|
326
|
-
child.on('close', () => {
|
|
327
|
-
state.rebuilding.delete(sk);
|
|
328
|
-
state.containerStatsHistory.delete(sk);
|
|
329
|
-
state.containerStats.delete(sk);
|
|
330
|
-
pollStatuses(state);
|
|
331
|
-
|
|
332
|
-
// Show container application logs after rebuild+start
|
|
333
|
-
const info = state.bottomLogLines.get(sk);
|
|
334
|
-
if (info) {
|
|
335
|
-
info.action = 'started';
|
|
336
|
-
info.lines = [];
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
startBottomLogTail(state, sk, entry.file, entry.service);
|
|
340
|
-
if (state.mode === MODE.LIST) render(state);
|
|
341
|
-
});
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
function startBottomLogTail(state, sk, file, service) {
|
|
345
|
-
// Kill any existing tail for this service
|
|
346
|
-
if (state.bottomLogTails.has(sk)) {
|
|
347
|
-
state.bottomLogTails.get(sk).kill('SIGTERM');
|
|
348
|
-
state.bottomLogTails.delete(sk);
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
// Get container ID and use docker logs directly (avoids compose buffering)
|
|
352
|
-
const containerId = getContainerId(file, service);
|
|
353
|
-
if (!containerId) return;
|
|
354
|
-
|
|
355
|
-
const maxLines = state.config.bottomLogCount || 10;
|
|
356
|
-
const logChild = tailContainerLogs(containerId, maxLines);
|
|
357
|
-
state.bottomLogTails.set(sk, logChild);
|
|
358
|
-
|
|
359
|
-
let buf = '';
|
|
360
|
-
const onData = (data) => {
|
|
361
|
-
const info = state.bottomLogLines.get(sk);
|
|
362
|
-
if (!info) return;
|
|
363
|
-
buf += data.toString();
|
|
364
|
-
const parts = buf.split(/\r?\n|\r/);
|
|
365
|
-
buf = parts.pop();
|
|
366
|
-
const newLines = parts.filter(l => l.trim().length > 0).map(stripAnsi).filter(Boolean);
|
|
367
|
-
if (newLines.length === 0) return;
|
|
368
|
-
info.lines.push(...newLines);
|
|
369
|
-
if (info.lines.length > maxLines) info.lines = info.lines.slice(-maxLines);
|
|
370
|
-
if (state.mode === MODE.LIST) throttledRender(state);
|
|
371
|
-
};
|
|
372
|
-
|
|
373
|
-
logChild.stdout.on('data', onData);
|
|
374
|
-
logChild.stderr.on('data', onData);
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
function doRestart(state) {
|
|
378
|
-
const entry = selectedEntry(state);
|
|
379
|
-
if (!entry) return;
|
|
380
|
-
|
|
381
|
-
const sk = statusKey(entry.file, entry.service);
|
|
382
|
-
if (state.restarting.has(sk) || state.rebuilding.has(sk)) return;
|
|
383
|
-
|
|
384
|
-
// Kill any existing startup log tail for this service
|
|
385
|
-
if (state.bottomLogTails.has(sk)) {
|
|
386
|
-
state.bottomLogTails.get(sk).kill('SIGTERM');
|
|
387
|
-
state.bottomLogTails.delete(sk);
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
const child = restartService(entry.file, entry.service);
|
|
391
|
-
state.restarting.set(sk, child);
|
|
392
|
-
|
|
393
|
-
state.bottomLogLines.set(sk, { action: 'restarting', service: entry.service, lines: [] });
|
|
394
|
-
render(state);
|
|
395
|
-
|
|
396
|
-
child.on('close', () => {
|
|
397
|
-
state.restarting.delete(sk);
|
|
398
|
-
state.containerStatsHistory.delete(sk);
|
|
399
|
-
state.containerStats.delete(sk);
|
|
400
|
-
pollStatuses(state);
|
|
401
|
-
|
|
402
|
-
// Show container application logs after restart
|
|
403
|
-
const info = state.bottomLogLines.get(sk);
|
|
404
|
-
if (info) {
|
|
405
|
-
info.action = 'started';
|
|
406
|
-
info.lines = [];
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
startBottomLogTail(state, sk, entry.file, entry.service);
|
|
410
|
-
if (state.mode === MODE.LIST) render(state);
|
|
411
|
-
});
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
function doStop(state) {
|
|
415
|
-
const entry = selectedEntry(state);
|
|
416
|
-
if (!entry) return;
|
|
417
|
-
|
|
418
|
-
const sk = statusKey(entry.file, entry.service);
|
|
419
|
-
if (state.stopping.has(sk) || state.rebuilding.has(sk) || state.restarting.has(sk)) return;
|
|
420
|
-
|
|
421
|
-
// Only stop running containers
|
|
422
|
-
const st = state.statuses.get(sk);
|
|
423
|
-
if (!st || st.state !== 'running') return;
|
|
424
|
-
|
|
425
|
-
// Kill any existing log tail for this service
|
|
426
|
-
if (state.bottomLogTails.has(sk)) {
|
|
427
|
-
state.bottomLogTails.get(sk).kill('SIGTERM');
|
|
428
|
-
state.bottomLogTails.delete(sk);
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
const child = stopService(entry.file, entry.service);
|
|
432
|
-
state.stopping.set(sk, child);
|
|
433
|
-
state.bottomLogLines.set(sk, { action: 'stopping', service: entry.service, lines: [] });
|
|
434
|
-
render(state);
|
|
435
|
-
|
|
436
|
-
child.on('close', () => {
|
|
437
|
-
state.stopping.delete(sk);
|
|
438
|
-
state.bottomLogLines.delete(sk);
|
|
439
|
-
pollStatuses(state);
|
|
440
|
-
if (state.mode === MODE.LIST) render(state);
|
|
441
|
-
});
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
function doStart(state) {
|
|
445
|
-
const entry = selectedEntry(state);
|
|
446
|
-
if (!entry) return;
|
|
447
|
-
|
|
448
|
-
const sk = statusKey(entry.file, entry.service);
|
|
449
|
-
if (state.starting.has(sk) || state.rebuilding.has(sk) || state.restarting.has(sk) || state.stopping.has(sk)) return;
|
|
450
|
-
|
|
451
|
-
// Only start stopped/exited containers
|
|
452
|
-
const st = state.statuses.get(sk);
|
|
453
|
-
if (st && st.state === 'running') return;
|
|
454
|
-
|
|
455
|
-
const child = startService(entry.file, entry.service);
|
|
456
|
-
state.starting.set(sk, child);
|
|
457
|
-
state.bottomLogLines.set(sk, { action: 'starting', service: entry.service, lines: [] });
|
|
458
|
-
render(state);
|
|
459
|
-
|
|
460
|
-
child.on('close', () => {
|
|
461
|
-
state.starting.delete(sk);
|
|
462
|
-
pollStatuses(state);
|
|
463
|
-
|
|
464
|
-
const info = state.bottomLogLines.get(sk);
|
|
465
|
-
if (info) {
|
|
466
|
-
info.action = 'started';
|
|
467
|
-
info.lines = [];
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
startBottomLogTail(state, sk, entry.file, entry.service);
|
|
471
|
-
if (state.mode === MODE.LIST) render(state);
|
|
472
|
-
});
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
function enterLogs(state) {
|
|
476
|
-
const entry = selectedEntry(state);
|
|
477
|
-
if (!entry) return;
|
|
478
|
-
|
|
479
|
-
if (logFetchTimer) {
|
|
480
|
-
clearTimeout(logFetchTimer);
|
|
481
|
-
logFetchTimer = null;
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
state.mode = MODE.LOGS;
|
|
485
|
-
state.logLines = [];
|
|
486
|
-
state.logScrollOffset = 0;
|
|
487
|
-
state.logAutoScroll = true;
|
|
488
|
-
state.logSearchQuery = '';
|
|
489
|
-
state.logSearchActive = false;
|
|
490
|
-
state.logSearchMatches = [];
|
|
491
|
-
state.logSearchMatchIdx = -1;
|
|
492
|
-
|
|
493
|
-
const child = tailLogs(entry.file, entry.service, state.config.logTailLines);
|
|
494
|
-
state.logChild = child;
|
|
495
|
-
|
|
496
|
-
let lineBuf = '';
|
|
497
|
-
const onData = (data) => {
|
|
498
|
-
lineBuf += data.toString();
|
|
499
|
-
const parts = lineBuf.split(/\r?\n|\r/);
|
|
500
|
-
lineBuf = parts.pop();
|
|
501
|
-
if (parts.length === 0) return;
|
|
502
|
-
for (const line of parts) {
|
|
503
|
-
state.logLines.push(stripAnsi(line));
|
|
504
|
-
}
|
|
505
|
-
// Cap buffer at 10000 lines
|
|
506
|
-
if (state.logLines.length > 10000) {
|
|
507
|
-
const excess = state.logLines.length - 10000;
|
|
508
|
-
state.logLines.splice(0, excess);
|
|
509
|
-
if (!state.logAutoScroll) {
|
|
510
|
-
state.logScrollOffset = Math.max(0, state.logScrollOffset - excess);
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
if (state.logAutoScroll) {
|
|
514
|
-
throttledRender(state);
|
|
515
|
-
}
|
|
516
|
-
};
|
|
517
|
-
|
|
518
|
-
child.stdout.on('data', onData);
|
|
519
|
-
child.stderr.on('data', onData);
|
|
520
|
-
child.on('close', () => {
|
|
521
|
-
if (state.logChild === child) {
|
|
522
|
-
state.logChild = null;
|
|
523
|
-
}
|
|
524
|
-
});
|
|
525
|
-
|
|
526
|
-
render(state);
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
function exitLogs(state) {
|
|
530
|
-
if (state.logChild) {
|
|
531
|
-
state.logChild.kill('SIGTERM');
|
|
532
|
-
state.logChild = null;
|
|
533
|
-
}
|
|
534
|
-
state.logLines = [];
|
|
535
|
-
state.mode = MODE.LIST;
|
|
536
|
-
pollStatuses(state);
|
|
537
|
-
render(state);
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
// --- Log Search ---
|
|
541
|
-
|
|
542
|
-
function executeLogSearch(state) {
|
|
543
|
-
const query = state.logSearchQuery;
|
|
544
|
-
state.logSearchMatches = [];
|
|
545
|
-
state.logSearchMatchIdx = -1;
|
|
546
|
-
if (!query) return;
|
|
547
|
-
|
|
548
|
-
const lowerQuery = query.toLowerCase();
|
|
549
|
-
for (let i = 0; i < state.logLines.length; i++) {
|
|
550
|
-
if (state.logLines[i].toLowerCase().includes(lowerQuery)) {
|
|
551
|
-
state.logSearchMatches.push(i);
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
if (state.logSearchMatches.length > 0) {
|
|
556
|
-
state.logSearchMatchIdx = 0;
|
|
557
|
-
scrollToLogLine(state, state.logSearchMatches[0]);
|
|
558
|
-
}
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
function scrollToLogLine(state, lineIdx) {
|
|
562
|
-
const { rows = 24 } = process.stdout;
|
|
563
|
-
const headerHeight = 9; // logo + separator + legend + info line
|
|
564
|
-
const availableRows = Math.max(1, rows - headerHeight);
|
|
565
|
-
const totalLines = state.logLines.length;
|
|
566
|
-
|
|
567
|
-
// logScrollOffset is lines from bottom
|
|
568
|
-
state.logScrollOffset = Math.max(0, totalLines - lineIdx - Math.floor(availableRows / 2));
|
|
569
|
-
state.logAutoScroll = state.logScrollOffset === 0;
|
|
570
|
-
render(state);
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
function jumpToNextMatch(state) {
|
|
574
|
-
if (state.logSearchMatches.length === 0) return;
|
|
575
|
-
state.logSearchMatchIdx = (state.logSearchMatchIdx + 1) % state.logSearchMatches.length;
|
|
576
|
-
scrollToLogLine(state, state.logSearchMatches[state.logSearchMatchIdx]);
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
function jumpToPrevMatch(state) {
|
|
580
|
-
if (state.logSearchMatches.length === 0) return;
|
|
581
|
-
state.logSearchMatchIdx = (state.logSearchMatchIdx - 1 + state.logSearchMatches.length) % state.logSearchMatches.length;
|
|
582
|
-
scrollToLogLine(state, state.logSearchMatches[state.logSearchMatchIdx]);
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
// --- Input Handling ---
|
|
586
|
-
|
|
587
|
-
function handleKeypress(state, key) {
|
|
588
|
-
// Ctrl+C always quits
|
|
589
|
-
if (key === '\x03') {
|
|
590
|
-
cleanup(state);
|
|
591
|
-
process.exit(0);
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
if (state.mode === MODE.LOGS) {
|
|
595
|
-
// Search input mode — capture keypresses into the search query
|
|
596
|
-
if (state.logSearchActive) {
|
|
597
|
-
if (key === '\x1b') {
|
|
598
|
-
// ESC cancels search
|
|
599
|
-
state.logSearchActive = false;
|
|
600
|
-
state.logSearchQuery = '';
|
|
601
|
-
render(state);
|
|
602
|
-
} else if (key === '\r') {
|
|
603
|
-
// Enter executes search
|
|
604
|
-
state.logSearchActive = false;
|
|
605
|
-
executeLogSearch(state);
|
|
606
|
-
render(state);
|
|
607
|
-
} else if (key === '\x7f' || key === '\b') {
|
|
608
|
-
// Backspace
|
|
609
|
-
state.logSearchQuery = state.logSearchQuery.slice(0, -1);
|
|
610
|
-
render(state);
|
|
611
|
-
} else if (key.length === 1 && key >= ' ') {
|
|
612
|
-
state.logSearchQuery += key;
|
|
613
|
-
render(state);
|
|
614
|
-
}
|
|
615
|
-
return;
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
const { rows = 24 } = process.stdout;
|
|
619
|
-
const pageSize = Math.max(1, Math.floor(rows / 2));
|
|
620
|
-
const maxOffset = Math.max(0, state.logLines.length - 1);
|
|
621
|
-
|
|
622
|
-
switch (key) {
|
|
623
|
-
case 'f':
|
|
624
|
-
case '\x1b':
|
|
625
|
-
exitLogs(state);
|
|
626
|
-
break;
|
|
627
|
-
case 'q':
|
|
628
|
-
cleanup(state);
|
|
629
|
-
process.exit(0);
|
|
630
|
-
break;
|
|
631
|
-
case 'k':
|
|
632
|
-
case '\x1b[A':
|
|
633
|
-
state.logAutoScroll = false;
|
|
634
|
-
state.logScrollOffset = Math.min(maxOffset, state.logScrollOffset + 1);
|
|
635
|
-
render(state);
|
|
636
|
-
break;
|
|
637
|
-
case 'j':
|
|
638
|
-
case '\x1b[B':
|
|
639
|
-
if (state.logScrollOffset > 0) {
|
|
640
|
-
state.logScrollOffset--;
|
|
641
|
-
if (state.logScrollOffset === 0) state.logAutoScroll = true;
|
|
642
|
-
}
|
|
643
|
-
render(state);
|
|
644
|
-
break;
|
|
645
|
-
case 'G':
|
|
646
|
-
state.logScrollOffset = 0;
|
|
647
|
-
state.logAutoScroll = true;
|
|
648
|
-
render(state);
|
|
649
|
-
break;
|
|
650
|
-
case '\x15': // Ctrl+U - page up
|
|
651
|
-
state.logAutoScroll = false;
|
|
652
|
-
state.logScrollOffset = Math.min(maxOffset, state.logScrollOffset + pageSize);
|
|
653
|
-
render(state);
|
|
654
|
-
break;
|
|
655
|
-
case '\x04': // Ctrl+D - page down
|
|
656
|
-
state.logScrollOffset = Math.max(0, state.logScrollOffset - pageSize);
|
|
657
|
-
if (state.logScrollOffset === 0) state.logAutoScroll = true;
|
|
658
|
-
render(state);
|
|
659
|
-
break;
|
|
660
|
-
case '/':
|
|
661
|
-
state.logSearchActive = true;
|
|
662
|
-
state.logSearchQuery = '';
|
|
663
|
-
render(state);
|
|
664
|
-
break;
|
|
665
|
-
case 'n':
|
|
666
|
-
jumpToNextMatch(state);
|
|
667
|
-
break;
|
|
668
|
-
case 'N':
|
|
669
|
-
jumpToPrevMatch(state);
|
|
670
|
-
break;
|
|
671
|
-
}
|
|
672
|
-
return;
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
// LIST mode — bottom panel search input
|
|
676
|
-
if (state.bottomSearchActive) {
|
|
677
|
-
if (key === '\x1b') {
|
|
678
|
-
state.bottomSearchActive = false;
|
|
679
|
-
state.bottomSearchQuery = '';
|
|
680
|
-
render(state);
|
|
681
|
-
} else if (key === '\r') {
|
|
682
|
-
state.bottomSearchActive = false;
|
|
683
|
-
render(state);
|
|
684
|
-
} else if (key === '\x7f' || key === '\b') {
|
|
685
|
-
state.bottomSearchQuery = state.bottomSearchQuery.slice(0, -1);
|
|
686
|
-
render(state);
|
|
687
|
-
} else if (key.length === 1 && key >= ' ') {
|
|
688
|
-
state.bottomSearchQuery += key;
|
|
689
|
-
render(state);
|
|
690
|
-
}
|
|
691
|
-
return;
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
// LIST mode
|
|
695
|
-
switch (key) {
|
|
696
|
-
case 'j':
|
|
697
|
-
case '\x1b[B': // Arrow Down
|
|
698
|
-
moveCursor(state, 1);
|
|
699
|
-
updateSelectedLogs(state);
|
|
700
|
-
render(state);
|
|
701
|
-
break;
|
|
702
|
-
case 'k':
|
|
703
|
-
case '\x1b[A': // Arrow Up
|
|
704
|
-
moveCursor(state, -1);
|
|
705
|
-
updateSelectedLogs(state);
|
|
706
|
-
render(state);
|
|
707
|
-
break;
|
|
708
|
-
case 'b':
|
|
709
|
-
doRebuild(state);
|
|
710
|
-
break;
|
|
711
|
-
case 's': {
|
|
712
|
-
const sEntry = selectedEntry(state);
|
|
713
|
-
if (sEntry) {
|
|
714
|
-
const sSk = statusKey(sEntry.file, sEntry.service);
|
|
715
|
-
const sSt = state.statuses.get(sSk);
|
|
716
|
-
if (sSt && sSt.state === 'running') {
|
|
717
|
-
doRestart(state);
|
|
718
|
-
} else {
|
|
719
|
-
doStart(state);
|
|
720
|
-
}
|
|
721
|
-
}
|
|
722
|
-
break;
|
|
723
|
-
}
|
|
724
|
-
case 'p':
|
|
725
|
-
doStop(state);
|
|
726
|
-
break;
|
|
727
|
-
case 'n':
|
|
728
|
-
state.noCache = !state.noCache;
|
|
729
|
-
render(state);
|
|
730
|
-
break;
|
|
731
|
-
case 'f':
|
|
732
|
-
case '\r': // Enter
|
|
733
|
-
enterLogs(state);
|
|
734
|
-
break;
|
|
735
|
-
case 'l':
|
|
736
|
-
state.showBottomLogs = !state.showBottomLogs;
|
|
737
|
-
render(state);
|
|
738
|
-
break;
|
|
739
|
-
case 'q':
|
|
740
|
-
cleanup(state);
|
|
741
|
-
process.exit(0);
|
|
742
|
-
break;
|
|
743
|
-
case 'G': // vim: go to bottom
|
|
744
|
-
state.cursor = state.flatList.length - 1;
|
|
745
|
-
updateSelectedLogs(state);
|
|
746
|
-
render(state);
|
|
747
|
-
break;
|
|
748
|
-
case 'g': // gg handled via double-tap buffer below
|
|
749
|
-
break;
|
|
750
|
-
case '/':
|
|
751
|
-
if (state.showBottomLogs) {
|
|
752
|
-
state.bottomSearchActive = true;
|
|
753
|
-
state.bottomSearchQuery = '';
|
|
754
|
-
render(state);
|
|
755
|
-
}
|
|
756
|
-
break;
|
|
757
|
-
}
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
// --- Arrow key sequence buffering ---
|
|
761
|
-
|
|
762
|
-
function createInputHandler(state) {
|
|
763
|
-
let buf = '';
|
|
764
|
-
let gPending = false;
|
|
765
|
-
|
|
766
|
-
return function onData(data) {
|
|
767
|
-
const str = data.toString();
|
|
768
|
-
|
|
769
|
-
// Handle escape sequences (arrow keys)
|
|
770
|
-
buf += str;
|
|
771
|
-
|
|
772
|
-
while (buf.length > 0) {
|
|
773
|
-
// Check for escape sequences
|
|
774
|
-
if (buf === '\x1b') {
|
|
775
|
-
// Could be start of escape sequence — wait for more
|
|
776
|
-
setTimeout(() => {
|
|
777
|
-
if (buf === '\x1b') {
|
|
778
|
-
handleKeypress(state, '\x1b');
|
|
779
|
-
buf = '';
|
|
780
|
-
}
|
|
781
|
-
}, 50);
|
|
782
|
-
return;
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
if (buf.startsWith('\x1b[A')) {
|
|
786
|
-
handleKeypress(state, '\x1b[A');
|
|
787
|
-
buf = buf.slice(3);
|
|
788
|
-
continue;
|
|
789
|
-
}
|
|
790
|
-
if (buf.startsWith('\x1b[B')) {
|
|
791
|
-
handleKeypress(state, '\x1b[B');
|
|
792
|
-
buf = buf.slice(3);
|
|
793
|
-
continue;
|
|
794
|
-
}
|
|
795
|
-
if (buf.startsWith('\x1b[')) {
|
|
796
|
-
// Unknown escape sequence — skip it
|
|
797
|
-
buf = buf.slice(buf.length);
|
|
798
|
-
continue;
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
// Single character
|
|
802
|
-
const ch = buf[0];
|
|
803
|
-
buf = buf.slice(1);
|
|
804
|
-
|
|
805
|
-
// In search input mode, send all chars directly
|
|
806
|
-
if (state.logSearchActive || state.bottomSearchActive) {
|
|
807
|
-
handleKeypress(state, ch);
|
|
808
|
-
continue;
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
// Handle gg (go to top)
|
|
812
|
-
if (ch === 'g') {
|
|
813
|
-
if (gPending) {
|
|
814
|
-
gPending = false;
|
|
815
|
-
if (state.mode === MODE.LIST) {
|
|
816
|
-
state.cursor = 0;
|
|
817
|
-
state.scrollOffset = 0;
|
|
818
|
-
updateSelectedLogs(state);
|
|
819
|
-
} else if (state.mode === MODE.LOGS) {
|
|
820
|
-
state.logAutoScroll = false;
|
|
821
|
-
state.logScrollOffset = Math.max(0, state.logLines.length - 1);
|
|
822
|
-
}
|
|
823
|
-
render(state);
|
|
824
|
-
continue;
|
|
825
|
-
}
|
|
826
|
-
gPending = true;
|
|
827
|
-
setTimeout(() => {
|
|
828
|
-
if (gPending) {
|
|
829
|
-
gPending = false;
|
|
830
|
-
// Single g — ignore
|
|
831
|
-
}
|
|
832
|
-
}, 300);
|
|
833
|
-
continue;
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
gPending = false;
|
|
837
|
-
handleKeypress(state, ch);
|
|
838
|
-
}
|
|
839
|
-
};
|
|
840
|
-
}
|
|
841
|
-
|
|
842
|
-
// --- Cleanup ---
|
|
843
|
-
|
|
844
|
-
function cleanup(state) {
|
|
845
|
-
if (state.logChild) {
|
|
846
|
-
state.logChild.kill('SIGTERM');
|
|
847
|
-
state.logChild = null;
|
|
848
|
-
}
|
|
849
|
-
for (const [, child] of state.rebuilding) {
|
|
850
|
-
child.kill('SIGTERM');
|
|
851
|
-
}
|
|
852
|
-
state.rebuilding.clear();
|
|
853
|
-
for (const [, child] of state.restarting) {
|
|
854
|
-
child.kill('SIGTERM');
|
|
855
|
-
}
|
|
856
|
-
state.restarting.clear();
|
|
857
|
-
for (const [, child] of state.stopping) {
|
|
858
|
-
child.kill('SIGTERM');
|
|
859
|
-
}
|
|
860
|
-
state.stopping.clear();
|
|
861
|
-
for (const [, child] of state.starting) {
|
|
862
|
-
child.kill('SIGTERM');
|
|
863
|
-
}
|
|
864
|
-
state.starting.clear();
|
|
865
|
-
for (const [, child] of state.bottomLogTails) {
|
|
866
|
-
child.kill('SIGTERM');
|
|
867
|
-
}
|
|
868
|
-
state.bottomLogTails.clear();
|
|
869
|
-
if (logFetchTimer) {
|
|
870
|
-
clearTimeout(logFetchTimer);
|
|
871
|
-
logFetchTimer = null;
|
|
872
|
-
}
|
|
873
|
-
if (state.logScanTimer) {
|
|
874
|
-
clearInterval(state.logScanTimer);
|
|
875
|
-
}
|
|
876
|
-
if (state.pollTimer) {
|
|
877
|
-
clearInterval(state.pollTimer);
|
|
878
|
-
}
|
|
879
|
-
if (state.statsTimer) {
|
|
880
|
-
clearInterval(state.statsTimer);
|
|
881
|
-
}
|
|
882
|
-
process.stdout.write('\x1b[r' + showCursor() + '\x1b[0m');
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
// --- Main ---
|
|
886
|
-
|
|
887
|
-
function main() {
|
|
888
|
-
const config = loadConfig();
|
|
889
|
-
const state = createState(config);
|
|
890
|
-
|
|
891
|
-
// Discover services
|
|
892
|
-
state.groups = discoverServices(config);
|
|
893
|
-
state.flatList = buildFlatList(state.groups);
|
|
894
|
-
|
|
895
|
-
if (state.flatList.length === 0) {
|
|
896
|
-
process.stderr.write('No services found in any compose file.\n');
|
|
897
|
-
process.exit(1);
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
// Initial status poll
|
|
901
|
-
pollStatuses(state);
|
|
902
|
-
|
|
903
|
-
// Setup terminal
|
|
904
|
-
if (process.stdin.isTTY) {
|
|
905
|
-
process.stdin.setRawMode(true);
|
|
906
|
-
}
|
|
907
|
-
process.stdin.resume();
|
|
908
|
-
process.stdin.setEncoding('utf8');
|
|
909
|
-
process.stdin.on('data', createInputHandler(state));
|
|
910
|
-
|
|
911
|
-
// Initial log pattern scan
|
|
912
|
-
pollLogCounts(state);
|
|
913
|
-
|
|
914
|
-
// Start log tail for initially selected container and render
|
|
915
|
-
updateSelectedLogs(state);
|
|
916
|
-
render(state);
|
|
917
|
-
|
|
918
|
-
// Poll loop
|
|
919
|
-
state.pollTimer = setInterval(() => {
|
|
920
|
-
if (state.mode === MODE.LIST) {
|
|
921
|
-
pollStatuses(state);
|
|
922
|
-
render(state);
|
|
923
|
-
}
|
|
924
|
-
}, config.pollInterval);
|
|
925
|
-
|
|
926
|
-
// Log pattern scan loop
|
|
927
|
-
state.logScanTimer = setInterval(() => {
|
|
928
|
-
if (state.mode === MODE.LIST) {
|
|
929
|
-
pollLogCounts(state);
|
|
930
|
-
}
|
|
931
|
-
}, config.logScanInterval || 10000);
|
|
932
|
-
|
|
933
|
-
// Stats polling loop
|
|
934
|
-
pollContainerStats(state);
|
|
935
|
-
state.statsTimer = setInterval(() => {
|
|
936
|
-
if (state.mode === MODE.LIST) {
|
|
937
|
-
pollContainerStats(state);
|
|
938
|
-
}
|
|
939
|
-
}, config.statsInterval || 5000);
|
|
940
|
-
|
|
941
|
-
// Terminal resize
|
|
942
|
-
process.stdout.on('resize', () => {
|
|
943
|
-
render(state);
|
|
944
|
-
});
|
|
945
|
-
|
|
946
|
-
// Cleanup on exit
|
|
947
|
-
process.on('exit', () => cleanup(state));
|
|
948
|
-
process.on('SIGINT', () => {
|
|
949
|
-
cleanup(state);
|
|
950
|
-
process.exit(0);
|
|
951
|
-
});
|
|
952
|
-
process.on('SIGTERM', () => {
|
|
953
|
-
cleanup(state);
|
|
954
|
-
process.exit(0);
|
|
955
|
-
});
|
|
956
|
-
}
|
|
957
|
-
|
|
958
|
-
main();
|