bgrun 3.12.3 → 3.12.5

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.
@@ -1,39 +1,105 @@
1
1
  import { getProcessHistory, getRecentHistory, addHistoryEntry } from '../../../../src/db';
2
2
 
3
+ function stringifyMetadata(metadata: unknown) {
4
+ try {
5
+ return JSON.stringify(metadata ?? {});
6
+ } catch {
7
+ return '{}';
8
+ }
9
+ }
10
+
11
+ function escapeCsv(value: unknown) {
12
+ const text = String(value ?? '');
13
+ if (/[",\n\r]/.test(text)) {
14
+ return `"${text.replace(/"/g, '""')}"`;
15
+ }
16
+ return text;
17
+ }
18
+
19
+ function buildHistoryCsv(rows: Array<{
20
+ process_name: string;
21
+ event: string;
22
+ pid: number | null;
23
+ timestamp: string;
24
+ metadata: unknown;
25
+ }>) {
26
+ const header = ['process_name', 'event', 'pid', 'timestamp', 'metadata'];
27
+ const lines = rows.map((row) => [
28
+ row.process_name,
29
+ row.event,
30
+ row.pid ?? '',
31
+ row.timestamp,
32
+ stringifyMetadata(row.metadata),
33
+ ].map(escapeCsv).join(','));
34
+ return [header.join(','), ...lines].join('\n');
35
+ }
36
+
3
37
  export async function GET(req: Request) {
4
38
  const url = new URL(req.url);
5
39
  const name = url.searchParams.get('name');
40
+ const event = (url.searchParams.get('event') || '').trim().toLowerCase();
41
+ const metadataFilter = (url.searchParams.get('metadata') || '')
42
+ .split(',')
43
+ .map((value) => value.toLowerCase().trim())
44
+ .filter(Boolean);
6
45
  const limit = parseInt(url.searchParams.get('limit') || '50');
7
-
46
+ const format = (url.searchParams.get('format') || 'json').toLowerCase();
47
+ const download = url.searchParams.get('download') === '1';
48
+
8
49
  let history;
9
50
  if (name) {
10
51
  history = getProcessHistory(name, limit);
11
52
  } else {
12
53
  history = getRecentHistory(limit);
13
54
  }
14
-
15
- return Response.json(history.map((h: any) => ({
55
+
56
+ let rows = history.map((h: any) => ({
16
57
  process_name: h.process_name,
17
58
  event: h.event,
18
59
  pid: h.pid,
19
60
  timestamp: h.timestamp,
20
61
  metadata: h.metadata ? JSON.parse(h.metadata) : {},
21
- })));
62
+ }));
63
+
64
+ if (event) {
65
+ rows = rows.filter((row) => row.event.toLowerCase() === event);
66
+ }
67
+ if (metadataFilter.length > 0) {
68
+ rows = rows.filter((row) => {
69
+ const haystack = stringifyMetadata(row.metadata).toLowerCase();
70
+ return metadataFilter.every((term) => haystack.includes(term));
71
+ });
72
+ }
73
+
74
+ if (format === 'csv') {
75
+ return new Response(buildHistoryCsv(rows), {
76
+ headers: {
77
+ 'content-type': 'text/csv; charset=utf-8',
78
+ ...(download ? { 'content-disposition': `attachment; filename="bgr-history${name ? `-${encodeURIComponent(name)}` : ''}.csv"` } : {}),
79
+ },
80
+ });
81
+ }
82
+
83
+ return Response.json(rows, {
84
+ headers: download
85
+ ? { 'content-disposition': `attachment; filename="bgr-history${name ? `-${encodeURIComponent(name)}` : ''}.json"` }
86
+ : undefined,
87
+ });
22
88
  }
23
89
 
24
90
  export async function POST(req: Request) {
25
91
  try {
26
92
  const body = await req.json();
27
93
  const { process_name, event, pid, metadata } = body;
28
-
94
+
29
95
  if (!process_name || !event) {
30
96
  return Response.json({ error: 'process_name and event are required' }, { status: 400 });
31
97
  }
32
-
98
+
33
99
  addHistoryEntry(process_name, event, pid, metadata);
34
100
  return Response.json({ success: true });
35
101
  } catch (err) {
36
102
  console.error('[api/history] Error adding history:', err);
37
103
  return Response.json({ error: 'Failed to add history' }, { status: 500 });
38
104
  }
39
- }
105
+ }
@@ -1,67 +1,100 @@
1
- /**
2
- * GET /api/logs/:name — Read process stdout/stderr logs
3
- *
4
- * Supports incremental loading via query params:
5
- * ?tab=stdout|stderr — which log to read (default: stdout)
6
- * ?offset=N — byte offset to start reading from (default: 0 = full file)
7
- *
8
- * Returns:
9
- * { text, size, mtime, filePath }
10
- *
11
- * On first call (offset=0), returns full file content.
12
- * On subsequent calls (offset=previousSize), returns only new bytes.
13
- * Client uses `size` as the offset for the next request.
14
- */
15
- import { getProcess } from '../../../../../src/db';
16
- import { stat, open } from 'fs/promises';
17
-
18
- interface FileInfo {
19
- text: string;
20
- size: number;
21
- mtime: string | null;
22
- filePath: string;
23
- }
24
-
25
- async function readLogFile(path: string, offset: number): Promise<FileInfo> {
26
- try {
27
- const s = await stat(path);
28
- const size = s.size;
29
- const mtime = s.mtime.toISOString();
30
-
31
- // If offset >= current size, no new data
32
- if (offset >= size) {
33
- return { text: '', size, mtime, filePath: path };
34
- }
35
-
36
- // Read from offset to end
37
- const handle = await open(path, 'r');
38
- try {
39
- const bytesToRead = size - offset;
40
- const buffer = Buffer.alloc(bytesToRead);
41
- await handle.read(buffer, 0, bytesToRead, offset);
42
- return { text: buffer.toString('utf-8'), size, mtime, filePath: path };
43
- } finally {
44
- await handle.close();
45
- }
46
- } catch {
47
- return { text: '', size: 0, mtime: null, filePath: path };
48
- }
49
- }
50
-
51
- export async function GET(req: Request, { params }: { params: { name: string } }) {
52
- const name = decodeURIComponent(params.name);
53
- const proc = getProcess(name);
54
-
55
- if (!proc) {
56
- return Response.json({ error: 'Process not found' }, { status: 404 });
57
- }
58
-
59
- const url = new URL(req.url);
60
- const tab = url.searchParams.get('tab') || 'stdout';
61
- const offset = parseInt(url.searchParams.get('offset') || '0', 10) || 0;
62
-
63
- const path = tab === 'stderr' ? proc.stderr_path : proc.stdout_path;
64
- const info = await readLogFile(path, offset);
65
-
66
- return Response.json(info);
67
- }
1
+ /**
2
+ * GET /api/logs/:name — Read process stdout/stderr logs
3
+ *
4
+ * Supports incremental loading via query params:
5
+ * ?tab=stdout|stderr — which log to read (default: stdout)
6
+ * ?offset=N — byte offset to start reading from (default: 0 = full file)
7
+ * ?format=json|text|csv — response format (default: json)
8
+ *
9
+ * Returns JSON by default:
10
+ * { text, size, mtime, filePath }
11
+ */
12
+ import { getProcess } from '../../../../../src/db';
13
+ import { stat, open } from 'fs/promises';
14
+
15
+ interface FileInfo {
16
+ text: string;
17
+ size: number;
18
+ mtime: string | null;
19
+ filePath: string;
20
+ }
21
+
22
+ function escapeCsv(value: unknown) {
23
+ const text = String(value ?? '');
24
+ if (/[",\n\r]/.test(text)) {
25
+ return `"${text.replace(/"/g, '""')}"`;
26
+ }
27
+ return text;
28
+ }
29
+
30
+ function buildLogCsv(text: string) {
31
+ const header = 'line,text';
32
+ const lines = text.split('\n').map((line, index) => `${index + 1},${escapeCsv(line)}`);
33
+ return [header, ...lines].join('\n');
34
+ }
35
+
36
+ async function readLogFile(path: string, offset: number): Promise<FileInfo> {
37
+ try {
38
+ const s = await stat(path);
39
+ const size = s.size;
40
+ const mtime = s.mtime.toISOString();
41
+
42
+ if (offset >= size) {
43
+ return { text: '', size, mtime, filePath: path };
44
+ }
45
+
46
+ const handle = await open(path, 'r');
47
+ try {
48
+ const bytesToRead = size - offset;
49
+ const buffer = Buffer.alloc(bytesToRead);
50
+ await handle.read(buffer, 0, bytesToRead, offset);
51
+ return { text: buffer.toString('utf-8'), size, mtime, filePath: path };
52
+ } finally {
53
+ await handle.close();
54
+ }
55
+ } catch {
56
+ return { text: '', size: 0, mtime: null, filePath: path };
57
+ }
58
+ }
59
+
60
+ export async function GET(req: Request, { params }: { params: { name: string } }) {
61
+ const name = decodeURIComponent(params.name);
62
+ const proc = getProcess(name);
63
+
64
+ if (!proc) {
65
+ return Response.json({ error: 'Process not found' }, { status: 404 });
66
+ }
67
+
68
+ const url = new URL(req.url);
69
+ const tab = url.searchParams.get('tab') || 'stdout';
70
+ const offset = parseInt(url.searchParams.get('offset') || '0', 10) || 0;
71
+ const format = (url.searchParams.get('format') || 'json').toLowerCase();
72
+ const download = url.searchParams.get('download') === '1';
73
+
74
+ const path = tab === 'stderr' ? proc.stderr_path : proc.stdout_path;
75
+ const info = await readLogFile(path, offset);
76
+
77
+ if (format === 'text') {
78
+ return new Response(info.text, {
79
+ headers: {
80
+ 'content-type': 'text/plain; charset=utf-8',
81
+ ...(download ? { 'content-disposition': `attachment; filename="${encodeURIComponent(name)}-${tab}.log"` } : {}),
82
+ },
83
+ });
84
+ }
85
+
86
+ if (format === 'csv') {
87
+ return new Response(buildLogCsv(info.text), {
88
+ headers: {
89
+ 'content-type': 'text/csv; charset=utf-8',
90
+ ...(download ? { 'content-disposition': `attachment; filename="${encodeURIComponent(name)}-${tab}.csv"` } : {}),
91
+ },
92
+ });
93
+ }
94
+
95
+ return Response.json(info, {
96
+ headers: download
97
+ ? { 'content-disposition': `attachment; filename="${encodeURIComponent(name)}-${tab}.json"` }
98
+ : undefined,
99
+ });
100
+ }
@@ -2463,6 +2463,14 @@ a.port-link:hover {
2463
2463
  border-bottom: 1px solid var(--border-subtle);
2464
2464
  background: rgba(0, 0, 0, 0.08);
2465
2465
  flex-shrink: 0;
2466
+ flex-wrap: wrap;
2467
+ }
2468
+
2469
+ .toolbar-export-actions {
2470
+ display: inline-flex;
2471
+ align-items: center;
2472
+ gap: 0.5rem;
2473
+ flex-wrap: wrap;
2466
2474
  }
2467
2475
 
2468
2476
  .log-search {
@@ -4000,171 +4008,171 @@ tr.keyboard-focus td:first-child .process-name span {
4000
4008
  opacity: 0.6;
4001
4009
  cursor: wait;
4002
4010
  }
4003
-
4004
- /* ─── Dependencies Graph ─── */
4005
-
4006
- .deps-controls {
4007
- display: flex;
4008
- align-items: center;
4009
- gap: 0.75rem;
4010
- margin-bottom: 1.25rem;
4011
- flex-wrap: wrap;
4012
- }
4013
-
4014
- .deps-arrow {
4015
- color: var(--text-secondary);
4016
- font-size: 0.85rem;
4017
- white-space: nowrap;
4018
- }
4019
-
4020
- .deps-graph-container {
4021
- background: rgba(0, 0, 0, 0.15);
4022
- border-radius: 12px;
4023
- border: 1px solid var(--border);
4024
- margin-bottom: 1.25rem;
4025
- overflow: hidden;
4026
- min-height: 300px;
4027
- position: relative;
4028
- }
4029
-
4030
- .deps-graph-container svg {
4031
- display: block;
4032
- }
4033
-
4034
- .deps-node {
4035
- cursor: pointer;
4036
- }
4037
-
4038
- .deps-node rect {
4039
- rx: 8;
4040
- ry: 8;
4041
- stroke-width: 2;
4042
- transition: filter 0.15s;
4043
- }
4044
-
4045
- .deps-node:hover rect {
4046
- filter: brightness(1.3);
4047
- }
4048
-
4049
- .deps-node text {
4050
- font-family: var(--font-mono);
4051
- font-size: 12px;
4052
- fill: var(--text-primary);
4053
- pointer-events: none;
4054
- }
4055
-
4056
- .deps-edge {
4057
- stroke: var(--text-secondary);
4058
- stroke-width: 1.5;
4059
- fill: none;
4060
- opacity: 0.6;
4061
- marker-end: url(#deps-arrowhead);
4062
- }
4063
-
4064
- .deps-edge-highlight {
4065
- stroke: var(--accent);
4066
- opacity: 1;
4067
- stroke-width: 2;
4068
- }
4069
-
4070
- .deps-list {
4071
- margin-bottom: 1rem;
4072
- }
4073
-
4074
- .deps-list-item {
4075
- display: flex;
4076
- align-items: center;
4077
- gap: 0.5rem;
4078
- padding: 0.5rem 0.75rem;
4079
- border-bottom: 1px solid var(--border);
4080
- font-size: 0.85rem;
4081
- }
4082
-
4083
- .deps-list-item:last-child {
4084
- border-bottom: none;
4085
- }
4086
-
4087
- .deps-list-item .deps-item-process {
4088
- font-weight: 600;
4089
- color: var(--accent);
4090
- font-family: var(--font-mono);
4091
- }
4092
-
4093
- .deps-list-item .deps-item-arrow {
4094
- color: var(--text-secondary);
4095
- }
4096
-
4097
- .deps-list-item .deps-item-target {
4098
- font-family: var(--font-mono);
4099
- color: var(--text-primary);
4100
- }
4101
-
4102
- .deps-list-item .deps-remove-btn {
4103
- margin-left: auto;
4104
- background: none;
4105
- border: none;
4106
- color: var(--red);
4107
- cursor: pointer;
4108
- font-size: 1rem;
4109
- padding: 0 0.25rem;
4110
- opacity: 0.6;
4111
- transition: opacity 0.15s;
4112
- }
4113
-
4114
- .deps-list-item .deps-remove-btn:hover {
4115
- opacity: 1;
4116
- }
4117
-
4118
- .deps-start-order {
4119
- padding: 0.75rem;
4120
- background: rgba(0, 0, 0, 0.1);
4121
- border-radius: 8px;
4122
- font-size: 0.8rem;
4123
- color: var(--text-secondary);
4124
- }
4125
-
4126
- .deps-start-order .deps-order-title {
4127
- font-weight: 600;
4128
- color: var(--text-primary);
4129
- margin-bottom: 0.35rem;
4130
- }
4131
-
4132
- .deps-start-order .deps-order-list {
4133
- display: flex;
4134
- flex-wrap: wrap;
4135
- gap: 0.35rem;
4136
- }
4137
-
4138
- .deps-order-badge {
4139
- display: inline-flex;
4140
- align-items: center;
4141
- gap: 0.3rem;
4142
- padding: 0.2rem 0.5rem;
4143
- background: rgba(var(--accent-rgb, 99, 102, 241), 0.15);
4144
- border-radius: 6px;
4145
- font-family: var(--font-mono);
4146
- font-size: 0.75rem;
4147
- }
4148
-
4149
- .deps-order-badge .deps-order-num {
4150
- color: var(--accent);
4151
- font-weight: 700;
4152
- font-size: 0.7rem;
4153
- }
4154
-
4155
- .deps-empty {
4156
- text-align: center;
4157
- padding: 2rem;
4158
- color: var(--text-secondary);
4159
- font-size: 0.9rem;
4160
- }
4161
-
4162
- @media (max-width: 768px) {
4163
- .deps-controls {
4164
- flex-direction: column;
4165
- align-items: stretch;
4166
- }
4167
- .deps-arrow {
4168
- text-align: center;
4169
- }
4170
- }
4011
+
4012
+ /* ─── Dependencies Graph ─── */
4013
+
4014
+ .deps-controls {
4015
+ display: flex;
4016
+ align-items: center;
4017
+ gap: 0.75rem;
4018
+ margin-bottom: 1.25rem;
4019
+ flex-wrap: wrap;
4020
+ }
4021
+
4022
+ .deps-arrow {
4023
+ color: var(--text-secondary);
4024
+ font-size: 0.85rem;
4025
+ white-space: nowrap;
4026
+ }
4027
+
4028
+ .deps-graph-container {
4029
+ background: rgba(0, 0, 0, 0.15);
4030
+ border-radius: 12px;
4031
+ border: 1px solid var(--border);
4032
+ margin-bottom: 1.25rem;
4033
+ overflow: hidden;
4034
+ min-height: 300px;
4035
+ position: relative;
4036
+ }
4037
+
4038
+ .deps-graph-container svg {
4039
+ display: block;
4040
+ }
4041
+
4042
+ .deps-node {
4043
+ cursor: pointer;
4044
+ }
4045
+
4046
+ .deps-node rect {
4047
+ rx: 8;
4048
+ ry: 8;
4049
+ stroke-width: 2;
4050
+ transition: filter 0.15s;
4051
+ }
4052
+
4053
+ .deps-node:hover rect {
4054
+ filter: brightness(1.3);
4055
+ }
4056
+
4057
+ .deps-node text {
4058
+ font-family: var(--font-mono);
4059
+ font-size: 12px;
4060
+ fill: var(--text-primary);
4061
+ pointer-events: none;
4062
+ }
4063
+
4064
+ .deps-edge {
4065
+ stroke: var(--text-secondary);
4066
+ stroke-width: 1.5;
4067
+ fill: none;
4068
+ opacity: 0.6;
4069
+ marker-end: url(#deps-arrowhead);
4070
+ }
4071
+
4072
+ .deps-edge-highlight {
4073
+ stroke: var(--accent);
4074
+ opacity: 1;
4075
+ stroke-width: 2;
4076
+ }
4077
+
4078
+ .deps-list {
4079
+ margin-bottom: 1rem;
4080
+ }
4081
+
4082
+ .deps-list-item {
4083
+ display: flex;
4084
+ align-items: center;
4085
+ gap: 0.5rem;
4086
+ padding: 0.5rem 0.75rem;
4087
+ border-bottom: 1px solid var(--border);
4088
+ font-size: 0.85rem;
4089
+ }
4090
+
4091
+ .deps-list-item:last-child {
4092
+ border-bottom: none;
4093
+ }
4094
+
4095
+ .deps-list-item .deps-item-process {
4096
+ font-weight: 600;
4097
+ color: var(--accent);
4098
+ font-family: var(--font-mono);
4099
+ }
4100
+
4101
+ .deps-list-item .deps-item-arrow {
4102
+ color: var(--text-secondary);
4103
+ }
4104
+
4105
+ .deps-list-item .deps-item-target {
4106
+ font-family: var(--font-mono);
4107
+ color: var(--text-primary);
4108
+ }
4109
+
4110
+ .deps-list-item .deps-remove-btn {
4111
+ margin-left: auto;
4112
+ background: none;
4113
+ border: none;
4114
+ color: var(--red);
4115
+ cursor: pointer;
4116
+ font-size: 1rem;
4117
+ padding: 0 0.25rem;
4118
+ opacity: 0.6;
4119
+ transition: opacity 0.15s;
4120
+ }
4121
+
4122
+ .deps-list-item .deps-remove-btn:hover {
4123
+ opacity: 1;
4124
+ }
4125
+
4126
+ .deps-start-order {
4127
+ padding: 0.75rem;
4128
+ background: rgba(0, 0, 0, 0.1);
4129
+ border-radius: 8px;
4130
+ font-size: 0.8rem;
4131
+ color: var(--text-secondary);
4132
+ }
4133
+
4134
+ .deps-start-order .deps-order-title {
4135
+ font-weight: 600;
4136
+ color: var(--text-primary);
4137
+ margin-bottom: 0.35rem;
4138
+ }
4139
+
4140
+ .deps-start-order .deps-order-list {
4141
+ display: flex;
4142
+ flex-wrap: wrap;
4143
+ gap: 0.35rem;
4144
+ }
4145
+
4146
+ .deps-order-badge {
4147
+ display: inline-flex;
4148
+ align-items: center;
4149
+ gap: 0.3rem;
4150
+ padding: 0.2rem 0.5rem;
4151
+ background: rgba(var(--accent-rgb, 99, 102, 241), 0.15);
4152
+ border-radius: 6px;
4153
+ font-family: var(--font-mono);
4154
+ font-size: 0.75rem;
4155
+ }
4156
+
4157
+ .deps-order-badge .deps-order-num {
4158
+ color: var(--accent);
4159
+ font-weight: 700;
4160
+ font-size: 0.7rem;
4161
+ }
4162
+
4163
+ .deps-empty {
4164
+ text-align: center;
4165
+ padding: 2rem;
4166
+ color: var(--text-secondary);
4167
+ font-size: 0.9rem;
4168
+ }
4169
+
4170
+ @media (max-width: 768px) {
4171
+ .deps-controls {
4172
+ flex-direction: column;
4173
+ align-items: stretch;
4174
+ }
4175
+ .deps-arrow {
4176
+ text-align: center;
4177
+ }
4178
+ }