@adcops/autocore-react 3.3.73 → 3.3.77

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.
Files changed (75) hide show
  1. package/dist/assets/HomeMotor.d.ts +4 -0
  2. package/dist/assets/HomeMotor.d.ts.map +1 -0
  3. package/dist/assets/HomeMotor.js +1 -0
  4. package/dist/assets/svg/home_motor.svg +57 -0
  5. package/dist/components/Indicator.d.ts +29 -52
  6. package/dist/components/Indicator.d.ts.map +1 -1
  7. package/dist/components/Indicator.js +1 -1
  8. package/dist/components/ValueInput.d.ts +1 -1
  9. package/dist/components/ValueInput.d.ts.map +1 -1
  10. package/dist/components/ams/AmsProvider.d.ts +7 -0
  11. package/dist/components/ams/AmsProvider.d.ts.map +1 -1
  12. package/dist/components/ams/AssetDetailView.d.ts.map +1 -1
  13. package/dist/components/ams/AssetDetailView.js +1 -1
  14. package/dist/components/ams/AssetRegistryTable.d.ts.map +1 -1
  15. package/dist/components/ams/AssetRegistryTable.js +1 -1
  16. package/dist/components/ams/CalibrationEntryDialog.d.ts.map +1 -1
  17. package/dist/components/ams/CalibrationEntryDialog.js +1 -1
  18. package/dist/components/ams/MissingAssetsBanner.d.ts +11 -0
  19. package/dist/components/ams/MissingAssetsBanner.d.ts.map +1 -0
  20. package/dist/components/ams/MissingAssetsBanner.js +1 -0
  21. package/dist/components/ams/PlaceholderHealthPanel.d.ts +3 -0
  22. package/dist/components/ams/PlaceholderHealthPanel.d.ts.map +1 -0
  23. package/dist/components/ams/PlaceholderHealthPanel.js +1 -0
  24. package/dist/components/ams/index.d.ts +2 -0
  25. package/dist/components/ams/index.d.ts.map +1 -1
  26. package/dist/components/ams/index.js +1 -1
  27. package/dist/components/index.d.ts +8 -0
  28. package/dist/components/index.d.ts.map +1 -1
  29. package/dist/components/index.js +1 -1
  30. package/dist/components/network/NetworkPanel.d.ts +8 -0
  31. package/dist/components/network/NetworkPanel.d.ts.map +1 -0
  32. package/dist/components/network/NetworkPanel.js +1 -0
  33. package/dist/components/network/NetworkProvider.d.ts +72 -0
  34. package/dist/components/network/NetworkProvider.d.ts.map +1 -0
  35. package/dist/components/network/NetworkProvider.js +1 -0
  36. package/dist/components/network/StagedChangeBanner.d.ts +8 -0
  37. package/dist/components/network/StagedChangeBanner.d.ts.map +1 -0
  38. package/dist/components/network/StagedChangeBanner.js +1 -0
  39. package/dist/components/network/index.d.ts +7 -0
  40. package/dist/components/network/index.d.ts.map +1 -0
  41. package/dist/components/network/index.js +1 -0
  42. package/dist/components/tis/ProjectManager.d.ts +7 -0
  43. package/dist/components/tis/ProjectManager.d.ts.map +1 -0
  44. package/dist/components/tis/ProjectManager.js +1 -0
  45. package/dist/components/tis/ResultHistoryTable.d.ts.map +1 -1
  46. package/dist/components/tis/ResultHistoryTable.js +1 -1
  47. package/dist/components/tis/TestDataView.d.ts.map +1 -1
  48. package/dist/components/tis/TestDataView.js +1 -1
  49. package/dist/components/tis/TestRawDataView.d.ts.map +1 -1
  50. package/dist/components/tis/TestRawDataView.js +1 -1
  51. package/dist/components/tis/TestSetupForm.d.ts +7 -0
  52. package/dist/components/tis/TestSetupForm.d.ts.map +1 -1
  53. package/dist/components/tis/TestSetupForm.js +1 -1
  54. package/package.json +5 -1
  55. package/src/assets/HomeMotor.tsx +37 -0
  56. package/src/assets/svg/home_motor.svg +57 -0
  57. package/src/components/Indicator.tsx +166 -162
  58. package/src/components/ValueInput.tsx +2 -2
  59. package/src/components/ams/AmsProvider.tsx +7 -0
  60. package/src/components/ams/AssetDetailView.tsx +287 -4
  61. package/src/components/ams/AssetRegistryTable.tsx +325 -21
  62. package/src/components/ams/CalibrationEntryDialog.tsx +163 -30
  63. package/src/components/ams/MissingAssetsBanner.tsx +124 -0
  64. package/src/components/ams/PlaceholderHealthPanel.tsx +188 -0
  65. package/src/components/ams/index.ts +2 -0
  66. package/src/components/index.ts +26 -0
  67. package/src/components/network/NetworkPanel.tsx +363 -0
  68. package/src/components/network/NetworkProvider.tsx +349 -0
  69. package/src/components/network/StagedChangeBanner.tsx +101 -0
  70. package/src/components/network/index.ts +17 -0
  71. package/src/components/tis/ProjectManager.tsx +392 -0
  72. package/src/components/tis/ResultHistoryTable.tsx +125 -74
  73. package/src/components/tis/TestDataView.tsx +160 -14
  74. package/src/components/tis/TestRawDataView.tsx +118 -8
  75. package/src/components/tis/TestSetupForm.tsx +42 -1
