bm2 1.0.36 → 1.0.38
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 +3 -1
- package/package.json +78 -76
- package/src/daemon.ts +55 -1
- package/src/index.ts +169 -53
- package/src/log-manager.ts +169 -117
- package/src/process-container.ts +9 -24
- package/src/process-manager.ts +31 -8
- package/src/types.ts +15 -1
package/README.md
CHANGED
|
@@ -135,7 +135,7 @@ curl -fsSL https://bun.sh/install | bash
|
|
|
135
135
|
### From Source
|
|
136
136
|
|
|
137
137
|
```
|
|
138
|
-
git clone https://github.com/
|
|
138
|
+
git clone https://github.com/bun-bm2/bm2.git
|
|
139
139
|
cd bm2
|
|
140
140
|
bun install
|
|
141
141
|
bun link
|
|
@@ -540,6 +540,8 @@ bm2 logs my-api --err
|
|
|
540
540
|
|
|
541
541
|
```
|
|
542
542
|
bm2 logs my-api --follow
|
|
543
|
+
bm2 logs my-api -f
|
|
544
|
+
bm2 logs -f
|
|
543
545
|
```
|
|
544
546
|
|
|
545
547
|
---
|
package/package.json
CHANGED
|
@@ -1,78 +1,80 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
2
|
+
"name": "bm2",
|
|
3
|
+
"version": "1.0.38",
|
|
4
|
+
"description": "A blazing-fast, full-featured process manager built entirely on Bun native APIs. The modern PM2 replacement — zero Node.js dependencies, pure Bun performance.",
|
|
5
|
+
"main": "src/api.ts",
|
|
6
|
+
"module": "src/api.ts",
|
|
7
|
+
"types": "src/api.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/api.ts",
|
|
10
|
+
"./cli": "./src/index.ts",
|
|
11
|
+
"./types": "./src/types.ts"
|
|
12
|
+
},
|
|
13
|
+
"bin": {
|
|
14
|
+
"bm2": "./src/index.ts"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"src/",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"start": "bun run src/index.ts",
|
|
23
|
+
"dev": "bun run --watch src/index.ts",
|
|
24
|
+
"test": "bun test",
|
|
25
|
+
"lint": "bun x tsc --noEmit",
|
|
26
|
+
"prepublishOnly": "bun test"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"bun",
|
|
30
|
+
"process-manager",
|
|
31
|
+
"pm2",
|
|
32
|
+
"pm2-alternative",
|
|
33
|
+
"cluster",
|
|
34
|
+
"daemon",
|
|
35
|
+
"production",
|
|
36
|
+
"deployment",
|
|
37
|
+
"monitoring",
|
|
38
|
+
"prometheus",
|
|
39
|
+
"zero-downtime",
|
|
40
|
+
"reload",
|
|
41
|
+
"log-management",
|
|
42
|
+
"health-check",
|
|
43
|
+
"bm2"
|
|
44
|
+
],
|
|
45
|
+
"author": {
|
|
46
|
+
"name": "MaxxPainn",
|
|
47
|
+
"email": "hello@maxxpainn.com",
|
|
48
|
+
"url": "https://maxxpainn.com"
|
|
49
|
+
},
|
|
50
|
+
"license": "GPL-3.0",
|
|
51
|
+
"repository": {
|
|
52
|
+
"type": "git",
|
|
53
|
+
"url": "git+https://github.com/bun-bm2/bm2.git"
|
|
54
|
+
},
|
|
55
|
+
"bugs": {
|
|
56
|
+
"url": "https://github.com/bun-bm2/bm2/issues",
|
|
57
|
+
"email": "hello@maxxpainn.com"
|
|
58
|
+
},
|
|
59
|
+
"homepage": "https://github.com/bun-bm2/bm2#readme",
|
|
60
|
+
"engines": {
|
|
61
|
+
"bun": ">=1.0.0"
|
|
62
|
+
},
|
|
63
|
+
"os": [
|
|
64
|
+
"linux",
|
|
65
|
+
"darwin"
|
|
66
|
+
],
|
|
67
|
+
"dependencies": {
|
|
68
|
+
"chalk": "^5.6.2",
|
|
69
|
+
"cli-table3": "^0.6.5",
|
|
70
|
+
"pidusage": "^4.0.1",
|
|
71
|
+
"ws": "^8.19.0"
|
|
72
|
+
},
|
|
73
|
+
"devDependencies": {
|
|
74
|
+
"@types/bun": "^1.3.9",
|
|
75
|
+
"@types/pidusage": "^2.0.5",
|
|
76
|
+
"@types/ws": "^8.18.1",
|
|
77
|
+
"bun-types": "latest",
|
|
78
|
+
"typescript": "^5.9.3"
|
|
79
|
+
}
|
|
78
80
|
}
|
package/src/daemon.ts
CHANGED
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
} from "./constants";
|
|
26
26
|
import { ensureDirs } from "./utils";
|
|
27
27
|
import type { DaemonMessage, DaemonResponse } from "./types";
|
|
28
|
-
import type { Server } from "bun";
|
|
28
|
+
import type { ReadableStreamController, Server } from "bun";
|
|
29
29
|
|
|
30
30
|
|
|
31
31
|
export default class Daemon {
|
|
@@ -100,6 +100,11 @@ export default class Daemon {
|
|
|
100
100
|
try {
|
|
101
101
|
|
|
102
102
|
const msg = (await req.json()) as DaemonMessage;
|
|
103
|
+
|
|
104
|
+
if (msg.mode == "stream") {
|
|
105
|
+
return this.handleStream(msg, req);
|
|
106
|
+
}
|
|
107
|
+
|
|
103
108
|
const response = await this.handleMessage(msg);
|
|
104
109
|
return Response.json(response);
|
|
105
110
|
|
|
@@ -110,6 +115,33 @@ export default class Daemon {
|
|
|
110
115
|
);
|
|
111
116
|
}
|
|
112
117
|
}
|
|
118
|
+
|
|
119
|
+
handleStream(msg: DaemonMessage, req: Request) {
|
|
120
|
+
|
|
121
|
+
//let controller: ReadableStreamDefaultController;
|
|
122
|
+
const self = this;
|
|
123
|
+
const signal: AbortSignal = req.signal;
|
|
124
|
+
|
|
125
|
+
const stream = new ReadableStream({
|
|
126
|
+
start(controller) {
|
|
127
|
+
|
|
128
|
+
self.handleStreamMessage(msg, controller, signal);
|
|
129
|
+
|
|
130
|
+
// cleanup when client disconnects
|
|
131
|
+
signal.addEventListener("abort", () => {
|
|
132
|
+
controller.close();
|
|
133
|
+
});
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
return new Response(stream, {
|
|
138
|
+
headers: {
|
|
139
|
+
"Content-Type": "text/event-stream", // SSE style
|
|
140
|
+
"Cache-Control": "no-cache",
|
|
141
|
+
Connection: "keep-alive",
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
}
|
|
113
145
|
|
|
114
146
|
// initialize MUST be called before startServer
|
|
115
147
|
startServer(): Server<any> {
|
|
@@ -121,6 +153,27 @@ export default class Daemon {
|
|
|
121
153
|
this.server = Bun.serve(this.getServerOpts());
|
|
122
154
|
return this.server;
|
|
123
155
|
}
|
|
156
|
+
|
|
157
|
+
async handleStreamMessage(msg: DaemonMessage, streamController: ReadableStreamDefaultController, signal: AbortSignal) {
|
|
158
|
+
|
|
159
|
+
if (!this.initialized) {
|
|
160
|
+
await this.initialize();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const pm = this.pm!;
|
|
164
|
+
//const dashboard = this.dashboard!;
|
|
165
|
+
//const moduleManager = this.moduleManager!;
|
|
166
|
+
//const metricsInterval = this.metricsInterval!;
|
|
167
|
+
|
|
168
|
+
switch (msg.type) {
|
|
169
|
+
case "streamLogs": {
|
|
170
|
+
await pm.streamLogs(msg.data.target, streamController, signal);
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
default:
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
124
177
|
|
|
125
178
|
async handleMessage(msg: DaemonMessage): Promise<DaemonResponse> {
|
|
126
179
|
try {
|
|
@@ -185,6 +238,7 @@ export default class Daemon {
|
|
|
185
238
|
const logs = await pm.getLogs(msg.data.target, msg.data.lines);
|
|
186
239
|
return { type: "logs", data: logs, success: true, id: msg.id };
|
|
187
240
|
}
|
|
241
|
+
|
|
188
242
|
case "flush": {
|
|
189
243
|
await pm.flushLogs(msg.data?.target);
|
|
190
244
|
return { type: "flush", success: true, id: msg.id };
|
package/src/index.ts
CHANGED
|
@@ -38,10 +38,13 @@ import type {
|
|
|
38
38
|
StartOptions,
|
|
39
39
|
EcosystemConfig,
|
|
40
40
|
ProcessState,
|
|
41
|
+
LogEntry,
|
|
42
|
+
LogItem,
|
|
41
43
|
} from "./types";
|
|
42
44
|
import { statusColor } from "./colors";
|
|
43
45
|
import { liveWatchProcess, printProcessTable } from "./process-table";
|
|
44
46
|
import Daemon from "./daemon";
|
|
47
|
+
import chalk from "chalk";
|
|
45
48
|
|
|
46
49
|
// ---------------------------------------------------------------------------
|
|
47
50
|
// Ensure directory structure exists
|
|
@@ -53,6 +56,8 @@ await ensureDirs();
|
|
|
53
56
|
// ---------------------------------------------------------------------------
|
|
54
57
|
|
|
55
58
|
class BM2CLI {
|
|
59
|
+
|
|
60
|
+
noDaemon = false;
|
|
56
61
|
|
|
57
62
|
// -------------------------------------------------------------------------
|
|
58
63
|
// Daemon helpers
|
|
@@ -120,6 +125,12 @@ class BM2CLI {
|
|
|
120
125
|
}
|
|
121
126
|
|
|
122
127
|
async sendToDaemon(msg: DaemonMessage): Promise<DaemonResponse> {
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
if (this.noDaemon) {
|
|
131
|
+
return this.callDaemonCmd(msg)
|
|
132
|
+
}
|
|
133
|
+
|
|
123
134
|
await this.startDaemon();
|
|
124
135
|
|
|
125
136
|
let res;
|
|
@@ -155,6 +166,57 @@ class BM2CLI {
|
|
|
155
166
|
return { type: "error", error: "Fetch Error", success: false };
|
|
156
167
|
}
|
|
157
168
|
}
|
|
169
|
+
|
|
170
|
+
async getDaemonStream<T>(data: DaemonMessage, callback: (data: T | null) => void) {
|
|
171
|
+
try {
|
|
172
|
+
|
|
173
|
+
await this.startDaemon();
|
|
174
|
+
|
|
175
|
+
const res = await fetch("http://localhost/command", {
|
|
176
|
+
unix: DAEMON_SOCKET,
|
|
177
|
+
method: "POST",
|
|
178
|
+
headers: { "Content-Type": "application/json" },
|
|
179
|
+
body: JSON.stringify(data)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
if (!res.body) {
|
|
183
|
+
console.error("No stream received");
|
|
184
|
+
process.exit(1);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const reader = res.body.getReader();
|
|
188
|
+
const decoder = new TextDecoder();
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
let buffer = "";
|
|
192
|
+
|
|
193
|
+
while (true) {
|
|
194
|
+
|
|
195
|
+
const { value, done } = await reader.read();
|
|
196
|
+
|
|
197
|
+
if (done) break;
|
|
198
|
+
|
|
199
|
+
buffer += decoder.decode(value, { stream: true });
|
|
200
|
+
|
|
201
|
+
// split SSE messages
|
|
202
|
+
const parts = buffer.split("\n\n");
|
|
203
|
+
buffer = parts.pop()!;
|
|
204
|
+
|
|
205
|
+
for (const part of parts) {
|
|
206
|
+
const line = part.replace(/^data:\s*/, "").trim();
|
|
207
|
+
if (!line) continue;
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
const result = JSON.parse(line) as T;
|
|
211
|
+
callback(result)
|
|
212
|
+
} catch {}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
} catch (e: any) {
|
|
217
|
+
console.log("getDaemonStream:", e, e.stack);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
158
220
|
|
|
159
221
|
// -------------------------------------------------------------------------
|
|
160
222
|
// Ecosystem config loader
|
|
@@ -180,9 +242,15 @@ class BM2CLI {
|
|
|
180
242
|
}
|
|
181
243
|
|
|
182
244
|
const cwd = path.dirname(abs);
|
|
245
|
+
|
|
246
|
+
if (config.noDaemon) {
|
|
247
|
+
this.noDaemon = config.noDaemon;
|
|
248
|
+
}
|
|
183
249
|
|
|
184
250
|
config.apps = config.apps.map((i) => {
|
|
185
|
-
if ((i.cwd || "").trim() === "")
|
|
251
|
+
if ((i.cwd || "").trim() === "") {
|
|
252
|
+
i.cwd = cwd;
|
|
253
|
+
}
|
|
186
254
|
return i;
|
|
187
255
|
});
|
|
188
256
|
|
|
@@ -202,7 +270,8 @@ class BM2CLI {
|
|
|
202
270
|
// -------------------------------------------------------------------------
|
|
203
271
|
|
|
204
272
|
parseStartFlags(args: string[]): StartOptions {
|
|
205
|
-
|
|
273
|
+
|
|
274
|
+
const opts: StartOptions = { script: "" };
|
|
206
275
|
|
|
207
276
|
let i = 0;
|
|
208
277
|
let scriptResolved = false;
|
|
@@ -275,10 +344,6 @@ class BM2CLI {
|
|
|
275
344
|
case "--no-autorestart":
|
|
276
345
|
opts.autorestart = false;
|
|
277
346
|
break;
|
|
278
|
-
case "--no-daemon":
|
|
279
|
-
case "-d":
|
|
280
|
-
opts.noDaemon = true;
|
|
281
|
-
break;
|
|
282
347
|
case "--env": {
|
|
283
348
|
const envPair = args[++i]!;
|
|
284
349
|
const eqIdx = envPair.indexOf("=");
|
|
@@ -369,6 +434,7 @@ class BM2CLI {
|
|
|
369
434
|
// -------------------------------------------------------------------------
|
|
370
435
|
|
|
371
436
|
async cmdStart(args: string[]) {
|
|
437
|
+
|
|
372
438
|
if (args.length === 0) {
|
|
373
439
|
console.error(colorize("Usage: bm2 start <script|config> [options]", "red"));
|
|
374
440
|
process.exit(1);
|
|
@@ -391,42 +457,47 @@ class BM2CLI {
|
|
|
391
457
|
firstPositional.includes("bm2.config") ||
|
|
392
458
|
firstPositional.includes("pm2.config")
|
|
393
459
|
) {
|
|
460
|
+
|
|
394
461
|
const config = await this.loadEcosystemConfig(firstPositional);
|
|
395
462
|
const res = await this.sendToDaemon({ type: "ecosystem", data: config });
|
|
463
|
+
|
|
396
464
|
if (!res.success) {
|
|
397
465
|
console.error(colorize(`Error: ${res.error}`, "red"));
|
|
398
466
|
process.exit(1);
|
|
399
467
|
}
|
|
468
|
+
|
|
400
469
|
printProcessTable(res.data);
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
if (!opts.script) {
|
|
408
|
-
console.error(colorize("Error: no script specified", "red"));
|
|
409
|
-
process.exit(1);
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
opts.script = resolve(opts.script);
|
|
413
|
-
if (!opts.cwd) opts.cwd = path.dirname(opts.script);
|
|
414
|
-
|
|
415
|
-
const noDaemon = opts.noDaemon;
|
|
416
|
-
|
|
417
|
-
const res = noDaemon
|
|
418
|
-
? await this.callDaemonCmd({ type: "start", data: opts })
|
|
419
|
-
: await this.sendToDaemon({ type: "start", data: opts });
|
|
420
|
-
|
|
421
|
-
if (!res.success) {
|
|
422
|
-
console.error(colorize(`Error: ${res.error}`, "red"));
|
|
423
|
-
process.exit(1);
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
printProcessTable(res.data);
|
|
470
|
+
|
|
471
|
+
if (this.noDaemon) {
|
|
472
|
+
await new Promise(() => {});
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
} else {
|
|
427
476
|
|
|
428
|
-
|
|
429
|
-
|
|
477
|
+
// Parse all args — parseStartFlags finds the script itself
|
|
478
|
+
const opts = this.parseStartFlags(args);
|
|
479
|
+
|
|
480
|
+
if (!opts.script) {
|
|
481
|
+
console.error(colorize("Error: no script specified", "red"));
|
|
482
|
+
process.exit(1);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
opts.script = resolve(opts.script);
|
|
486
|
+
|
|
487
|
+
if (!opts.cwd) opts.cwd = path.dirname(opts.script);
|
|
488
|
+
|
|
489
|
+
const res = await this.sendToDaemon({ type: "start", data: opts });
|
|
490
|
+
|
|
491
|
+
if (!res.success) {
|
|
492
|
+
console.error(colorize(`Error: ${res.error}`, "red"));
|
|
493
|
+
process.exit(1);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
printProcessTable(res.data);
|
|
497
|
+
|
|
498
|
+
if (this.noDaemon) {
|
|
499
|
+
await new Promise(() => {});
|
|
500
|
+
}
|
|
430
501
|
}
|
|
431
502
|
}
|
|
432
503
|
|
|
@@ -548,31 +619,74 @@ class BM2CLI {
|
|
|
548
619
|
console.log();
|
|
549
620
|
}
|
|
550
621
|
}
|
|
551
|
-
|
|
622
|
+
|
|
552
623
|
async cmdLogs(args: string[]) {
|
|
553
|
-
|
|
624
|
+
|
|
625
|
+
let target: string | number = "all";
|
|
554
626
|
let lines = 20;
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
627
|
+
let follow = false;
|
|
628
|
+
|
|
629
|
+
let i = 0;
|
|
630
|
+
|
|
631
|
+
while (i < args.length) {
|
|
632
|
+
const arg = args[i]!;
|
|
633
|
+
|
|
634
|
+
if ((arg === "--lines" || arg === "-l") && !Number.isNaN(Number(args[i + 1]))) {
|
|
635
|
+
lines = parseInt(args[i + 1]!);
|
|
636
|
+
i++; // skip value
|
|
637
|
+
} else if (arg.startsWith("--lines=")) {
|
|
638
|
+
lines = parseInt(arg.split("=")[1]!);
|
|
639
|
+
} else if (arg === "--follow" || arg === "-f") {
|
|
640
|
+
follow = true;
|
|
641
|
+
} else if (!arg.startsWith("-")) {
|
|
642
|
+
target = arg;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
i++;
|
|
558
646
|
}
|
|
559
|
-
|
|
560
|
-
const
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
647
|
+
|
|
648
|
+
const renderLog = (log: LogItem) => {
|
|
649
|
+
let line;
|
|
650
|
+
if (log.level == "err") {
|
|
651
|
+
line = chalk.red(`[ERROR] ${log.name} | ${log.ts}: ${log.msg}\n`)
|
|
652
|
+
} else {
|
|
653
|
+
line = chalk.white(`${chalk.cyan(`[OUTPUT] ${log.name} | ${log.ts}`)}: ${log.msg}\n`)
|
|
654
|
+
}
|
|
655
|
+
console.log(line)
|
|
564
656
|
}
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
657
|
+
|
|
658
|
+
if (follow) {
|
|
659
|
+
|
|
660
|
+
const opts: DaemonMessage = {
|
|
661
|
+
type: "streamLogs",
|
|
662
|
+
data: { target },
|
|
663
|
+
mode: "stream"
|
|
571
664
|
}
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
665
|
+
|
|
666
|
+
const callback = (log: LogItem | null) => {
|
|
667
|
+
if(log) renderLog(log)
|
|
575
668
|
}
|
|
669
|
+
|
|
670
|
+
await this.getDaemonStream<LogItem>(opts, callback);
|
|
671
|
+
|
|
672
|
+
} else {
|
|
673
|
+
|
|
674
|
+
const res = await this.sendToDaemon({
|
|
675
|
+
type: "logs",
|
|
676
|
+
data: { target, lines },
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
if (!res.success) {
|
|
680
|
+
console.error(colorize(`Error: ${res.error}`, "red"));
|
|
681
|
+
process.exit(1);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const logs: LogItem[] = res.data ?? [];
|
|
685
|
+
|
|
686
|
+
for (let log of logs) {
|
|
687
|
+
renderLog(log)
|
|
688
|
+
}
|
|
689
|
+
|
|
576
690
|
}
|
|
577
691
|
}
|
|
578
692
|
|
|
@@ -1044,6 +1158,8 @@ class BM2CLI {
|
|
|
1044
1158
|
async run(argv: string[]) {
|
|
1045
1159
|
const command = argv[0];
|
|
1046
1160
|
const commandArgs = argv.slice(1);
|
|
1161
|
+
|
|
1162
|
+
this.noDaemon = argv.includes("--no-daemon") || argv.includes("-d");
|
|
1047
1163
|
|
|
1048
1164
|
switch (command) {
|
|
1049
1165
|
case "start":
|
package/src/log-manager.ts
CHANGED
|
@@ -15,12 +15,21 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { join, dirname } from "path";
|
|
18
|
-
import {
|
|
19
|
-
import { appendFile, stat, rename, unlink, readdir, access } from "fs/promises";
|
|
18
|
+
import { appendFile, rename, unlink, readdir } from "fs/promises";
|
|
20
19
|
import { LOG_DIR, DEFAULT_LOG_MAX_SIZE, DEFAULT_LOG_RETAIN } from "./constants";
|
|
21
|
-
import type { LogRotateOptions } from "./types";
|
|
20
|
+
import type { LogEntry, LogRotateOptions } from "./types";
|
|
21
|
+
import { watch } from "fs";
|
|
22
|
+
import type { ReadableStreamController } from "bun";
|
|
23
|
+
import { $ } from "bun"
|
|
24
|
+
import { EOL } from 'node:os';
|
|
25
|
+
|
|
26
|
+
const isoRegex: RegExp = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?/;
|
|
27
|
+
|
|
28
|
+
// [__br__] = linebreak
|
|
29
|
+
const nl = "[__br__]"
|
|
22
30
|
|
|
23
31
|
export class LogManager {
|
|
32
|
+
|
|
24
33
|
private writeBuffers: Map<string, string[]> = new Map();
|
|
25
34
|
private flushTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();
|
|
26
35
|
|
|
@@ -30,7 +39,8 @@ export class LogManager {
|
|
|
30
39
|
errFile: customErr || join(LOG_DIR, `${name}-${id}-error.log`),
|
|
31
40
|
};
|
|
32
41
|
}
|
|
33
|
-
|
|
42
|
+
|
|
43
|
+
|
|
34
44
|
async appendLog(filePath: string, data: string | Uint8Array) {
|
|
35
45
|
const text = typeof data === "string" ? data : new TextDecoder().decode(data);
|
|
36
46
|
|
|
@@ -47,6 +57,32 @@ export class LogManager {
|
|
|
47
57
|
}, 100));
|
|
48
58
|
}
|
|
49
59
|
}
|
|
60
|
+
|
|
61
|
+
async appendJSONLog(filePath: string, msg: string) {
|
|
62
|
+
|
|
63
|
+
msg = msg.trim().replace(/[\r\n]+/g, nl);
|
|
64
|
+
|
|
65
|
+
const log: LogEntry = {
|
|
66
|
+
ts: new Date().toISOString(),
|
|
67
|
+
msg
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const line = JSON.stringify(log) + "\n";
|
|
71
|
+
|
|
72
|
+
// reuse your buffer system
|
|
73
|
+
if (!this.writeBuffers.has(filePath)) {
|
|
74
|
+
this.writeBuffers.set(filePath, []);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
this.writeBuffers.get(filePath)!.push(line);
|
|
78
|
+
|
|
79
|
+
if (!this.flushTimers.has(filePath)) {
|
|
80
|
+
this.flushTimers.set(
|
|
81
|
+
filePath,
|
|
82
|
+
setTimeout(() => this.flushBuffer(filePath), 100)
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
50
86
|
|
|
51
87
|
private async flushBuffer(filePath: string) {
|
|
52
88
|
const buffer = this.writeBuffers.get(filePath);
|
|
@@ -72,6 +108,32 @@ export class LogManager {
|
|
|
72
108
|
await this.flushBuffer(filePath);
|
|
73
109
|
}
|
|
74
110
|
}
|
|
111
|
+
|
|
112
|
+
private parseLine(line: string, level?: "err" | "out"): LogEntry {
|
|
113
|
+
|
|
114
|
+
let newLine: LogEntry;
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
|
|
118
|
+
newLine = JSON.parse(line) as LogEntry;
|
|
119
|
+
|
|
120
|
+
} catch {
|
|
121
|
+
// fallback to old format
|
|
122
|
+
const ts = this.extractLogTs(line);
|
|
123
|
+
const msg = line.replace(`[${ts}]`, "").trim();
|
|
124
|
+
newLine = { ts, msg };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
newLine.msg = newLine.msg.replaceAll(nl, EOL)
|
|
128
|
+
newLine.level = level;
|
|
129
|
+
|
|
130
|
+
return newLine;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private extractLogTs(line: string) {
|
|
134
|
+
const match = line.match(isoRegex);
|
|
135
|
+
return match?.[0] ?? ""
|
|
136
|
+
}
|
|
75
137
|
|
|
76
138
|
async readLogs(
|
|
77
139
|
name: string,
|
|
@@ -79,135 +141,125 @@ export class LogManager {
|
|
|
79
141
|
lines: number = 20,
|
|
80
142
|
customOut?: string,
|
|
81
143
|
customErr?: string
|
|
82
|
-
): Promise<
|
|
83
|
-
const paths = this.getLogPaths(name, id, customOut, customErr);
|
|
84
|
-
let out = "";
|
|
85
|
-
let err = "";
|
|
86
|
-
|
|
87
|
-
try {
|
|
88
|
-
const outFile = Bun.file(paths.outFile);
|
|
89
|
-
if (await outFile.exists()) {
|
|
90
|
-
const text = await outFile.text();
|
|
91
|
-
out = text.split("\n").slice(-lines).join("\n");
|
|
92
|
-
}
|
|
93
|
-
} catch {}
|
|
144
|
+
): Promise<LogEntry[]> {
|
|
94
145
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
} catch {}
|
|
146
|
+
const paths = this.getLogPaths(name, id, customOut, customErr);
|
|
147
|
+
|
|
148
|
+
const logs = (await Promise.all(Object.values(paths).map(async (fp) => {
|
|
149
|
+
|
|
150
|
+
const f = Bun.file(fp);
|
|
151
|
+
if (!(await f.exists())) return [];
|
|
102
152
|
|
|
103
|
-
|
|
153
|
+
const level = (fp == paths.errFile) ? "err" : "out";
|
|
154
|
+
|
|
155
|
+
const rawLog = await $`tail -n ${lines} ${fp}`.text();
|
|
156
|
+
|
|
157
|
+
return rawLog
|
|
158
|
+
.split("\n")
|
|
159
|
+
.filter(Boolean)
|
|
160
|
+
.map(l => this.parseLine(l, level));
|
|
161
|
+
|
|
162
|
+
}))).flat();
|
|
163
|
+
|
|
164
|
+
// lets sort the logs here
|
|
165
|
+
let sortedLogs = logs
|
|
166
|
+
.sort((a, b) => (a.ts || "").localeCompare(b.ts || ""))
|
|
167
|
+
|
|
168
|
+
if (sortedLogs.length > lines) {
|
|
169
|
+
sortedLogs = sortedLogs.slice(-lines)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
console.log(sortedLogs)
|
|
173
|
+
|
|
174
|
+
return sortedLogs
|
|
104
175
|
}
|
|
105
176
|
|
|
106
177
|
async tailLog(
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
if (!(await f.exists()))
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
// 2. Slicing by character offset (lastSize) on a UTF-8 string,
|
|
134
|
-
// which silently corrupts multi-byte sequences.
|
|
135
|
-
const buf = Buffer.allocUnsafe(byteLength);
|
|
136
|
-
const fd = openSync(filePath, "r");
|
|
137
|
-
try {
|
|
138
|
-
readSync(fd, buf, 0, byteLength, lastSize);
|
|
139
|
-
} finally {
|
|
140
|
-
closeSync(fd);
|
|
178
|
+
name: string,
|
|
179
|
+
id: number,
|
|
180
|
+
streamController: ReadableStreamDefaultController,
|
|
181
|
+
signal: AbortSignal
|
|
182
|
+
) {
|
|
183
|
+
const paths = this.getLogPaths(name, id);
|
|
184
|
+
|
|
185
|
+
const state = {
|
|
186
|
+
out: Bun.file(paths.outFile).size,
|
|
187
|
+
err: Bun.file(paths.errFile).size,
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const poll = setInterval(async () => {
|
|
191
|
+
for (const [type, fp] of [["out", paths.outFile],["err", paths.errFile],] as const) {
|
|
192
|
+
|
|
193
|
+
const f = Bun.file(fp);
|
|
194
|
+
|
|
195
|
+
if (!(await f.exists())) continue;
|
|
196
|
+
|
|
197
|
+
let lastSize = state[type];
|
|
198
|
+
|
|
199
|
+
const size = f.size;
|
|
200
|
+
|
|
201
|
+
if (size < lastSize) {
|
|
202
|
+
state[type] = 0; // rotated file
|
|
203
|
+
lastSize = 0;
|
|
141
204
|
}
|
|
142
|
-
|
|
143
|
-
lastSize
|
|
144
|
-
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
205
|
+
|
|
206
|
+
if (size === lastSize) continue;
|
|
207
|
+
|
|
208
|
+
const chunk = await f.slice(lastSize, size).text();
|
|
209
|
+
state[type] = size;
|
|
210
|
+
|
|
211
|
+
for (const line of chunk.split("\n").filter(Boolean)) {
|
|
212
|
+
const log = { name, id, ...this.parseLine(line, type) };
|
|
213
|
+
streamController.enqueue(`data: ${JSON.stringify(log)}\n\n`);
|
|
148
214
|
}
|
|
149
|
-
}
|
|
215
|
+
}
|
|
150
216
|
}, 500);
|
|
217
|
+
|
|
218
|
+
signal.addEventListener("abort", () => {
|
|
219
|
+
clearInterval(poll);
|
|
220
|
+
});
|
|
151
221
|
}
|
|
152
222
|
|
|
153
223
|
async rotate(filePath: string, options: LogRotateOptions): Promise<void> {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
if (!srcExists) continue;
|
|
169
|
-
|
|
224
|
+
|
|
225
|
+
const file = Bun.file(filePath);
|
|
226
|
+
|
|
227
|
+
if (!(await file.exists()) || file.size < options.maxSize) return;
|
|
228
|
+
|
|
229
|
+
const bgTasks: Promise<any>[] = [];
|
|
230
|
+
|
|
231
|
+
for (let i = options.retain - 1; i >= 1; i--) {
|
|
232
|
+
|
|
233
|
+
const src = i === 1 ? filePath : `${filePath}.${i - 1}`;
|
|
234
|
+
const dst = `${filePath}.${i}`;
|
|
235
|
+
|
|
236
|
+
if (await Bun.file(src).exists()) {
|
|
237
|
+
|
|
170
238
|
await rename(src, dst);
|
|
171
|
-
|
|
172
239
|
if (options.compress) {
|
|
173
|
-
//
|
|
174
|
-
|
|
175
|
-
// `dst` with `dst.gz` in-place, matching the old .gz naming.
|
|
176
|
-
try {
|
|
177
|
-
const proc = Bun.spawn(["gzip", "-f", dst], {
|
|
178
|
-
stdout: "ignore",
|
|
179
|
-
stderr: "pipe",
|
|
180
|
-
});
|
|
181
|
-
const exitCode = await proc.exited;
|
|
182
|
-
if (exitCode !== 0) {
|
|
183
|
-
const errText = await new Response(proc.stderr).text();
|
|
184
|
-
console.error(`[bm2] gzip failed for ${dst}: ${errText.trim()}`);
|
|
185
|
-
}
|
|
186
|
-
} catch (compressErr) {
|
|
187
|
-
console.error(`[bm2] Failed to compress rotated log ${dst}:`, compressErr);
|
|
188
|
-
}
|
|
240
|
+
// Fire-and-forget compression doesn't block the next rename
|
|
241
|
+
bgTasks.push(Bun.spawn(["gzip", "-f", dst]).exited);
|
|
189
242
|
}
|
|
190
243
|
}
|
|
191
|
-
|
|
192
|
-
// Clean excess rotated files asynchronously
|
|
193
|
-
const dir = dirname(filePath);
|
|
194
|
-
const baseName = filePath.split("/").pop()!;
|
|
195
|
-
try {
|
|
196
|
-
const files = await readdir(dir);
|
|
197
|
-
const rotated = files
|
|
198
|
-
.filter((f) => f.startsWith(baseName + "."))
|
|
199
|
-
.sort()
|
|
200
|
-
.reverse();
|
|
201
|
-
await Promise.all(
|
|
202
|
-
rotated.slice(options.retain).map((f) => unlink(join(dir, f)).catch(() => {}))
|
|
203
|
-
);
|
|
204
|
-
} catch {}
|
|
205
|
-
|
|
206
|
-
// Truncate original to reclaim inode while keeping it open for writers
|
|
207
|
-
await Bun.write(filePath, "");
|
|
208
|
-
} catch (err) {
|
|
209
|
-
console.error(`[bm2] Log rotation failed for ${filePath}:`, err);
|
|
210
244
|
}
|
|
245
|
+
|
|
246
|
+
await Bun.write(filePath, ""); // Instantly truncate and reclaim space
|
|
247
|
+
|
|
248
|
+
const dir = dirname(filePath);
|
|
249
|
+
const baseName = filePath.split("/").pop()!;
|
|
250
|
+
|
|
251
|
+
// Background cleanup
|
|
252
|
+
bgTasks.push(
|
|
253
|
+
readdir(dir).then(files =>
|
|
254
|
+
Promise.all(
|
|
255
|
+
files.filter(f => f.startsWith(`${baseName}.`)).sort().reverse()
|
|
256
|
+
.slice(options.retain).map(f => unlink(join(dir, f)).catch(() => {}))
|
|
257
|
+
)
|
|
258
|
+
).catch(() => {})
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
// Let Bun handle the heavy lifting in the background!
|
|
262
|
+
Promise.all(bgTasks).catch(() => {});
|
|
211
263
|
}
|
|
212
264
|
|
|
213
265
|
async flush(name: string, id: number, customOut?: string, customErr?: string) {
|
package/src/process-container.ts
CHANGED
|
@@ -153,11 +153,9 @@ export class ProcessContainer {
|
|
|
153
153
|
}
|
|
154
154
|
} catch (err: any) {
|
|
155
155
|
this.status = "errored";
|
|
156
|
-
|
|
157
|
-
await this.logManager.
|
|
158
|
-
|
|
159
|
-
`[${timestamp}] [bm2] Failed to start: ${err.message}\n`
|
|
160
|
-
);
|
|
156
|
+
|
|
157
|
+
await this.logManager.appendJSONLog(logPaths.errFile, `[bm2] Failed to start: ${err.message}`);
|
|
158
|
+
|
|
161
159
|
throw err;
|
|
162
160
|
}
|
|
163
161
|
}
|
|
@@ -229,6 +227,7 @@ export class ProcessContainer {
|
|
|
229
227
|
const reader = stream.getReader();
|
|
230
228
|
const decoder = new TextDecoder();
|
|
231
229
|
|
|
230
|
+
|
|
232
231
|
// Holds the tail of the last chunk if it did not end on a newline.
|
|
233
232
|
// Without this, a chunk boundary mid-word (e.g. "hel" / "lo\n") would be
|
|
234
233
|
// written as two separate log lines, corrupting the output.
|
|
@@ -237,12 +236,11 @@ export class ProcessContainer {
|
|
|
237
236
|
try {
|
|
238
237
|
while (true) {
|
|
239
238
|
const { done, value } = await reader.read();
|
|
240
|
-
|
|
239
|
+
|
|
241
240
|
if (done) {
|
|
242
241
|
// Flush any buffered content that was never terminated with \n
|
|
243
242
|
if (remainder.length > 0) {
|
|
244
|
-
|
|
245
|
-
await this.logManager.appendLog(filePath, `[${timestamp}] ${remainder}\n`);
|
|
243
|
+
await this.logManager.appendJSONLog(filePath, remainder)
|
|
246
244
|
remainder = "";
|
|
247
245
|
}
|
|
248
246
|
break;
|
|
@@ -255,27 +253,14 @@ export class ProcessContainer {
|
|
|
255
253
|
// Prepend any leftover from the previous chunk before splitting.
|
|
256
254
|
// This is a single string allocation per chunk (not per line), so
|
|
257
255
|
// allocation pressure stays O(chunk size) rather than O(line count).
|
|
258
|
-
const text = remainder + chunk;
|
|
259
|
-
const lines = text.split("\n");
|
|
260
|
-
|
|
261
|
-
// The last element is either "" (chunk ended on \n) or an incomplete
|
|
262
|
-
// line. Either way, hold it back for the next iteration.
|
|
263
|
-
remainder = lines.pop()!;
|
|
264
|
-
|
|
265
|
-
if (lines.length === 0) continue;
|
|
256
|
+
const text = (remainder + chunk).trim();
|
|
266
257
|
|
|
267
|
-
|
|
268
|
-
// Build a single string for all complete lines in this chunk so
|
|
269
|
-
// appendLog (and the underlying O_APPEND write) is called once per
|
|
270
|
-
// chunk, not once per line.
|
|
271
|
-
const output = lines.map((line) => `[${timestamp}] ${line}\n`).join("");
|
|
272
|
-
await this.logManager.appendLog(filePath, output);
|
|
258
|
+
await this.logManager.appendJSONLog(filePath, text);
|
|
273
259
|
}
|
|
274
260
|
} catch {
|
|
275
261
|
// Flush remainder on unexpected stream error
|
|
276
262
|
if (remainder.length > 0) {
|
|
277
|
-
|
|
278
|
-
await this.logManager.appendLog(filePath, `[${timestamp}] ${remainder}\n`).catch(() => {});
|
|
263
|
+
await this.logManager.appendJSONLog(filePath, remainder).catch(() => {});
|
|
279
264
|
}
|
|
280
265
|
}
|
|
281
266
|
}
|
package/src/process-manager.ts
CHANGED
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
StartOptions,
|
|
20
20
|
EcosystemConfig,
|
|
21
21
|
MetricSnapshot,
|
|
22
|
+
LogEntry,
|
|
23
|
+
LogItem,
|
|
22
24
|
} from "./types";
|
|
23
25
|
import { ProcessContainer } from "./process-container";
|
|
24
26
|
import { LogManager } from "./log-manager";
|
|
@@ -37,6 +39,7 @@
|
|
|
37
39
|
DEFAULT_LOG_RETAIN,
|
|
38
40
|
} from "./constants";
|
|
39
41
|
import path from "path";
|
|
42
|
+
import type { ReadableStreamController } from "bun";
|
|
40
43
|
|
|
41
44
|
export class ProcessManager {
|
|
42
45
|
private processes: Map<number, ProcessContainer> = new Map();
|
|
@@ -306,15 +309,34 @@ import path from "path";
|
|
|
306
309
|
}
|
|
307
310
|
|
|
308
311
|
async getLogs(target: string | number, lines: number = 20) {
|
|
312
|
+
|
|
309
313
|
const containers = this.resolveTarget(target);
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
314
|
+
|
|
315
|
+
// just for readability
|
|
316
|
+
let results: LogItem[] = [];
|
|
317
|
+
|
|
318
|
+
results = (await Promise.all(containers.map(async (c) => {
|
|
319
|
+
const logs = await this.logManager.readLogs(c.name, c.id, lines, c.config.outFile, c.config.errorFile);
|
|
320
|
+
return logs.map((log) => ({ name: c.name, id: c.id, ...log }))
|
|
321
|
+
}))).flat();
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
let sortedResults = results
|
|
325
|
+
.sort((a, b) => (a.ts || "").localeCompare(b.ts || ""))
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
return sortedResults;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async streamLogs(target: string | number, streamController: ReadableStreamDefaultController, signal: AbortSignal) {
|
|
332
|
+
|
|
333
|
+
const containers = this.resolveTarget(target);
|
|
334
|
+
const lm = this.logManager;
|
|
335
|
+
|
|
336
|
+
await Promise.all(containers.map(async (c) => (
|
|
337
|
+
lm.tailLog(c.name, c.id, streamController, signal)
|
|
338
|
+
)))
|
|
339
|
+
|
|
318
340
|
}
|
|
319
341
|
|
|
320
342
|
async flushLogs(target?: string | number) {
|
|
@@ -401,6 +423,7 @@ import path from "path";
|
|
|
401
423
|
}
|
|
402
424
|
|
|
403
425
|
private resolveTarget(target: string | number): ProcessContainer[] {
|
|
426
|
+
|
|
404
427
|
if (target === "all") {
|
|
405
428
|
return Array.from(this.processes.values());
|
|
406
429
|
}
|
package/src/types.ts
CHANGED
|
@@ -124,7 +124,6 @@ export interface StartOptions {
|
|
|
124
124
|
instances?: number;
|
|
125
125
|
execMode?: ExecMode;
|
|
126
126
|
autorestart?: boolean;
|
|
127
|
-
noDaemon?: boolean;
|
|
128
127
|
maxRestarts?: number;
|
|
129
128
|
minUptime?: number;
|
|
130
129
|
maxMemoryRestart?: string | number;
|
|
@@ -156,6 +155,7 @@ export interface StartOptions {
|
|
|
156
155
|
|
|
157
156
|
export interface EcosystemConfig {
|
|
158
157
|
apps: StartOptions[];
|
|
158
|
+
noDaemon?: boolean;
|
|
159
159
|
deploy?: Record<string, DeployConfig>;
|
|
160
160
|
}
|
|
161
161
|
|
|
@@ -177,6 +177,7 @@ export interface DaemonMessage {
|
|
|
177
177
|
type: string;
|
|
178
178
|
data?: any;
|
|
179
179
|
id?: string;
|
|
180
|
+
mode?: "stream" | "http"
|
|
180
181
|
}
|
|
181
182
|
|
|
182
183
|
export interface DaemonResponse {
|
|
@@ -229,3 +230,16 @@ export interface DashboardState {
|
|
|
229
230
|
metrics: MetricSnapshot;
|
|
230
231
|
logs: Record<string, { out: string; err: string }>;
|
|
231
232
|
}
|
|
233
|
+
|
|
234
|
+
export type LogEntry = {
|
|
235
|
+
ts: string;
|
|
236
|
+
level?: "err" | "out",
|
|
237
|
+
msg: string;
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
export interface LogItem {
|
|
241
|
+
name: string;
|
|
242
|
+
id: number;
|
|
243
|
+
ts: string;
|
|
244
|
+
msg: string; level?: "err" | "out"
|
|
245
|
+
}[]
|