@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.
- package/dist/assets/HomeMotor.d.ts +4 -0
- package/dist/assets/HomeMotor.d.ts.map +1 -0
- package/dist/assets/HomeMotor.js +1 -0
- package/dist/assets/svg/home_motor.svg +57 -0
- package/dist/components/Indicator.d.ts +29 -52
- package/dist/components/Indicator.d.ts.map +1 -1
- package/dist/components/Indicator.js +1 -1
- package/dist/components/ValueInput.d.ts +1 -1
- package/dist/components/ValueInput.d.ts.map +1 -1
- package/dist/components/ams/AmsProvider.d.ts +7 -0
- package/dist/components/ams/AmsProvider.d.ts.map +1 -1
- package/dist/components/ams/AssetDetailView.d.ts.map +1 -1
- package/dist/components/ams/AssetDetailView.js +1 -1
- package/dist/components/ams/AssetRegistryTable.d.ts.map +1 -1
- package/dist/components/ams/AssetRegistryTable.js +1 -1
- package/dist/components/ams/CalibrationEntryDialog.d.ts.map +1 -1
- package/dist/components/ams/CalibrationEntryDialog.js +1 -1
- package/dist/components/ams/MissingAssetsBanner.d.ts +11 -0
- package/dist/components/ams/MissingAssetsBanner.d.ts.map +1 -0
- package/dist/components/ams/MissingAssetsBanner.js +1 -0
- package/dist/components/ams/PlaceholderHealthPanel.d.ts +3 -0
- package/dist/components/ams/PlaceholderHealthPanel.d.ts.map +1 -0
- package/dist/components/ams/PlaceholderHealthPanel.js +1 -0
- package/dist/components/ams/index.d.ts +2 -0
- package/dist/components/ams/index.d.ts.map +1 -1
- package/dist/components/ams/index.js +1 -1
- package/dist/components/index.d.ts +8 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +1 -1
- package/dist/components/network/NetworkPanel.d.ts +8 -0
- package/dist/components/network/NetworkPanel.d.ts.map +1 -0
- package/dist/components/network/NetworkPanel.js +1 -0
- package/dist/components/network/NetworkProvider.d.ts +72 -0
- package/dist/components/network/NetworkProvider.d.ts.map +1 -0
- package/dist/components/network/NetworkProvider.js +1 -0
- package/dist/components/network/StagedChangeBanner.d.ts +8 -0
- package/dist/components/network/StagedChangeBanner.d.ts.map +1 -0
- package/dist/components/network/StagedChangeBanner.js +1 -0
- package/dist/components/network/index.d.ts +7 -0
- package/dist/components/network/index.d.ts.map +1 -0
- package/dist/components/network/index.js +1 -0
- package/dist/components/tis/ProjectManager.d.ts +7 -0
- package/dist/components/tis/ProjectManager.d.ts.map +1 -0
- package/dist/components/tis/ProjectManager.js +1 -0
- package/dist/components/tis/ResultHistoryTable.d.ts.map +1 -1
- package/dist/components/tis/ResultHistoryTable.js +1 -1
- package/dist/components/tis/TestDataView.d.ts.map +1 -1
- package/dist/components/tis/TestDataView.js +1 -1
- package/dist/components/tis/TestRawDataView.d.ts.map +1 -1
- package/dist/components/tis/TestRawDataView.js +1 -1
- package/dist/components/tis/TestSetupForm.d.ts +7 -0
- package/dist/components/tis/TestSetupForm.d.ts.map +1 -1
- package/dist/components/tis/TestSetupForm.js +1 -1
- package/package.json +5 -1
- package/src/assets/HomeMotor.tsx +37 -0
- package/src/assets/svg/home_motor.svg +57 -0
- package/src/components/Indicator.tsx +166 -162
- package/src/components/ValueInput.tsx +2 -2
- package/src/components/ams/AmsProvider.tsx +7 -0
- package/src/components/ams/AssetDetailView.tsx +287 -4
- package/src/components/ams/AssetRegistryTable.tsx +325 -21
- package/src/components/ams/CalibrationEntryDialog.tsx +163 -30
- package/src/components/ams/MissingAssetsBanner.tsx +124 -0
- package/src/components/ams/PlaceholderHealthPanel.tsx +188 -0
- package/src/components/ams/index.ts +2 -0
- package/src/components/index.ts +26 -0
- package/src/components/network/NetworkPanel.tsx +363 -0
- package/src/components/network/NetworkProvider.tsx +349 -0
- package/src/components/network/StagedChangeBanner.tsx +101 -0
- package/src/components/network/index.ts +17 -0
- package/src/components/tis/ProjectManager.tsx +392 -0
- package/src/components/tis/ResultHistoryTable.tsx +125 -74
- package/src/components/tis/TestDataView.tsx +160 -14
- package/src/components/tis/TestRawDataView.tsx +118 -8
- 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
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
|
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 = '
|
|
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
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
119
|
+
alert(`${label} for ${runId} is empty.`);
|
|
157
120
|
return;
|
|
158
121
|
}
|
|
159
|
-
|
|
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
|
-
<
|
|
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
|
|
246
|
-
const
|
|
247
|
-
const anyBusy
|
|
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={
|
|
252
|
-
label="
|
|
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, '
|
|
257
|
-
tooltip="Download
|
|
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={
|
|
262
|
-
label="
|
|
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, '
|
|
267
|
-
tooltip="Download
|
|
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>
|