@@ -0,0 +1,392 @@
1
+ /*
2
+ * Copyright (C) 2026 Automated Design Corp. All Rights Reserved.
3
+ *
4
+ * <ProjectManager> — administrative panel for the active project.
5
+ *
6
+ * - Lists every test (run) in the project with a per-row Delete button.
7
+ * - One-shot "Delete Project" button that wipes the whole project tree
8
+ * (with a confirmation prompt — this is destructive and can't be undone).
9
+ * - "Download Archive" button that streams a ZIP of the project tree.
10
+ * - "Server Disk Space" panel showing free / total bytes for the
11
+ * filesystem hosting the TIS data directory.
12
+ *
13
+ * Mounts under the operator-facing Project tab, alongside <ProjectSelector>.
14
+ * Reads `selection.projectId` from <TisProvider> so it follows whatever
15
+ * project the operator has picked.
16
+ */
17
+
18
+ import React, { useCallback, useContext, useEffect, useState } from 'react';
19
+ import { Button } from 'primereact/button';
20
+ import { DataTable } from 'primereact/datatable';
21
+ import { Column } from 'primereact/column';
22
+ import { ConfirmDialog, confirmDialog } from 'primereact/confirmdialog';
23
+ import { ProgressBar } from 'primereact/progressbar';
24
+ import { EventEmitterContext } from '../../core/EventEmitterContext';
25
+ import { MessageType } from '../../hub/CommandMessage';
26
+ import { useTis } from './TisProvider';
27
+
28
+ export interface ProjectManagerProps {
29
+ /** Override the project scope. Defaults to `useTis().selection.projectId`. */
30
+ projectId?: string;
31
+ }
32
+
33
+ interface DiskUsage {
34
+ base_directory: string;
35
+ total_bytes: number;
36
+ free_bytes: number;
37
+ available_bytes: number;
38
+ used_bytes: number;
39
+ }
40
+
41
+ const formatBytes = (n: number): string => {
42
+ if (!Number.isFinite(n) || n <= 0) return '0 B';
43
+ const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB'];
44
+ let i = 0;
45
+ let v = n;
46
+ while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
47
+ return `${v.toFixed(v >= 100 ? 0 : v >= 10 ? 1 : 2)} ${units[i]}`;
48
+ };
49
+
50
+ const formatDate = (s: string): string => {
51
+ if (!s) return '';
52
+ const d = new Date(s);
53
+ return Number.isNaN(d.getTime()) ? s : d.toLocaleString();
54
+ };
55
+
56
+ export const ProjectManager: React.FC<ProjectManagerProps> = (props) => {
57
+ const tis = useTis();
58
+ const projectId = props.projectId ?? tis.selection.projectId;
59
+ const { invoke } = useContext(EventEmitterContext);
60
+
61
+ const [tests, setTests] = useState<any[]>([]);
62
+ const [loading, setLoading] = useState(false);
63
+ const [deletingRunId, setDeletingRunId] = useState<string | null>(null);
64
+ const [deletingProject, setDeletingProject] = useState(false);
65
+ const [archiving, setArchiving] = useState(false);
66
+ const [disk, setDisk] = useState<DiskUsage | null>(null);
67
+ const [diskError, setDiskError] = useState<string>('');
68
+
69
+ const loadTests = useCallback(async () => {
70
+ if (!projectId) { setTests([]); return; }
71
+ setLoading(true);
72
+ try {
73
+ const resp: any = await invoke('tis.list_tests' as any, MessageType.Request, {
74
+ project_id: projectId,
75
+ } as any);
76
+ if (resp?.success && resp.data?.tests) {
77
+ setTests(resp.data.tests as any[]);
78
+ } else {
79
+ setTests([]);
80
+ }
81
+ } catch (e) {
82
+ console.error('[ProjectManager] tis.list_tests failed:', e);
83
+ setTests([]);
84
+ } finally {
85
+ setLoading(false);
86
+ }
87
+ }, [projectId, invoke]);
88
+
89
+ const loadDisk = useCallback(async () => {
90
+ try {
91
+ const resp: any = await invoke('tis.disk_usage' as any, MessageType.Request, {} as any);
92
+ if (resp?.success && resp.data) {
93
+ setDisk(resp.data as DiskUsage);
94
+ setDiskError('');
95
+ } else {
96
+ setDisk(null);
97
+ setDiskError(resp?.error_message ?? 'disk_usage failed');
98
+ }
99
+ } catch (e) {
100
+ setDisk(null);
101
+ setDiskError(e instanceof Error ? e.message : String(e));
102
+ }
103
+ }, [invoke]);
104
+
105
+ useEffect(() => { void loadTests(); }, [loadTests, tis.state.activeRunId]);
106
+ useEffect(() => { void loadDisk(); }, [loadDisk]);
107
+
108
+ const performDeleteTest = async (rowData: any) => {
109
+ const runId = rowData?.run_id;
110
+ const methodId = rowData?.method_id;
111
+ if (!projectId || !methodId || !runId) return;
112
+ setDeletingRunId(runId);
113
+ try {
114
+ const resp: any = await invoke('tis.delete_test' as any, MessageType.Request, {
115
+ project_id: projectId, method_id: methodId, run_id: runId,
116
+ } as any);
117
+ if (!resp?.success) {
118
+ alert(`Failed to delete test ${runId}` +
119
+ (resp?.error_message ? `: ${resp.error_message}` : ''));
120
+ return;
121
+ }
122
+ await loadTests();
123
+ // Pinned selection may now point at a vanished run; clear it so
124
+ // sibling views fall back to the active scalars instead of
125
+ // staying parked on a deleted record.
126
+ if (tis.selection.runId === runId) {
127
+ tis.setSelection({ runId: null });
128
+ }
129
+ } catch (e) {
130
+ alert(`Delete failed: ${e instanceof Error ? e.message : String(e)}`);
131
+ } finally {
132
+ setDeletingRunId(null);
133
+ }
134
+ };
135
+
136
+ const confirmDeleteTest = (rowData: any) => {
137
+ const runId = rowData?.run_id ?? '';
138
+ const sampleId = rowData?.sample_id ?? '';
139
+ confirmDialog({
140
+ header: 'Delete test',
141
+ icon: 'pi pi-exclamation-triangle',
142
+ acceptLabel: 'Delete',
143
+ rejectLabel: 'Cancel',
144
+ acceptClassName: 'p-button-danger',
145
+ message: (
146
+ <div>
147
+ <p style={{ marginTop: 0 }}>
148
+ Permanently delete run <code>{runId}</code>
149
+ {sampleId ? <> (sample <code>{sampleId}</code>)</> : null}?
150
+ </p>
151
+ <p style={{ marginBottom: 0, fontSize: '0.875rem', color: '#9ca3af' }}>
152
+ Removes <code>test.json</code>, cycles, raw and filtered
153
+ data for this run. This action cannot be undone.
154
+ </p>
155
+ </div>
156
+ ),
157
+ accept: () => { void performDeleteTest(rowData); },
158
+ });
159
+ };
160
+
161
+ const performDeleteProject = async () => {
162
+ if (!projectId) return;
163
+ setDeletingProject(true);
164
+ try {
165
+ const resp: any = await invoke('tis.delete_project' as any, MessageType.Request, {
166
+ project_id: projectId,
167
+ } as any);
168
+ if (!resp?.success) {
169
+ alert(`Failed to delete project ${projectId}` +
170
+ (resp?.error_message ? `: ${resp.error_message}` : ''));
171
+ return;
172
+ }
173
+ // Clear pins so nothing keeps trying to read this project's data.
174
+ tis.setSelection({ projectId: null, methodId: null, sampleId: null, runId: null });
175
+ await tis.refreshProjects();
176
+ await loadDisk();
177
+ setTests([]);
178
+ } catch (e) {
179
+ alert(`Delete failed: ${e instanceof Error ? e.message : String(e)}`);
180
+ } finally {
181
+ setDeletingProject(false);
182
+ }
183
+ };
184
+
185
+ const confirmDeleteProject = () => {
186
+ if (!projectId) return;
187
+ const n = tests.length;
188
+ confirmDialog({
189
+ header: 'Delete project',
190
+ icon: 'pi pi-exclamation-triangle',
191
+ acceptLabel: 'Delete Project',
192
+ rejectLabel: 'Cancel',
193
+ acceptClassName: 'p-button-danger',
194
+ message: (
195
+ <div>
196
+ <p style={{ marginTop: 0 }}>
197
+ Permanently delete project <code>{projectId}</code> and
198
+ all <strong>{n}</strong> test{n === 1 ? '' : 's'} inside it?
199
+ </p>
200
+ <p style={{ marginBottom: 0, fontSize: '0.875rem', color: '#9ca3af' }}>
201
+ This removes every method, run, cycle, and raw/filtered
202
+ blob under the project. Consider downloading the
203
+ archive first. This action cannot be undone.
204
+ </p>
205
+ </div>
206
+ ),
207
+ accept: () => { void performDeleteProject(); },
208
+ });
209
+ };
210
+
211
+ const downloadArchive = async () => {
212
+ if (!projectId) return;
213
+ setArchiving(true);
214
+ try {
215
+ const resp: any = await invoke('tis.export_project_zip' as any, MessageType.Request, {
216
+ project_id: projectId,
217
+ } as any);
218
+ if (!resp?.success) {
219
+ alert(`Failed to build project archive` +
220
+ (resp?.error_message ? `: ${resp.error_message}` : ''));
221
+ return;
222
+ }
223
+ const url = typeof resp.data?.download_url === 'string' ? resp.data.download_url : '';
224
+ if (!url) {
225
+ alert('Server did not return a download URL for the archive.');
226
+ return;
227
+ }
228
+ const a = document.createElement('a');
229
+ a.href = url;
230
+ a.download = typeof resp.data?.filename === 'string'
231
+ ? resp.data.filename
232
+ : `${projectId}_project_archive.zip`;
233
+ document.body.appendChild(a);
234
+ a.click();
235
+ a.remove();
236
+ } catch (e) {
237
+ alert(`Download failed: ${e instanceof Error ? e.message : String(e)}`);
238
+ } finally {
239
+ setArchiving(false);
240
+ }
241
+ };
242
+
243
+ const sampleIdOf = (row: any): string => {
244
+ const top = typeof row?.sample_id === 'string' ? row.sample_id : '';
245
+ if (top) return top;
246
+ const cfg = row?.config;
247
+ if (cfg && typeof cfg === 'object' && typeof cfg.sample_id === 'string') {
248
+ return cfg.sample_id;
249
+ }
250
+ return '';
251
+ };
252
+
253
+ const diskPercent = disk && disk.total_bytes > 0
254
+ ? Math.min(100, Math.round((disk.used_bytes / disk.total_bytes) * 100))
255
+ : 0;
256
+
257
+ return (
258
+ <div style={{ width: '100%', maxWidth: '100%', boxSizing: 'border-box' }}>
259
+ <ConfirmDialog />
260
+
261
+ <div style={{
262
+ display: 'flex',
263
+ justifyContent: 'space-between',
264
+ alignItems: 'center',
265
+ marginBottom: '1rem',
266
+ gap: '0.5rem',
267
+ flexWrap: 'wrap',
268
+ }}>
269
+ <h3 style={{ margin: 0 }}>
270
+ {projectId
271
+ ? `Project Manager: ${projectId}`
272
+ : 'Project Manager (no project selected)'}
273
+ </h3>
274
+ <div style={{ display: 'flex', gap: '0.4rem', alignItems: 'center', flexWrap: 'wrap' }}>
275
+ <Button
276
+ icon={archiving ? 'pi pi-spin pi-spinner' : 'pi pi-box'}
277
+ label="Download Archive"
278
+ size="small"
279
+ outlined
280
+ disabled={!projectId || archiving || deletingProject}
281
+ onClick={downloadArchive}
282
+ tooltip="Download a ZIP of the entire project directory"
283
+ tooltipOptions={{ position: 'bottom' }}
284
+ />
285
+ <Button
286
+ icon={deletingProject ? 'pi pi-spin pi-spinner' : 'pi pi-trash'}
287
+ label="Delete Project"
288
+ size="small"
289
+ severity="danger"
290
+ disabled={!projectId || deletingProject || archiving}
291
+ onClick={confirmDeleteProject}
292
+ tooltip="Permanently delete the project and every test inside it"
293
+ tooltipOptions={{ position: 'bottom' }}
294
+ />
295
+ <Button
296
+ icon="pi pi-refresh"
297
+ label="Refresh"
298
+ size="small"
299
+ onClick={() => { void loadTests(); void loadDisk(); }}
300
+ disabled={loading}
301
+ />
302
+ </div>
303
+ </div>
304
+
305
+ {/* Server disk usage panel — visible regardless of project selection
306
+ so the operator can spot a near-full disk before staging a run. */}
307
+ <div style={{
308
+ marginBottom: '1rem',
309
+ padding: '0.75rem 1rem',
310
+ border: '1px solid #2a2a2a',
311
+ borderRadius: 4,
312
+ background: '#161616',
313
+ }}>
314
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', flexWrap: 'wrap', gap: '0.5rem' }}>
315
+ <strong>Server Disk Space</strong>
316
+ {disk ? (
317
+ <span style={{ fontSize: '0.875rem', color: '#9ca3af' }}>
318
+ {formatBytes(disk.available_bytes)} free of {formatBytes(disk.total_bytes)}
319
+ {' '}({100 - diskPercent}% available)
320
+ </span>
321
+ ) : diskError ? (
322
+ <span style={{ fontSize: '0.875rem', color: '#f87171' }}>
323
+ {diskError}
324
+ </span>
325
+ ) : (
326
+ <span style={{ fontSize: '0.875rem', color: '#9ca3af' }}>Loading…</span>
327
+ )}
328
+ </div>
329
+ {disk && (
330
+ <>
331
+ <ProgressBar
332
+ value={diskPercent}
333
+ showValue={false}
334
+ style={{ height: '0.5rem', marginTop: '0.5rem' }}
335
+ color={diskPercent >= 90 ? '#dc2626' : diskPercent >= 75 ? '#f59e0b' : undefined}
336
+ />
337
+ <div style={{ fontSize: '0.75rem', color: '#6b7280', marginTop: '0.4rem' }}>
338
+ <code>{disk.base_directory}</code>
339
+ </div>
340
+ </>
341
+ )}
342
+ </div>
343
+
344
+ <DataTable
345
+ value={tests}
346
+ loading={loading}
347
+ paginator
348
+ rows={10}
349
+ emptyMessage={projectId ? 'No tests in this project.' : 'Select a project to manage.'}
350
+ scrollable
351
+ scrollHeight="flex"
352
+ tableStyle={{ minWidth: 0 }}
353
+ style={{ width: '100%' }}
354
+ >
355
+ <Column header="Sample ID" body={sampleIdOf}
356
+ sortable
357
+ sortFunction={(e) => {
358
+ const copy = [...e.data];
359
+ copy.sort((a, b) => sampleIdOf(a).localeCompare(sampleIdOf(b)) * (e.order ?? 1));
360
+ return copy;
361
+ }}
362
+ style={{ minWidth: '8rem' }} />
363
+ <Column field="start_time" header="Date/Time" sortable
364
+ body={(r) => formatDate(r.start_time)}
365
+ style={{ minWidth: '12rem' }} />
366
+ <Column field="method_id" header="Test Method" sortable style={{ minWidth: '10rem' }} />
367
+ <Column field="run_id" header="Run ID" sortable style={{ minWidth: '12rem' }} />
368
+ <Column
369
+ header="Action"
370
+ style={{ width: '8rem' }}
371
+ body={(rowData) => {
372
+ const isBusy = deletingRunId === rowData.run_id;
373
+ const anyBusy = deletingRunId !== null;
374
+ return (
375
+ <Button
376
+ icon={isBusy ? 'pi pi-spin pi-spinner' : 'pi pi-trash'}
377
+ label="Delete"
378
+ size="small"
379
+ severity="danger"
380
+ outlined
381
+ disabled={anyBusy || deletingProject}
382
+ onClick={() => confirmDeleteTest(rowData)}
383
+ tooltip="Permanently delete this test"
384
+ tooltipOptions={{ position: 'left' }}
385
+ />
386
+ );
387
+ }}
388
+ />
389
+ </DataTable>
390
+ </div>
391
+ );
392
+ };
@@ -24,45 +24,12 @@ export interface ResultHistoryTableProps {
24
24
  }
