bgrun 3.10.2 → 3.12.0

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 CHANGED
@@ -5,6 +5,7 @@
5
5
  **Production-ready process manager with dashboard and programmatic API, designed for running your containers, services, and AI agents.**
6
6
 
7
7
  [![npm](https://img.shields.io/npm/v/bgrun?color=F7A41D&label=npm&logo=npm)](https://www.npmjs.com/package/bgrun)
8
+ [![CI](https://github.com/Mements/bgr/actions/workflows/ci.yml/badge.svg)](https://github.com/Mements/bgr/actions/workflows/ci.yml)
8
9
  [![bun](https://img.shields.io/badge/runtime-bun-F7A41D?logo=bun)](https://bun.sh/)
9
10
  [![license](https://img.shields.io/npm/l/bgrun)](./LICENSE)
10
11
 
@@ -74,10 +75,16 @@ bgrun.yourdomain.com {
74
75
  Features:
75
76
  - Real-time process status via SSE (no polling)
76
77
  - Start, stop, restart, and delete processes from the UI
77
- - Live stdout/stderr log viewer with search
78
- - Memory, PID, port, and runtime at a glance
79
- - Responsive mobile layout
78
+ - Live stdout/stderr log viewer with search and virtual scrolling
79
+ - Memory, PID, port, runtime, and guard restarts at a glance
80
+ - Guard toggle per-process (auto-restart on crash)
81
+ - Keyboard shortcuts — `↑/↓` or `j/k` navigate, `Enter` open, `R` restart, `S` stop, `G` guard, `D` delete, `N` new, `?` help
82
+ - Search with debounce, result count badge, and persistence across SSE updates
83
+ - Auto-calibrated virtual scroll for large log files (10K+ lines)
84
+ - Dark / light theme toggle
85
+ - Responsive mobile layout with cards view
80
86
  - Collapsible directory groups
87
+ - Right-click context menu on process rows
81
88
 
82
89
  ---
83
90
 
@@ -91,6 +98,9 @@ Features:
91
98
  - [Caddy Reverse Proxy](#caddy-reverse-proxy)
92
99
  - [TOML Configuration](#toml-configuration)
93
100
  - [Programmatic API](#programmatic-api)
101
+ - [Process Dependencies](#process-dependencies)
102
+ - [Log Rotation](#log-rotation)
103
+ - [Guard (Auto-Restart)](#guard-auto-restart)
94
104
  - [Migrating from PM2](#migrating-from-pm2)
95
105
  - [Edge Cases & Behaviors](#edge-cases--behaviors)
96
106
  - [Full CLI Reference](#full-cli-reference)
@@ -555,12 +565,80 @@ bgrun --name worker --directory ./workers --command "node worker.js" --force
555
565
  WantedBy=multi-user.target
556
566
  ```
557
567
 
558
- 3. **No log rotation** — bgrun writes to plain text files in `~/.bgr/`. Use `logrotate` or similar tools, or specify custom log paths with `--stdout` and `--stderr`.
568
+ 3. **Built-in log rotation** — bgrun automatically rotates logs when they exceed 10MB, keeping the last 5000 lines. Manual rotation is also available via the dashboard API.
559
569
 
560
570
  4. **Bun required** — bgrun runs on Bun, but the *processes it manages* can be anything: Node.js, Python, Ruby, Go, Docker, shell scripts.
561
571
 
562
572
  ---
563
573
 
574
+ ## Process Dependencies
575
+
576
+ bgrun supports declaring process startup dependencies via the `BGR_DEPENDS_ON` environment variable:
577
+
578
+ ```bash
579
+ # Start a price listener (no dependencies)
580
+ bgrun --name price-feed --command "bun run sqd.ts" --force
581
+
582
+ # Start a worker that depends on the price feed
583
+ BGR_DEPENDS_ON=price-feed bgrun --name mm-worker --command "bun run worker.ts" --force
584
+ ```
585
+
586
+ When you start `mm-worker`, bgrun will automatically start `price-feed` first if it's not already running.
587
+
588
+ ### Features
589
+
590
+ - **Topological sort** — processes start in correct dependency order
591
+ - **Cycle detection** — bgrun warns if dependencies form a cycle
592
+ - **Auto-start** — unmet dependencies are started automatically
593
+ - **API** — `GET /api/deps` returns the full dependency graph with startup order
594
+
595
+ ### Setting dependencies via API
596
+
597
+ ```bash
598
+ curl -X POST http://localhost:3001/api/deps \
599
+ -H 'Content-Type: application/json' \
600
+ -d '{"name": "mm-worker", "dependsOn": ["price-feed", "redis"]}'
601
+ ```
602
+
603
+ ---
604
+
605
+ ## Log Rotation
606
+
607
+ bgrun includes automatic log rotation to prevent unbounded log file growth:
608
+
609
+ - **Size-based rotation**: Files exceeding 10MB are truncated, keeping the last 5000 lines
610
+ - **Automatic checks**: Rotation runs every 60 seconds alongside the dashboard
611
+ - **Rotation header**: Rotated files include a timestamp header for auditability
612
+ - **Manual rotation**: Trigger via the dashboard API
613
+
614
+ ```bash
615
+ # Check log sizes
616
+ curl http://localhost:3001/api/logs/rotate
617
+
618
+ # Force rotation now
619
+ curl -X POST http://localhost:3001/api/logs/rotate
620
+ ```
621
+
622
+ ---
623
+
624
+ ## Guard (Auto-Restart)
625
+
626
+ bgrun includes a standalone guard process that monitors and auto-restarts crashed processes:
627
+
628
+ ```bash
629
+ # Enable guard for a process
630
+ BGR_KEEP_ALIVE=true bgrun --name my-api --command "bun run server.ts" --force
631
+ ```
632
+
633
+ The guard checks processes every 30 seconds and restarts any that have stopped. Features:
634
+
635
+ - **Exponential backoff** — avoids restart storms for processes that keep crashing
636
+ - **Per-process toggle** — enable/disable guard via the dashboard shield icon
637
+ - **Guard sentinel** — dashboard shows a pulsing green dot when the guard is active
638
+ - **Restart counter** — dashboard tracks total guard restarts across all processes
639
+
640
+ ---
641
+
564
642
  ## Edge Cases & Behaviors
565
643
 
566
644
  ### What happens when a process crashes?
@@ -702,6 +780,8 @@ All state lives in `~/.bgr/`. To reset everything, delete this directory.
702
780
  |----------|-------------|---------|
703
781
  | `DB_NAME` | Custom database file name | `bgr` |
704
782
  | `BGR_GROUP` | Assign process to a group | *(none)* |
783
+ | `BGR_KEEP_ALIVE` | Enable guard auto-restart for this process | `false` |
784
+ | `BGR_DEPENDS_ON` | Comma-separated list of process dependencies | *(none)* |
705
785
  | `BUN_PORT` | Dashboard port (explicit, no fallback) | *(auto: 3000+)* |
706
786
 
707
787
  ---
@@ -0,0 +1,49 @@
1
+ /**
2
+ * GET /api/deps — Get the process dependency graph
3
+ * POST /api/deps — Set dependencies for a process
4
+ * Body: { name: string, dependsOn: string[] }
5
+ */
6
+
7
+ import { getProcess, updateProcessEnv } from '../../../../src/db'
8
+ import { buildDepGraph } from '../../../../src/deps'
9
+ import { parseEnvString } from '../../../../src/utils'
10
+
11
+ export async function GET() {
12
+ const graph = await buildDepGraph()
13
+ return Response.json(graph)
14
+ }
15
+
16
+ export async function POST(req: Request) {
17
+ try {
18
+ const body = await req.json() as { name: string; dependsOn: string[] }
19
+ if (!body.name) {
20
+ return Response.json({ error: 'Missing process name' }, { status: 400 })
21
+ }
22
+
23
+ const proc = getProcess(body.name)
24
+ if (!proc) {
25
+ return Response.json({ error: `Process "${body.name}" not found` }, { status: 404 })
26
+ }
27
+
28
+ // Parse existing env
29
+ let env: Record<string, string> = {}
30
+ try { env = JSON.parse(proc.env) } catch { env = parseEnvString(proc.env) }
31
+
32
+ // Update dependencies
33
+ if (body.dependsOn && body.dependsOn.length > 0) {
34
+ env.BGR_DEPENDS_ON = body.dependsOn.join(',')
35
+ } else {
36
+ delete env.BGR_DEPENDS_ON
37
+ }
38
+
39
+ updateProcessEnv(body.name, JSON.stringify(env))
40
+
41
+ return Response.json({
42
+ ok: true,
43
+ name: body.name,
44
+ dependsOn: body.dependsOn || [],
45
+ })
46
+ } catch (err: any) {
47
+ return Response.json({ error: err.message }, { status: 500 })
48
+ }
49
+ }
@@ -26,7 +26,12 @@ export async function GET(req: Request) {
26
26
  controller.enqueue(encoder.encode(`data: []\n\n`));
27
27
  }
28
28
 
29
- controller.enqueue(encoder.encode(`: keepalive\n\n`));
29
+ // Periodic keepalive to prevent proxy/browser timeouts
30
+ const keepaliveInterval = setInterval(() => {
31
+ try {
32
+ controller.enqueue(encoder.encode(`: keepalive\n\n`));
33
+ } catch { /* stream closed */ }
34
+ }, 15_000);
30
35
 
31
36
  // Then send updates every INTERVAL_MS
32
37
  const interval = setInterval(async () => {
@@ -40,7 +45,10 @@ export async function GET(req: Request) {
40
45
  }, INTERVAL_MS);
41
46
 
42
47
  // Store cleanup for when the stream is cancelled
43
- (stream as any).__cleanup = () => clearInterval(interval);
48
+ (stream as any).__cleanup = () => {
49
+ clearInterval(interval);
50
+ clearInterval(keepaliveInterval);
51
+ };
44
52
  },
45
53
  cancel() {
46
54
  if ((stream as any).__cleanup) {
@@ -5,7 +5,7 @@
5
5
  * When enabled=true, the built-in guard will auto-restart this process if it dies.
6
6
  * When enabled=false, the process is left alone.
7
7
  */
8
- import { getProcess, updateProcessEnv } from '../../../src/db';
8
+ import { getProcess, updateProcessEnv } from '../../../../src/db';
9
9
 
10
10
  export async function POST(req: Request) {
11
11
  try {
@@ -0,0 +1,50 @@
1
+ /**
2
+ * POST /api/guard-all — Bulk toggle guard for all processes
3
+ * Body: { enabled: boolean }
4
+ *
5
+ * When enabled=true, sets BGR_KEEP_ALIVE=true for ALL processes (except bgr-dashboard).
6
+ * When enabled=false, removes BGR_KEEP_ALIVE from ALL processes.
7
+ */
8
+ import { getAllProcesses, getProcess, updateProcessEnv } from '../../../../src/db';
9
+
10
+ const SKIP = new Set(['bgr-dashboard']);
11
+
12
+ export async function POST(req: Request) {
13
+ try {
14
+ const body = await req.json() as { enabled: boolean };
15
+ const processes = getAllProcesses();
16
+ let count = 0;
17
+
18
+ for (const proc of processes) {
19
+ if (SKIP.has(proc.name)) continue;
20
+
21
+ // Parse existing env
22
+ let env: Record<string, string> = {};
23
+ if (proc.env) {
24
+ try { env = JSON.parse(proc.env); } catch { env = {}; }
25
+ }
26
+
27
+ const alreadyGuarded = env.BGR_KEEP_ALIVE === 'true';
28
+ if (body.enabled && alreadyGuarded) continue;
29
+ if (!body.enabled && !alreadyGuarded) continue;
30
+
31
+ if (body.enabled) {
32
+ env.BGR_KEEP_ALIVE = 'true';
33
+ } else {
34
+ delete env.BGR_KEEP_ALIVE;
35
+ }
36
+
37
+ updateProcessEnv(proc.name, JSON.stringify(env));
38
+ count++;
39
+ }
40
+
41
+ return Response.json({
42
+ ok: true,
43
+ action: body.enabled ? 'guarded' : 'unguarded',
44
+ count,
45
+ total: processes.length,
46
+ });
47
+ } catch (err: any) {
48
+ return Response.json({ error: err.message }, { status: 500 });
49
+ }
50
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * POST /api/logs/rotate — Rotate all log files
3
+ * GET /api/logs/rotate — Get log file sizes
4
+ */
5
+
6
+ import { getAllProcesses } from '../../../../../src/db'
7
+ import { rotateAllLogs } from '../../../../../src/log-rotation'
8
+ import { existsSync, statSync } from 'fs'
9
+
10
+ export function POST() {
11
+ const result = rotateAllLogs(() => getAllProcesses())
12
+ return Response.json({
13
+ ok: true,
14
+ rotated: result.rotated,
15
+ checked: result.checked,
16
+ })
17
+ }
18
+
19
+ export function GET() {
20
+ const processes = getAllProcesses()
21
+ const files: Array<{ name: string; type: string; path: string; sizeBytes: number; sizeMB: string }> = []
22
+
23
+ for (const proc of processes) {
24
+ for (const [type, path] of [['stdout', proc.stdout_path], ['stderr', proc.stderr_path]] as const) {
25
+ if (path && existsSync(path)) {
26
+ const stat = statSync(path)
27
+ files.push({
28
+ name: proc.name,
29
+ type,
30
+ path,
31
+ sizeBytes: stat.size,
32
+ sizeMB: (stat.size / 1_000_000).toFixed(2),
33
+ })
34
+ }
35
+ }
36
+ }
37
+
38
+ const totalBytes = files.reduce((sum, f) => sum + f.sizeBytes, 0)
39
+
40
+ return Response.json({
41
+ files,
42
+ totalBytes,
43
+ totalMB: (totalBytes / 1_000_000).toFixed(2),
44
+ })
45
+ }
@@ -1,6 +1,7 @@
1
1
  import { getAllProcesses, updateProcessPid } from '../../../../src/db';
2
2
  import { calculateRuntime } from '../../../../src/utils';
3
- import { getProcessBatchMemory, reconcileProcessPids } from '../../../../src/platform';
3
+ import { getProcessBatchResources, reconcileProcessPids } from '../../../../src/platform';
4
+ import { guardRestartCounts } from '../../../../src/server';
4
5
  import { measure, createMeasure } from 'measure-fn';
5
6
  import { $ } from 'bun';
6
7
 
@@ -14,7 +15,12 @@ const g = globalThis as any;
14
15
  if (!g.__bgrProcessCache) {
15
16
  g.__bgrProcessCache = { data: null, timestamp: 0, inflight: null };
16
17
  }
18
+ if (!g.__bgrResourceHistory) {
19
+ // Map of process name -> { memory: number[], cpu: number[], lastCpuTime: number, lastCheck: number }
20
+ g.__bgrResourceHistory = new Map<string, { memory: number[], cpu: number[], lastCpuTime: number, lastCheck: number }>();
21
+ }
17
22
  const cache = g.__bgrProcessCache;
23
+ const history = g.__bgrResourceHistory;
18
24
 
19
25
  function withTimeout<T>(promise: Promise<T>, fallback: T): Promise<T> {
20
26
  return Promise.race([
@@ -109,10 +115,10 @@ async function fetchProcesses(): Promise<any[]> {
109
115
  const pids = procs.map((p: any) => p.pid);
110
116
 
111
117
  // Three subprocess calls total (not 3×N)
112
- let [runningPids, portMap, memoryMap] = await Promise.all([
118
+ let [runningPids, portMap, resourceMap] = await Promise.all([
113
119
  m('Running PIDs', () => withTimeout(getRunningPids(pids), new Set<number>())),
114
120
  m('Port map', () => withTimeout(getPortsByPid(pids), new Map<number, number[]>())),
115
- m('Memory map', () => withTimeout(getProcessBatchMemory(pids), new Map<number, number>())),
121
+ m('Resource map', () => withTimeout(getProcessBatchResources(pids), new Map<number, { memory: number, cpu: number }>())),
116
122
  ]);
117
123
 
118
124
  // PID reconciliation: if stored PIDs are dead, try to find the real process
@@ -139,22 +145,69 @@ async function fetchProcesses(): Promise<any[]> {
139
145
  if (!runningPids) runningPids = new Set();
140
146
  for (const pid of newPids) runningPids.add(pid);
141
147
 
142
- // Re-fetch ports and memory for the new PIDs
143
- const [newPorts, newMem] = await Promise.all([
148
+ // Re-fetch ports and resources for the new PIDs
149
+ const [newPorts, newResources] = await Promise.all([
144
150
  withTimeout(getPortsByPid(newPids), new Map<number, number[]>()),
145
- withTimeout(getProcessBatchMemory(newPids), new Map<number, number>()),
151
+ withTimeout(getProcessBatchResources(newPids), new Map<number, { memory: number, cpu: number }>()),
146
152
  ]);
147
153
  if (!portMap) portMap = new Map();
148
- if (!memoryMap) memoryMap = new Map();
154
+ if (!resourceMap) resourceMap = new Map();
149
155
  for (const [pid, ports] of newPorts) portMap.set(pid, ports);
150
- for (const [pid, mem] of newMem) memoryMap.set(pid, mem);
156
+ for (const [pid, res] of newResources) resourceMap.set(pid, res);
151
157
  }
152
158
  }
153
159
 
160
+ const now = Date.now();
161
+ const isWin = process.platform === 'win32';
162
+
154
163
  return procs.map((p: any) => {
155
164
  const running = runningPids?.has(p.pid) ?? false;
156
165
  const ports = running ? (portMap?.get(p.pid) || []) : [];
157
- const memory = running ? (memoryMap?.get(p.pid) || 0) : 0;
166
+ const res = running ? (resourceMap?.get(p.pid) || { memory: 0, cpu: 0 }) : { memory: 0, cpu: 0 };
167
+
168
+ // Manage history tracking for sparklines (up to 60 points = 5 minutes at 5s polling)
169
+ let h = history.get(p.name);
170
+ if (!h) {
171
+ h = { memory: [], cpu: [], lastCpuTime: 0, lastCheck: 0 };
172
+ history.set(p.name, h);
173
+ }
174
+
175
+ let cpuPercent = 0;
176
+ if (running) {
177
+ if (isWin) {
178
+ // Windows: cpu is cumulative seconds. Calculate delta percentage across time.
179
+ if (h.lastCheck > 0 && h.lastCpuTime > 0) {
180
+ const timeDeltaSec = (now - h.lastCheck) / 1000;
181
+ const cpuDeltaSec = res.cpu - h.lastCpuTime;
182
+ if (timeDeltaSec > 0 && cpuDeltaSec >= 0) {
183
+ // Max it at 100% per core? We'll just display the total usage
184
+ cpuPercent = (cpuDeltaSec / timeDeltaSec) * 100;
185
+ }
186
+ }
187
+ h.lastCpuTime = res.cpu;
188
+ } else {
189
+ // Unix: it's already a percentage from \`ps\`
190
+ cpuPercent = res.cpu;
191
+ }
192
+
193
+ // Add points
194
+ h.memory.push(res.memory);
195
+ h.cpu.push(cpuPercent);
196
+ if (h.memory.length > 60) h.memory.shift();
197
+ if (h.cpu.length > 60) h.cpu.shift();
198
+ } else {
199
+ // Not running, push zeros if not already zeroed out to bring graphs down
200
+ if (h.memory.length > 0 && h.memory[h.memory.length - 1] !== 0) {
201
+ h.memory.push(0);
202
+ h.cpu.push(0);
203
+ if (h.memory.length > 60) h.memory.shift();
204
+ if (h.cpu.length > 60) h.cpu.shift();
205
+ }
206
+ h.lastCheck = 0;
207
+ h.lastCpuTime = 0;
208
+ }
209
+
210
+ if (running) h.lastCheck = now;
158
211
 
159
212
  return {
160
213
  name: p.name,
@@ -164,7 +217,10 @@ async function fetchProcesses(): Promise<any[]> {
164
217
  running,
165
218
  port: ports.length > 0 ? ports[0] : null,
166
219
  ports,
167
- memory, // Bytes
220
+ memory: res.memory, // Bytes
221
+ cpu: cpuPercent, // Percentage
222
+ memoryHistory: [...h.memory],
223
+ cpuHistory: [...h.cpu],
168
224
  group: getProcessGroup(p.env),
169
225
  runtime: calculateRuntime(p.timestamp),
170
226
  timestamp: p.timestamp,
@@ -172,6 +228,7 @@ async function fetchProcesses(): Promise<any[]> {
172
228
  configPath: p.configPath || '',
173
229
  stdoutPath: p.stdout_path || '',
174
230
  stderrPath: p.stderr_path || '',
231
+ guardRestarts: guardRestartCounts.get(p.name) || 0,
175
232
  };
176
233
  });
177
234
  }) ?? [];