bgrun 3.12.4 → 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
+ }
@@ -1292,6 +1292,23 @@ export default function mount(): () => void {
1292
1292
  else renderEnvPanel();
1293
1293
  }
1294
1294
 
1295
+ function updateLogExportLinks() {
1296
+ const textBtn = $('log-export-text-btn') as HTMLAnchorElement | null;
1297
+ const jsonBtn = $('log-export-json-btn') as HTMLAnchorElement | null;
1298
+ const csvBtn = $('log-export-csv-btn') as HTMLAnchorElement | null;
1299
+ const disabledHref = '#';
1300
+ if (!drawerProcess) {
1301
+ if (textBtn) textBtn.href = disabledHref;
1302
+ if (jsonBtn) jsonBtn.href = disabledHref;
1303
+ if (csvBtn) csvBtn.href = disabledHref;
1304
+ return;
1305
+ }
1306
+ const base = `/api/logs/${encodeURIComponent(drawerProcess)}?tab=${drawerTab}&offset=0&download=1`;
1307
+ if (textBtn) textBtn.href = `${base}&format=text`;
1308
+ if (jsonBtn) jsonBtn.href = `${base}&format=json`;
1309
+ if (csvBtn) csvBtn.href = `${base}&format=csv`;
1310
+ }
1311
+
1295
1312
  function switchLogSubtab(subtab: string, skipRefresh = false) {
1296
1313
  drawerTab = subtab as 'stdout' | 'stderr';
1297
1314
  $('log-subtabs')?.querySelectorAll('.accordion-subtab').forEach(btn => {
@@ -1305,6 +1322,7 @@ export default function mount(): () => void {
1305
1322
  logNeedsFullRebuild = true;
1306
1323
  logVirtualActive = false;
1307
1324
  logFilteredIndices = [];
1325
+ updateLogExportLinks();
1308
1326
  if (!skipRefresh) refreshDrawerLogs();
1309
1327
  }
1310
1328
 
@@ -1354,6 +1372,7 @@ export default function mount(): () => void {
1354
1372
  function openDrawer(name: string) {
1355
1373
  drawerProcess = name;
1356
1374
  drawerTab = 'stdout';
1375
+ updateLogExportLinks();
1357
1376
 
1358
1377
  // Update header
1359
1378
  const nameEl = $('drawer-process-name');
@@ -1501,6 +1520,7 @@ export default function mount(): () => void {
1501
1520
  drawer?.classList.remove('open');
1502
1521
  backdrop?.classList.remove('active');
1503
1522
  drawerProcess = null;
1523
+ updateLogExportLinks();
1504
1524
  tbody?.querySelectorAll('tr.selected').forEach(r => r.classList.remove('selected'));
1505
1525
  }
1506
1526
 
@@ -2559,6 +2579,21 @@ export default function mount(): () => void {
2559
2579
  }
2560
2580
  }
2561
2581
 
2582
+ function updateHistoryExportLinks() {
2583
+ const processFilter = $('history-process-filter') as HTMLSelectElement | null;
2584
+ const eventFilter = $('history-event-filter') as HTMLSelectElement | null;
2585
+ const metadataFilter = $('history-metadata-filter') as HTMLInputElement | null;
2586
+ const params = new URLSearchParams({ limit: '100', download: '1' });
2587
+ if (processFilter?.value) params.set('name', processFilter.value);
2588
+ if (eventFilter?.value) params.set('event', eventFilter.value);
2589
+ if (metadataFilter?.value.trim()) params.set('metadata', metadataFilter.value.trim());
2590
+
2591
+ const jsonBtn = $('history-export-json-btn') as HTMLAnchorElement | null;
2592
+ const csvBtn = $('history-export-csv-btn') as HTMLAnchorElement | null;
2593
+ if (jsonBtn) jsonBtn.href = `/api/history?${params.toString()}&format=json`;
2594
+ if (csvBtn) csvBtn.href = `/api/history?${params.toString()}&format=csv`;
2595
+ }
2596
+
2562
2597
  function updateHistoryFilters() {
2563
2598
  const processFilter = $('history-process-filter') as HTMLSelectElement;
2564
2599
  const eventFilter = $('history-event-filter') as HTMLSelectElement;
@@ -2827,6 +2862,7 @@ export default function mount(): () => void {
2827
2862
  }
2828
2863
 
2829
2864
  updateHistoryClearButton();
2865
+ updateHistoryExportLinks();
2830
2866
  applyHistoryDensity();
2831
2867
  applyHistoryShortcutPreference();
2832
2868
  applyHistoryDetailsPreference();
@@ -266,6 +266,11 @@ export default function DashboardPage() {
266
266
  <div className="drawer-log-toolbar" id="drawer-log-toolbar">
267
267
  <input type="text" id="log-search" className="log-search" placeholder="Filter logs..." />
268
268
  <span className="log-line-count" id="log-line-count"></span>
269
+ <div className="toolbar-export-actions">
270
+ <a id="log-export-text-btn" className="btn btn-ghost btn-sm" href="#" target="_blank" rel="noopener">Export .log</a>
271
+ <a id="log-export-json-btn" className="btn btn-ghost btn-sm" href="#" target="_blank" rel="noopener">Export JSON</a>
272
+ <a id="log-export-csv-btn" className="btn btn-ghost btn-sm" href="#" target="_blank" rel="noopener">Export CSV</a>
273
+ </div>
269
274
  <button id="log-autoscroll-btn" className="log-autoscroll" title="Auto-scroll: OFF">
270
275
  <svg viewBox="0 0 24 24"><path d="M12 5v14M5 12l7 7 7-7" /></svg>
271
276
  Follow
@@ -387,6 +392,10 @@ export default function DashboardPage() {
387
392
  </select>
388
393
  </label>
389
394
  <button className="btn btn-ghost btn-sm" id="history-clear-filters-btn" title="Clear all history filters">Clear</button>
395
+ <div className="toolbar-export-actions">
396
+ <a id="history-export-json-btn" className="btn btn-ghost btn-sm" href="#" target="_blank" rel="noopener">Export JSON</a>
397
+ <a id="history-export-csv-btn" className="btn btn-ghost btn-sm" href="#" target="_blank" rel="noopener">Export CSV</a>
398
+ </div>
390
399
  </div>
391
400
  <div className="history-hints-bar">
392
401
  <div className="history-hints-bar-left">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bgrun",
3
- "version": "3.12.4",
3
+ "version": "3.12.5",
4
4
  "description": "bgrun — A lightweight process manager for Bun",
5
5
  "type": "module",
6
6
  "main": "./src/api.ts",