25
25
 
26
26
  /**
27
- * Convert a raw_data blob (`{ colA: [...], colB: [...], ... }`) into a CSV
28
- * string. Keys with scalar (non-array) values are skipped; array lengths are
29
- * truncated to the shortest column so the grid is rectangular.
30
- *
31
- * Column order: `t` first if present (it's the canonical x-axis in every
32
- * current test schema), then the remaining keys in their JSON order. Each
33
- * cell is quoted only when it contains a comma, quote, or newline — matching
34
- * RFC 4180.
27
+ * Browser download shim: turn an inline CSV (already built by the server)
28
+ * into a transient blob URL, click it, and clean up. The ZIP path uses a
29
+ * direct `<a href=download_url>` instead the server has already written
30
+ * the file to /downloads/ and we just point the browser at it.
35
31
  */
36
- const rawBlobToCsv = (blob: any): string => {
37
- if (!blob || typeof blob !== 'object') return '';
38
-
39
- const entries: Array<[string, any[]]> = Object.entries(blob)
40
- .filter(([, v]) => Array.isArray(v)) as Array<[string, any[]]>;
41
- if (entries.length === 0) return '';
42
-
43
- entries.sort(([a], [b]) => (a === 't' ? -1 : b === 't' ? 1 : 0));
44
-
45
- const columns = entries.map(([k]) => k);
46
- const nRows = entries.reduce((min, [, arr]) => Math.min(min, arr.length), Infinity);
47
-
48
- const escape = (v: any): string => {
49
- if (v === null || v === undefined) return '';
50
- const s = String(v);
51
- return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
52
- };
53
-
54
- const lines: string[] = [columns.join(',')];
55
- for (let i = 0; i < nRows; i++) {
56
- lines.push(entries.map(([, arr]) => escape(arr[i])).join(','));
57
- }
58
- return lines.join('\n');
59
- };
60
-
61
- /**
62
- * Browser download shim: turn a string into a transient blob URL, click it,
63
- * and clean up. Works without any extra libraries.
64
- */
65
- const downloadCsv = (filename: string, csv: string) => {
32
+ const downloadCsvBlob = (filename: string, csv: string) => {
66
33
  const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
67
34
  const url = URL.createObjectURL(blob);
68
35
  const a = document.createElement('a');
@@ -74,8 +41,9 @@ const downloadCsv = (filename: string, csv: string) => {
74
41
  URL.revokeObjectURL(url);
75
42
  };
76
43
 
77
- type DownloadKind = 'raw' | 'filtered';
44
+ type DownloadKind = 'data' | 'report';
78
45
  type InFlight = { runId: string; kind: DownloadKind };
46
+ type ProjectDownloadKind = 'report' | 'archive';
79
47
 
80
48
  export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) => {
81
49
  const tis = useTis();
@@ -85,6 +53,7 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
85
53
  const [tests, setTests] = useState<any[]>([]);
86
54
  const [loading, setLoading] = useState(false);
87
55
  const [downloading, setDownloading] = useState<InFlight | null>(null);
56
+ const [projectBusy, setProjectBusy] = useState<ProjectDownloadKind | null>(null);
88
57
  const { invoke } = useContext(EventEmitterContext);
89
58
 
90
59
  const loadTests = async () => {
@@ -124,39 +93,39 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
124
93
  const handleDownload = async (rowData: any, kind: DownloadKind) => {
125
94
  const runId = rowData?.run_id;
126
95
  const rowMethodId = rowData?.method_id ?? methodId;
127
- if (!runId || !rowMethodId) return;
96
+ if (!runId || !rowMethodId || !projectId) return;
128
97
 
129
- // raw_data/ and filtered_data/ are symmetric dirs on disk; the same
130
- // blob name ("trace") exists in both. Only the IPC topic and filename
131
- // suffix change.
132
- const topic = kind === 'raw' ? 'tis.read_raw' : 'tis.read_filtered';
133
- const label = kind === 'raw' ? 'raw trace' : 'filtered trace';
134
- const suffix = kind === 'raw' ? 'raw' : 'filtered';
98
+ // The server builds both CSVs end-to-end now. `Report` returns
99
+ // metadata + cycles + results (no traces). `Data` returns raw
100
+ // cycles concatenated with `filtered_`-prefixed columns paired
101
+ // against cycle 1.
102
+ const topic = kind === 'report' ? 'tis.export_test_csv'
103
+ : 'tis.export_test_data_csv';
104
+ const label = kind === 'report' ? 'test report' : 'test data';
135
105
 
136
106
  setDownloading({ runId, kind });
137
107
  try {
138
108
  const resp: any = await invoke(topic as any, MessageType.Request, {
139
- project_id: projectId,
140
- method_id: rowMethodId,
141
- run_id: runId,
142
- name: 'trace',
109
+ project_id: projectId, method_id: rowMethodId, run_id: runId,
143
110
  } as any);
144
-
145
- if (!resp?.success || !resp.data) {
146
- console.warn(`${topic} returned no data for`, runId, resp?.error_message);
147
- alert(
148
- `No ${label} available for ${runId}` +
149
- (resp?.error_message ? `: ${resp.error_message}` : '')
150
- );
111
+ if (!resp?.success) {
112
+ console.warn(`${topic} failed`, runId, resp?.error_message);
113
+ alert(`Failed to build ${label} for ${runId}` +
114
+ (resp?.error_message ? `: ${resp.error_message}` : ''));
151
115
  return;
152
116
  }
153
-
154
- const csv = rawBlobToCsv(resp.data);
117
+ const csv = typeof resp.data?.csv === 'string' ? resp.data.csv : '';
155
118
  if (!csv) {
156
- alert(`${label} for ${runId} is empty or has no array columns.`);
119
+ alert(`${label} for ${runId} is empty.`);
157
120
  return;
158
121
  }
159
- downloadCsv(`${projectId}_${rowMethodId}_${runId}_${suffix}.csv`, csv);
122
+ // Trust the server-supplied filename — it already has the
123
+ // sanitized sample_id, project, method, and run components
124
+ // assembled with the same rules used for files on disk.
125
+ const filename = typeof resp.data?.filename === 'string' && resp.data.filename
126
+ ? resp.data.filename
127
+ : `${projectId}_${rowMethodId}_${runId}_${kind}.csv`;
128
+ downloadCsvBlob(filename, csv);
160
129
  } catch (err) {
161
130
  console.error(`Failed to download ${label}`, err);
162
131
  alert(`Download failed: ${err instanceof Error ? err.message : String(err)}`);
@@ -165,6 +134,60 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
165
134
  }
166
135
  };
167
136
 
137
+ const handleProjectDownload = async (kind: ProjectDownloadKind) => {
138
+ if (!projectId) return;
139
+ const topic = kind === 'report' ? 'tis.export_project_csv'
140
+ : 'tis.export_project_zip';
141
+ const label = kind === 'report' ? 'project report' : 'project archive';
142
+
143
+ setProjectBusy(kind);
144
+ try {
145
+ const resp: any = await invoke(topic as any, MessageType.Request, {
146
+ project_id: projectId,
147
+ } as any);
148
+ if (!resp?.success) {
149
+ console.warn(`${topic} failed`, projectId, resp?.error_message);
150
+ alert(`Failed to build ${label}` +
151
+ (resp?.error_message ? `: ${resp.error_message}` : ''));
152
+ return;
153
+ }
154
+
155
+ if (kind === 'report') {
156
+ const csv = typeof resp.data?.csv === 'string' ? resp.data.csv : '';
157
+ if (!csv) {
158
+ alert(`Project ${projectId} has no tests to report.`);
159
+ return;
160
+ }
161
+ const filename = typeof resp.data?.filename === 'string' && resp.data.filename
162
+ ? resp.data.filename
163
+ : `${projectId}_project_report.csv`;
164
+ downloadCsvBlob(filename, csv);
165
+ } else {
166
+ // Archive lives on the server's /downloads/ endpoint — point
167
+ // the browser at it directly. Anchor click triggers a GET
168
+ // with content-disposition: attachment.
169
+ const url = typeof resp.data?.download_url === 'string' ? resp.data.download_url : '';
170
+ if (!url) {
171
+ alert(`Server did not return a download URL for ${label}.`);
172
+ return;
173
+ }
174
+ const a = document.createElement('a');
175
+ a.href = url;
176
+ a.download = typeof resp.data?.filename === 'string'
177
+ ? resp.data.filename
178
+ : `${projectId}_project_archive.zip`;
179
+ document.body.appendChild(a);
180
+ a.click();
181
+ a.remove();
182
+ }
183
+ } catch (err) {
184
+ console.error(`Failed to download ${label}`, err);
185
+ alert(`Download failed: ${err instanceof Error ? err.message : String(err)}`);
186
+ } finally {
187
+ setProjectBusy(null);
188
+ }
189
+ };
190
+
168
191
  // sample_id is now a top-level field on test.json. Older test.json
169
192
  // files (written before the rename) carry it nested in `config`; fall
170
193
  // back to the nested form so we can still display the column for runs
@@ -185,13 +208,41 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
185
208
  // parent; DataTable's `scrollable` handles internal overflow via its
186
209
  // own scrollbars instead of blowing out layout.
187
210
  <div style={{ width: '100%', maxWidth: '100%', overflow: 'hidden', boxSizing: 'border-box' }}>
188
- <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
211
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem', gap: '0.5rem', flexWrap: 'wrap' }}>
189
212
  <h3 style={{ margin: 0 }}>
190
213
  {projectId
191
214
  ? `Test History: ${projectId}${methodId ? ` / ${methodId}` : ''}`
192
215
  : 'Test History (no project selected)'}
193
216
  </h3>
194
- <Button icon="pi pi-refresh" label="Refresh" onClick={loadTests} disabled={loading} />
217
+ <div style={{ display: 'flex', gap: '0.4rem', alignItems: 'center' }}>
218
+ <Button
219
+ icon={projectBusy === 'report' ? 'pi pi-spin pi-spinner' : 'pi pi-file'}
220
+ label="Download Report"
221
+ size="small"
222
+ outlined
223
+ disabled={!projectId || projectBusy !== null}
224
+ onClick={() => handleProjectDownload('report')}
225
+ tooltip="Download a CSV report of every test in this project"
226
+ tooltipOptions={{ position: 'bottom' }}
227
+ />
228
+ <Button
229
+ icon={projectBusy === 'archive' ? 'pi pi-spin pi-spinner' : 'pi pi-box'}
230
+ label="Download Archive"
231
+ size="small"
232
+ outlined
233
+ disabled={!projectId || projectBusy !== null}
234
+ onClick={() => handleProjectDownload('archive')}
235
+ tooltip="Download a ZIP of the entire project directory (all tests, raw data, configs)"
236
+ tooltipOptions={{ position: 'bottom' }}
237
+ />
238
+ <Button
239
+ icon="pi pi-refresh"
240
+ label="Refresh"
241
+ size="small"
242
+ onClick={loadTests}
243
+ disabled={loading}
244
+ />
245
+ </div>
195
246
  </div>
196
247
 
197
248
  <DataTable
@@ -242,29 +293,29 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
242
293
  header="Download"
243
294
  style={{ width: '14rem' }}
244
295
  body={(rowData) => {
245
- const isRawBusy = downloading?.runId === rowData.run_id && downloading?.kind === 'raw';
246
- const isFilteredBusy = downloading?.runId === rowData.run_id && downloading?.kind === 'filtered';
247
- const anyBusy = downloading !== null;
296
+ const isDataBusy = downloading?.runId === rowData.run_id && downloading?.kind === 'data';
297
+ const isReportBusy = downloading?.runId === rowData.run_id && downloading?.kind === 'report';
298
+ const anyBusy = downloading !== null;
248
299
  return (
249
300
  <div style={{ display: 'flex', gap: '0.4rem' }}>
250
301
  <Button
251
- icon={isRawBusy ? 'pi pi-spin pi-spinner' : 'pi pi-download'}
252
- label="Raw"
302
+ icon={isDataBusy ? 'pi pi-spin pi-spinner' : 'pi pi-download'}
303
+ label="Data"
253
304
  size="small"
254
305
  outlined
255
306
  disabled={anyBusy}
256
- onClick={() => handleDownload(rowData, 'raw')}
257
- tooltip="Download raw_data/trace.json as CSV"
307
+ onClick={() => handleDownload(rowData, 'data')}
308
+ tooltip="Download raw + filtered trace data as one CSV"
258
309
  tooltipOptions={{ position: 'left' }}
259
310
  />
260
311
  <Button
261
- icon={isFilteredBusy ? 'pi pi-spin pi-spinner' : 'pi pi-download'}
262
- label="Filtered"
312
+ icon={isReportBusy ? 'pi pi-spin pi-spinner' : 'pi pi-file'}
313
+ label="Report"
263
314
  size="small"
264
315
  outlined
265
316
  disabled={anyBusy}
266
- onClick={() => handleDownload(rowData, 'filtered')}
267
- tooltip="Download filtered_data/trace.json as CSV"
317
+ onClick={() => handleDownload(rowData, 'report')}
318
+ tooltip="Download a CSV report (metadata + cycles + results) for this test"
268
319
  tooltipOptions={{ position: 'left' }}
269
320
  />
270
321
  </div>