@adcops/autocore-react 3.3.59 → 3.3.63
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/components/ams/AmsProvider.d.ts +45 -0
- package/dist/components/ams/AmsProvider.d.ts.map +1 -0
- package/dist/components/ams/AmsProvider.js +1 -0
- package/dist/components/ams/AssetDetailView.d.ts +3 -0
- package/dist/components/ams/AssetDetailView.d.ts.map +1 -0
- package/dist/components/ams/AssetDetailView.js +1 -0
- package/dist/components/ams/AssetRegistryTable.d.ts +3 -0
- package/dist/components/ams/AssetRegistryTable.d.ts.map +1 -0
- package/dist/components/ams/AssetRegistryTable.js +1 -0
- package/dist/components/ams/CalibrationEntryDialog.d.ts +10 -0
- package/dist/components/ams/CalibrationEntryDialog.d.ts.map +1 -0
- package/dist/components/ams/CalibrationEntryDialog.js +1 -0
- package/dist/components/ams/SubLocationPicker.d.ts +3 -0
- package/dist/components/ams/SubLocationPicker.d.ts.map +1 -0
- package/dist/components/ams/SubLocationPicker.js +1 -0
- package/dist/components/ams/index.d.ts +6 -0
- package/dist/components/ams/index.d.ts.map +1 -0
- package/dist/components/ams/index.js +1 -0
- package/dist/components/index.d.ts +9 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +1 -1
- package/dist/components/tis/ProjectSelector.d.ts +15 -0
- package/dist/components/tis/ProjectSelector.d.ts.map +1 -0
- package/dist/components/tis/ProjectSelector.js +1 -0
- package/dist/components/tis/TestDataView.d.ts +9 -1
- package/dist/components/tis/TestDataView.d.ts.map +1 -1
- package/dist/components/tis/TestDataView.js +1 -1
- package/dist/components/tis/TestSetupForm.d.ts +8 -4
- package/dist/components/tis/TestSetupForm.d.ts.map +1 -1
- package/dist/components/tis/TestSetupForm.js +1 -1
- package/dist/components/tis/TisProvider.d.ts +45 -0
- package/dist/components/tis/TisProvider.d.ts.map +1 -1
- package/dist/components/tis/TisProvider.js +1 -1
- package/dist/core/AutoCoreTagContext.d.ts +16 -0
- package/dist/core/AutoCoreTagContext.d.ts.map +1 -1
- package/dist/core/AutoCoreTagContext.js +1 -1
- package/dist/themes/adc-dark/blue/theme.css +67 -37
- package/dist/themes/adc-dark/blue/theme.css.map +1 -1
- package/package.json +1 -1
- package/src/components/ams/AmsProvider.tsx +219 -0
- package/src/components/ams/AssetDetailView.tsx +101 -0
- package/src/components/ams/AssetRegistryTable.tsx +171 -0
- package/src/components/ams/CalibrationEntryDialog.tsx +197 -0
- package/src/components/ams/SubLocationPicker.tsx +146 -0
- package/src/components/ams/index.ts +12 -0
- package/src/components/index.ts +30 -0
- package/src/components/tis/ProjectSelector.tsx +190 -0
- package/src/components/tis/TestDataView.tsx +321 -28
- package/src/components/tis/TestSetupForm.tsx +66 -253
- package/src/components/tis/TisProvider.tsx +192 -1
- package/src/core/AutoCoreTagContext.tsx +114 -16
- package/src/themes/adc-dark/_extensions.scss +15 -0
- package/src/themes/adc-dark/blue/adc_theme.scss +56 -10
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Automated Design Corp. All Rights Reserved.
|
|
3
|
+
*
|
|
4
|
+
* <ProjectSelector> — standalone Project ID picker, designed to live
|
|
5
|
+
* on a "Project" tab alongside <ResultHistoryTable>. Was previously
|
|
6
|
+
* the first row of <TestSetupForm>; lifted out so a user can browse a
|
|
7
|
+
* project's history without being forced through the full test-setup
|
|
8
|
+
* UI.
|
|
9
|
+
*
|
|
10
|
+
* The component is intentionally narrow:
|
|
11
|
+
*
|
|
12
|
+
* - One AutoComplete bound to `useTisSelection().projectId`.
|
|
13
|
+
* - A `+` button that opens the Create-Project dialog.
|
|
14
|
+
* - A `✏️` button that opens the Edit-Project-Information dialog.
|
|
15
|
+
*
|
|
16
|
+
* State (the existing projects list, the just-created set, the
|
|
17
|
+
* project_fields cache) all lives in <TisProvider>, so the form on
|
|
18
|
+
* the Test tab and this picker on the Project tab agree on what's
|
|
19
|
+
* known.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import React, { useContext, useState, useMemo } from 'react';
|
|
23
|
+
import { AutoComplete } from 'primereact/autocomplete';
|
|
24
|
+
import type { AutoCompleteCompleteEvent } from 'primereact/autocomplete';
|
|
25
|
+
import { Button } from 'primereact/button';
|
|
26
|
+
import { EventEmitterContext } from '../../core/EventEmitterContext';
|
|
27
|
+
import { useTis } from './TisProvider';
|
|
28
|
+
import { ProjectInfoDialog } from './ProjectInfoDialog';
|
|
29
|
+
|
|
30
|
+
// Project IDs follow the same character class as the server's
|
|
31
|
+
// `tis.create_project` validator. Keep these in sync — see
|
|
32
|
+
// `src/tis_servelet.rs::create_project`.
|
|
33
|
+
const PROJECT_ID_RE = /^[A-Za-z0-9_-]+$/;
|
|
34
|
+
const isValidProjectIdFormat = (id: string) => PROJECT_ID_RE.test(id);
|
|
35
|
+
|
|
36
|
+
export interface ProjectSelectorProps {
|
|
37
|
+
/**
|
|
38
|
+
* Optional override of the method whose `project_fields` are shown
|
|
39
|
+
* in the create / edit dialog. By default the dialog uses the
|
|
40
|
+
* provider's selected method (which is what you want — the
|
|
41
|
+
* project's metadata schema is per-method, and the form on the
|
|
42
|
+
* Test tab is going to use that same method anyway). Passing this
|
|
43
|
+
* is only useful if you have a "view-only" Project tab in a
|
|
44
|
+
* read-only HMI and want to lock the dialog to a specific method.
|
|
45
|
+
*/
|
|
46
|
+
methodIdOverride?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const ProjectSelector: React.FC<ProjectSelectorProps> = ({ methodIdOverride }) => {
|
|
50
|
+
const tis = useTis();
|
|
51
|
+
const { invoke: _invoke } = useContext(EventEmitterContext);
|
|
52
|
+
void _invoke; // EventEmitterContext is consumed via ProjectInfoDialog; no direct call here.
|
|
53
|
+
|
|
54
|
+
const projectId = tis.selection.projectId;
|
|
55
|
+
const dialogMethodId =
|
|
56
|
+
methodIdOverride
|
|
57
|
+
?? tis.selection.methodId
|
|
58
|
+
?? tis.defaultMethodId
|
|
59
|
+
?? Object.keys(tis.schemas)[0]
|
|
60
|
+
?? '';
|
|
61
|
+
const dialogSchema = dialogMethodId ? tis.schemas[dialogMethodId] : undefined;
|
|
62
|
+
const projectFieldsSchema = dialogSchema?.project_fields ?? [];
|
|
63
|
+
|
|
64
|
+
const [filteredProjects, setFilteredProjects] = useState<string[]>([]);
|
|
65
|
+
const [newOpen, setNewOpen] = useState(false);
|
|
66
|
+
const [editOpen, setEditOpen] = useState(false);
|
|
67
|
+
|
|
68
|
+
const projectExists = projectId.trim() !== '' && tis.projectKnown(projectId.trim());
|
|
69
|
+
const projectIdFormatValid = isValidProjectIdFormat(projectId.trim());
|
|
70
|
+
const canCreateProject =
|
|
71
|
+
projectId.trim() !== ''
|
|
72
|
+
&& projectIdFormatValid
|
|
73
|
+
&& !tis.projectKnown(projectId.trim());
|
|
74
|
+
|
|
75
|
+
const search = (event: AutoCompleteCompleteEvent) => {
|
|
76
|
+
const q = event.query.toLowerCase();
|
|
77
|
+
setFilteredProjects(tis.existingProjects.filter(p => p.toLowerCase().includes(q)));
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const handleChange = (value: string | null | undefined) => {
|
|
81
|
+
const sanitized = (value || '').replace(/[^a-zA-Z0-9_-]/g, '');
|
|
82
|
+
tis.setSelection({ projectId: sanitized });
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// -----------------------------------------------------------------
|
|
86
|
+
// Plumbing for the create + edit dialogs. We treat the "create"
|
|
87
|
+
// result as authoritative for project_fields; the dialog sends
|
|
88
|
+
// them straight to `tis.create_project` so the persisted file
|
|
89
|
+
// already has the values. Stash them in the provider's cache so
|
|
90
|
+
// the form on the Test tab folds them into stage_test without an
|
|
91
|
+
// extra read_project round trip.
|
|
92
|
+
// -----------------------------------------------------------------
|
|
93
|
+
const handleSubmitted = (pid: string, fields: Record<string, any>) => {
|
|
94
|
+
tis.markProjectJustCreated(pid);
|
|
95
|
+
tis.setProjectFields(pid, fields);
|
|
96
|
+
// Refresh the dropdown so the new ID appears in future
|
|
97
|
+
// suggestions, and surface the new project as the current
|
|
98
|
+
// selection — operator's intent on `+` is "set up this
|
|
99
|
+
// project and start using it."
|
|
100
|
+
void tis.refreshProjects();
|
|
101
|
+
if (tis.selection.projectId !== pid) tis.setSelection({ projectId: pid });
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const headerStatus = useMemo(() => {
|
|
105
|
+
if (projectExists) return { color: 'var(--green-500)', icon: 'pi-check-circle' };
|
|
106
|
+
if (projectId.trim() === '') return { color: 'var(--text-secondary-color)', icon: 'pi-info-circle' };
|
|
107
|
+
return { color: 'var(--red-500)', icon: 'pi-exclamation-circle' };
|
|
108
|
+
}, [projectExists, projectId]);
|
|
109
|
+
|
|
110
|
+
const gridStyle: React.CSSProperties = {
|
|
111
|
+
padding: '1.25rem',
|
|
112
|
+
gridTemplateColumns: 'auto 1fr 1.75rem 1.75rem',
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<div className="ac-form-grid" style={gridStyle}>
|
|
117
|
+
<h3 className="ac-form-section" style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
|
118
|
+
Project
|
|
119
|
+
<span style={{ color: headerStatus.color }}>
|
|
120
|
+
<i className={`pi ${headerStatus.icon}`} />
|
|
121
|
+
</span>
|
|
122
|
+
</h3>
|
|
123
|
+
|
|
124
|
+
<span className="ac-form-label">Project ID</span>
|
|
125
|
+
<div className="p-inputgroup" style={{ flex: 1 }}>
|
|
126
|
+
<AutoComplete
|
|
127
|
+
value={projectId}
|
|
128
|
+
suggestions={filteredProjects}
|
|
129
|
+
completeMethod={search}
|
|
130
|
+
onChange={(e) => handleChange(e.value)}
|
|
131
|
+
dropdown
|
|
132
|
+
placeholder="Select an existing Project ID, or type a new one and click +"
|
|
133
|
+
className={projectId.trim() && !projectExists ? 'p-invalid' : ''}
|
|
134
|
+
style={{ flex: 1 }}
|
|
135
|
+
/>
|
|
136
|
+
<Button
|
|
137
|
+
icon="pi pi-plus"
|
|
138
|
+
type="button"
|
|
139
|
+
onClick={() => setNewOpen(true)}
|
|
140
|
+
disabled={!canCreateProject}
|
|
141
|
+
tooltip={
|
|
142
|
+
!projectId.trim() ? 'Type a project ID first' :
|
|
143
|
+
!projectIdFormatValid ? 'Letters, digits, _ and - only' :
|
|
144
|
+
tis.projectKnown(projectId.trim()) ? 'Project already exists' :
|
|
145
|
+
`Create project "${projectId.trim()}"`
|
|
146
|
+
}
|
|
147
|
+
tooltipOptions={{ position: 'top' }}
|
|
148
|
+
/>
|
|
149
|
+
<Button
|
|
150
|
+
icon="pi pi-pencil"
|
|
151
|
+
type="button"
|
|
152
|
+
onClick={() => setEditOpen(true)}
|
|
153
|
+
disabled={!projectExists}
|
|
154
|
+
tooltip={projectExists
|
|
155
|
+
? `Edit information for "${projectId.trim()}"`
|
|
156
|
+
: 'Select an existing project to edit'}
|
|
157
|
+
tooltipOptions={{ position: 'top' }}
|
|
158
|
+
/>
|
|
159
|
+
</div>
|
|
160
|
+
<span aria-hidden="true" />
|
|
161
|
+
<span style={{
|
|
162
|
+
color: projectExists ? 'var(--green-500)' :
|
|
163
|
+
projectId.trim() === '' ? 'var(--text-secondary-color)' : 'var(--red-500)',
|
|
164
|
+
display: 'flex', alignItems: 'center',
|
|
165
|
+
}}>
|
|
166
|
+
<i className={projectExists ? 'pi pi-check' : projectId.trim() === '' ? 'pi pi-minus' : 'pi pi-times'} />
|
|
167
|
+
</span>
|
|
168
|
+
|
|
169
|
+
{/* Both dialogs are mounted unconditionally and gated by
|
|
170
|
+
their own `visible` prop. Cheap, and lets PrimeReact's
|
|
171
|
+
portal layering manage its own lifecycle. */}
|
|
172
|
+
<ProjectInfoDialog
|
|
173
|
+
visible={newOpen}
|
|
174
|
+
onHide={() => setNewOpen(false)}
|
|
175
|
+
mode="create"
|
|
176
|
+
projectId={projectId.trim()}
|
|
177
|
+
projectFields={projectFieldsSchema}
|
|
178
|
+
onSubmitted={handleSubmitted}
|
|
179
|
+
/>
|
|
180
|
+
<ProjectInfoDialog
|
|
181
|
+
visible={editOpen}
|
|
182
|
+
onHide={() => setEditOpen(false)}
|
|
183
|
+
mode="edit"
|
|
184
|
+
projectId={projectId.trim()}
|
|
185
|
+
projectFields={projectFieldsSchema}
|
|
186
|
+
onSubmitted={handleSubmitted}
|
|
187
|
+
/>
|
|
188
|
+
</div>
|
|
189
|
+
);
|
|
190
|
+
};
|
|
@@ -8,12 +8,13 @@
|
|
|
8
8
|
* control program appends cycles.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import React, { useContext, useEffect, useMemo, useRef, useState } from 'react';
|
|
11
|
+
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
|
12
12
|
import { Button } from 'primereact/button';
|
|
13
13
|
import { Column } from 'primereact/column';
|
|
14
14
|
import { DataTable } from 'primereact/datatable';
|
|
15
15
|
import { Dialog } from 'primereact/dialog';
|
|
16
16
|
import { Dropdown } from 'primereact/dropdown';
|
|
17
|
+
import { TabView, TabPanel } from 'primereact/tabview';
|
|
17
18
|
|
|
18
19
|
import { Chart as ChartJS,
|
|
19
20
|
CategoryScale, LinearScale, PointElement, LineElement,
|
|
@@ -24,7 +25,6 @@ import { Line } from 'react-chartjs-2';
|
|
|
24
25
|
|
|
25
26
|
import { EventEmitterContext } from '../../core/EventEmitterContext';
|
|
26
27
|
import { MessageType } from '../../hub/CommandMessage';
|
|
27
|
-
import { TestRawDataView } from './TestRawDataView';
|
|
28
28
|
import { useTis } from './TisProvider';
|
|
29
29
|
|
|
30
30
|
ChartJS.register(
|
|
@@ -53,9 +53,13 @@ export interface ChartView {
|
|
|
53
53
|
x: ChartAxis;
|
|
54
54
|
y: ChartSeries[];
|
|
55
55
|
}
|
|
56
|
+
export interface RawColumn { source: string; }
|
|
56
57
|
export interface RawDataShape {
|
|
57
58
|
blob_name: string;
|
|
58
|
-
|
|
59
|
+
/** Map of column name → declared source. Iteration order over the
|
|
60
|
+
* keys defines the column order in the View Raw Data dialog's
|
|
61
|
+
* tables and in the raw_trace charts. */
|
|
62
|
+
columns: { [col: string]: RawColumn };
|
|
59
63
|
units?: { [col: string]: string };
|
|
60
64
|
}
|
|
61
65
|
export interface TestMethod {
|
|
@@ -101,6 +105,17 @@ export const TestDataView: React.FC<TestDataViewProps> = (props) => {
|
|
|
101
105
|
const [cycles, setCycles] = useState<any[]>([]);
|
|
102
106
|
const [results, setResults] = useState<any>({});
|
|
103
107
|
const [rawOpen, setRawOpen] = useState(false);
|
|
108
|
+
const [configOpen, setConfigOpen] = useState(false);
|
|
109
|
+
|
|
110
|
+
// Lazy-loaded blobs for the View Raw Data dialog. Fetched only
|
|
111
|
+
// when the dialog opens, and re-fetched if the operator pins a
|
|
112
|
+
// different run while the dialog is closed.
|
|
113
|
+
const [rawBlob, setRawBlob] = useState<any | null>(null);
|
|
114
|
+
const [rawError, setRawError] = useState<string | null>(null);
|
|
115
|
+
const [rawLoading, setRawLoading] = useState(false);
|
|
116
|
+
const [filteredBlob, setFilteredBlob] = useState<any | null>(null);
|
|
117
|
+
const [filteredError, setFilteredError] = useState<string | null>(null);
|
|
118
|
+
const [filteredLoading, setFilteredLoading] = useState(false);
|
|
104
119
|
|
|
105
120
|
// Scatter-capable views only — raw_trace lives in <TestRawDataView>.
|
|
106
121
|
const scatterViews = useMemo(() => {
|
|
@@ -253,6 +268,78 @@ export const TestDataView: React.FC<TestDataViewProps> = (props) => {
|
|
|
253
268
|
},
|
|
254
269
|
}), [selectedViewDef, usesRightAxis]);
|
|
255
270
|
|
|
271
|
+
// -----------------------------------------------------------------
|
|
272
|
+
// View Raw Data dialog: lazy-fetch raw + filtered blobs the first
|
|
273
|
+
// time the dialog opens (and re-fetch if the operator switches
|
|
274
|
+
// runs while it's closed). The blob shape is `{ col: number[] }`,
|
|
275
|
+
// streamed in full — these are the same files the per-row CSV
|
|
276
|
+
// download in <ResultHistoryTable> pulls.
|
|
277
|
+
// -----------------------------------------------------------------
|
|
278
|
+
const blobName = schema?.raw_data?.blob_name ?? 'trace';
|
|
279
|
+
const fetchKeyRef = useRef<string>(''); // last (project, method, run, blob) we fetched
|
|
280
|
+
|
|
281
|
+
const loadBlobs = useCallback(async () => {
|
|
282
|
+
if (!projectId || !methodId || !runId) return;
|
|
283
|
+
const key = `${projectId}|${methodId}|${runId}|${blobName}`;
|
|
284
|
+
if (fetchKeyRef.current === key) return; // already loaded for this run
|
|
285
|
+
fetchKeyRef.current = key;
|
|
286
|
+
|
|
287
|
+
// Raw — must succeed for the dialog to be useful, but a
|
|
288
|
+
// missing file is logged and surfaced rather than aborted.
|
|
289
|
+
setRawLoading(true);
|
|
290
|
+
setRawError(null);
|
|
291
|
+
setRawBlob(null);
|
|
292
|
+
try {
|
|
293
|
+
const resp: any = await invoke(
|
|
294
|
+
'tis.read_raw' as any, MessageType.Request,
|
|
295
|
+
{ project_id: projectId, method_id: methodId, run_id: runId, name: blobName } as any,
|
|
296
|
+
);
|
|
297
|
+
if (resp?.success) {
|
|
298
|
+
setRawBlob(resp.data ?? {});
|
|
299
|
+
} else {
|
|
300
|
+
setRawError(resp?.error_message ?? 'No raw data on disk for this run.');
|
|
301
|
+
}
|
|
302
|
+
} catch (e: any) {
|
|
303
|
+
setRawError(String(e?.message ?? e));
|
|
304
|
+
} finally {
|
|
305
|
+
setRawLoading(false);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Filtered is optional. The 'no filtered data' case is the
|
|
309
|
+
// common one (only the post-processing pipeline writes it),
|
|
310
|
+
// and we render a friendly message rather than an error tone.
|
|
311
|
+
setFilteredLoading(true);
|
|
312
|
+
setFilteredError(null);
|
|
313
|
+
setFilteredBlob(null);
|
|
314
|
+
try {
|
|
315
|
+
const resp: any = await invoke(
|
|
316
|
+
'tis.read_filtered' as any, MessageType.Request,
|
|
317
|
+
{ project_id: projectId, method_id: methodId, run_id: runId, name: blobName } as any,
|
|
318
|
+
);
|
|
319
|
+
if (resp?.success) {
|
|
320
|
+
setFilteredBlob(resp.data ?? {});
|
|
321
|
+
} else {
|
|
322
|
+
setFilteredError(resp?.error_message ?? 'No filtered data on disk for this run.');
|
|
323
|
+
}
|
|
324
|
+
} catch (e: any) {
|
|
325
|
+
setFilteredError(String(e?.message ?? e));
|
|
326
|
+
} finally {
|
|
327
|
+
setFilteredLoading(false);
|
|
328
|
+
}
|
|
329
|
+
}, [projectId, methodId, runId, blobName, invoke]);
|
|
330
|
+
|
|
331
|
+
// When the run changes, drop the cache key so the next dialog
|
|
332
|
+
// open refetches. Don't auto-fetch — that's wasteful if the
|
|
333
|
+
// operator never opens the dialog.
|
|
334
|
+
useEffect(() => {
|
|
335
|
+
fetchKeyRef.current = '';
|
|
336
|
+
}, [projectId, methodId, runId, blobName]);
|
|
337
|
+
|
|
338
|
+
const openRawDialog = () => {
|
|
339
|
+
setRawOpen(true);
|
|
340
|
+
void loadBlobs();
|
|
341
|
+
};
|
|
342
|
+
|
|
256
343
|
// -----------------------------------------------------------------
|
|
257
344
|
// Render
|
|
258
345
|
// -----------------------------------------------------------------
|
|
@@ -269,7 +356,8 @@ export const TestDataView: React.FC<TestDataViewProps> = (props) => {
|
|
|
269
356
|
<Header meta={meta} config={meta?.config} runId={runId}
|
|
270
357
|
projectId={projectId} methodId={methodId}
|
|
271
358
|
canViewRaw={!!schema.raw_data}
|
|
272
|
-
onViewRaw={
|
|
359
|
+
onViewRaw={openRawDialog}
|
|
360
|
+
onShowConfig={() => setConfigOpen(true)} />
|
|
273
361
|
|
|
274
362
|
{scatterViews.length > 0 && (
|
|
275
363
|
<div className="p-card" style={{ padding: '1rem' }}>
|
|
@@ -310,22 +398,62 @@ export const TestDataView: React.FC<TestDataViewProps> = (props) => {
|
|
|
310
398
|
<ResultsGrid schema={schema.results_fields} values={results} />
|
|
311
399
|
</div>
|
|
312
400
|
|
|
401
|
+
{/*
|
|
402
|
+
* View Raw Data dialog — tabular listing of the raw and
|
|
403
|
+
* filtered columnar blobs. Charts of cycle data are
|
|
404
|
+
* already shown in the main view above; this dialog is
|
|
405
|
+
* specifically the per-sample numerical view that the
|
|
406
|
+
* operator needs for spot-checking, copy/paste, or
|
|
407
|
+
* confirming a post-processing pipeline ran.
|
|
408
|
+
*/}
|
|
313
409
|
{schema.raw_data && (
|
|
314
410
|
<Dialog
|
|
315
411
|
visible={rawOpen}
|
|
316
412
|
onHide={() => setRawOpen(false)}
|
|
317
|
-
header=
|
|
413
|
+
header={`Run Data — ${runId}`}
|
|
318
414
|
style={{ width: '90vw', height: '80vh' }}
|
|
319
415
|
maximizable
|
|
320
416
|
>
|
|
321
|
-
<
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
417
|
+
<TabView style={{ height: '100%' }}>
|
|
418
|
+
<TabPanel header="Raw Data">
|
|
419
|
+
<DataBlobTable
|
|
420
|
+
blob={rawBlob}
|
|
421
|
+
loading={rawLoading}
|
|
422
|
+
error={rawError}
|
|
423
|
+
rawData={schema.raw_data}
|
|
424
|
+
/>
|
|
425
|
+
</TabPanel>
|
|
426
|
+
<TabPanel header="Filtered Data">
|
|
427
|
+
<DataBlobTable
|
|
428
|
+
blob={filteredBlob}
|
|
429
|
+
loading={filteredLoading}
|
|
430
|
+
error={filteredError}
|
|
431
|
+
rawData={schema.raw_data}
|
|
432
|
+
emptyMessage="Filtered data is written by post-processing — none on disk for this run yet."
|
|
433
|
+
/>
|
|
434
|
+
</TabPanel>
|
|
435
|
+
</TabView>
|
|
327
436
|
</Dialog>
|
|
328
437
|
)}
|
|
438
|
+
|
|
439
|
+
{/*
|
|
440
|
+
* Test Configuration dialog — surfaces the staged config
|
|
441
|
+
* map (everything the operator typed into the Setup tab,
|
|
442
|
+
* plus any project_fields the manager attached). Was
|
|
443
|
+
* previously inline under the header; moved into a dialog
|
|
444
|
+
* because it's reference material the operator only
|
|
445
|
+
* occasionally needs to consult, and inline space is at
|
|
446
|
+
* a premium on the run-data view.
|
|
447
|
+
*/}
|
|
448
|
+
<Dialog
|
|
449
|
+
visible={configOpen}
|
|
450
|
+
onHide={() => setConfigOpen(false)}
|
|
451
|
+
header="Test Configuration"
|
|
452
|
+
style={{ width: 'min(640px, 90vw)' }}
|
|
453
|
+
modal
|
|
454
|
+
>
|
|
455
|
+
<ConfigList config={meta?.config} />
|
|
456
|
+
</Dialog>
|
|
329
457
|
</div>
|
|
330
458
|
);
|
|
331
459
|
};
|
|
@@ -338,7 +466,8 @@ const Header: React.FC<{
|
|
|
338
466
|
meta: any; config: any; runId: string;
|
|
339
467
|
projectId: string; methodId: string;
|
|
340
468
|
canViewRaw: boolean; onViewRaw: () => void;
|
|
341
|
-
|
|
469
|
+
onShowConfig: () => void;
|
|
470
|
+
}> = ({ meta, config, runId, projectId, methodId, canViewRaw, onViewRaw, onShowConfig }) => {
|
|
342
471
|
// Sample ID is the field operators look for first; pull it from the
|
|
343
472
|
// top-level test.json field, falling back to the legacy nested location.
|
|
344
473
|
const sampleId =
|
|
@@ -346,39 +475,203 @@ const Header: React.FC<{
|
|
|
346
475
|
(typeof meta?.config?.sample_id === 'string' && meta.config.sample_id) ||
|
|
347
476
|
'';
|
|
348
477
|
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
478
|
+
// Config detail moved into its own dialog (operator only occasionally
|
|
479
|
+
// needs to consult it). The Info button only renders when there's
|
|
480
|
+
// something to show — keeps the header tight on runs whose config
|
|
481
|
+
// is empty.
|
|
482
|
+
const hasConfigDetail = config && typeof config === 'object'
|
|
483
|
+
&& Object.entries(config).some(([k]) => k !== 'sample_id');
|
|
354
484
|
|
|
355
485
|
return (
|
|
356
486
|
<div className="p-card" style={{ padding: '1rem' }}>
|
|
357
487
|
<div className="flex" style={{ justifyContent: 'space-between', alignItems: 'flex-start', gap: '1rem' }}>
|
|
358
488
|
<div>
|
|
359
|
-
<h2 style={{ margin: 0
|
|
489
|
+
<h2 style={{ margin: 0, display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
|
490
|
+
{sampleId || runId}
|
|
491
|
+
{hasConfigDetail && (
|
|
492
|
+
<Button
|
|
493
|
+
icon="pi pi-info-circle"
|
|
494
|
+
type="button"
|
|
495
|
+
rounded
|
|
496
|
+
text
|
|
497
|
+
onClick={onShowConfig}
|
|
498
|
+
tooltip="Show test configuration"
|
|
499
|
+
tooltipOptions={{ position: 'top' }}
|
|
500
|
+
style={{ width: '2rem', height: '2rem', padding: 0 }}
|
|
501
|
+
aria-label="Show test configuration"
|
|
502
|
+
/>
|
|
503
|
+
)}
|
|
504
|
+
</h2>
|
|
360
505
|
<div style={{ color: 'var(--text-secondary-color)', fontSize: '0.85em' }}>
|
|
361
506
|
project: {projectId} · method: {methodId} · run: {runId}
|
|
362
507
|
{meta?.start_time && <> · started: {new Date(meta.start_time).toLocaleString()}</>}
|
|
363
508
|
</div>
|
|
364
509
|
</div>
|
|
365
510
|
{canViewRaw && (
|
|
366
|
-
<Button icon="pi pi-
|
|
511
|
+
<Button icon="pi pi-table" label="View Raw Data" onClick={onViewRaw} outlined />
|
|
367
512
|
)}
|
|
368
513
|
</div>
|
|
369
|
-
{Object.keys(configWithoutSampleId).length > 0 && (
|
|
370
|
-
<div style={{ marginTop: '0.75rem', display: 'grid',
|
|
371
|
-
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
|
|
372
|
-
gap: '0.25rem 1rem', fontSize: '0.9em' }}>
|
|
373
|
-
{Object.entries(configWithoutSampleId).map(([k, v]) => (
|
|
374
|
-
<div key={k}><strong>{k}:</strong> {formatCell(v, 'string')}</div>
|
|
375
|
-
))}
|
|
376
|
-
</div>
|
|
377
|
-
)}
|
|
378
514
|
</div>
|
|
379
515
|
);
|
|
380
516
|
};
|
|
381
517
|
|
|
518
|
+
// -------------------------------------------------------------------------
|
|
519
|
+
// ConfigList — flat key/value listing for the Test Configuration dialog.
|
|
520
|
+
// Same data the inline grid used to show, just inside its own modal.
|
|
521
|
+
// -------------------------------------------------------------------------
|
|
522
|
+
const ConfigList: React.FC<{ config: any }> = ({ config }) => {
|
|
523
|
+
const entries = config && typeof config === 'object'
|
|
524
|
+
? Object.entries(config).filter(([k]) => k !== 'sample_id')
|
|
525
|
+
: [];
|
|
526
|
+
if (entries.length === 0) {
|
|
527
|
+
return (
|
|
528
|
+
<div style={{ color: 'var(--text-secondary-color)' }}>
|
|
529
|
+
No configuration recorded for this run.
|
|
530
|
+
</div>
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
return (
|
|
534
|
+
<div style={{ display: 'grid',
|
|
535
|
+
gridTemplateColumns: 'auto 1fr',
|
|
536
|
+
gap: '0.5rem 1rem',
|
|
537
|
+
fontSize: '0.95em' }}>
|
|
538
|
+
{entries.map(([k, v]) => (
|
|
539
|
+
<React.Fragment key={k}>
|
|
540
|
+
<div style={{ color: 'var(--text-secondary-color)' }}>{k}</div>
|
|
541
|
+
<div>{formatCell(v, 'string')}</div>
|
|
542
|
+
</React.Fragment>
|
|
543
|
+
))}
|
|
544
|
+
</div>
|
|
545
|
+
);
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
// -------------------------------------------------------------------------
|
|
549
|
+
// DataBlobTable — tabular display of a columnar JSON blob
|
|
550
|
+
// (`{ col_name: number[] }`). Used by the View Raw Data dialog for both
|
|
551
|
+
// the Raw Data and Filtered Data tabs. Schema's `raw_data.columns` is
|
|
552
|
+
// consulted for column ordering and unit labels; columns present in the
|
|
553
|
+
// blob but not in the schema are still rendered (degrades gracefully if
|
|
554
|
+
// the blob carries extra channels). Rendered with virtual scrolling so
|
|
555
|
+
// 5000-sample captures don't blow up the DOM.
|
|
556
|
+
// -------------------------------------------------------------------------
|
|
557
|
+
const DataBlobTable: React.FC<{
|
|
558
|
+
blob: any | null;
|
|
559
|
+
loading: boolean;
|
|
560
|
+
error: string | null;
|
|
561
|
+
rawData: RawDataShape;
|
|
562
|
+
emptyMessage?: string;
|
|
563
|
+
}> = ({ blob, loading, error, rawData, emptyMessage }) => {
|
|
564
|
+
if (loading) {
|
|
565
|
+
return (
|
|
566
|
+
<div style={{ padding: '1rem', color: 'var(--text-secondary-color)' }}>
|
|
567
|
+
Loading…
|
|
568
|
+
</div>
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
if (error) {
|
|
572
|
+
return (
|
|
573
|
+
<div style={{ padding: '1rem', color: 'var(--text-secondary-color)' }}>
|
|
574
|
+
{emptyMessage ?? error}
|
|
575
|
+
</div>
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
if (!blob || typeof blob !== 'object') {
|
|
579
|
+
return (
|
|
580
|
+
<div style={{ padding: '1rem', color: 'var(--text-secondary-color)' }}>
|
|
581
|
+
No data.
|
|
582
|
+
</div>
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Schema-declared columns first, in declaration order; then any
|
|
587
|
+
// extras present in the blob that the schema didn't mention.
|
|
588
|
+
// Filter to columns that are actually array-valued in the blob —
|
|
589
|
+
// scalar fields (e.g., a top-level "checksum") shouldn't get a
|
|
590
|
+
// table column.
|
|
591
|
+
const schemaCols = Object.keys(rawData.columns ?? {});
|
|
592
|
+
const blobArrayCols = Object.keys(blob).filter(k => Array.isArray(blob[k]));
|
|
593
|
+
const orderedCols: string[] = [];
|
|
594
|
+
for (const c of schemaCols) {
|
|
595
|
+
if (Array.isArray(blob[c])) orderedCols.push(c);
|
|
596
|
+
}
|
|
597
|
+
for (const c of blobArrayCols) {
|
|
598
|
+
if (!orderedCols.includes(c)) orderedCols.push(c);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (orderedCols.length === 0) {
|
|
602
|
+
return (
|
|
603
|
+
<div style={{ padding: '1rem', color: 'var(--text-secondary-color)' }}>
|
|
604
|
+
{emptyMessage ?? 'No columnar data in this blob.'}
|
|
605
|
+
</div>
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Row count is the shortest array — keeps the table rectangular
|
|
610
|
+
// even when columns disagree (rare, but the post-processing
|
|
611
|
+
// pipeline could produce truncated columns).
|
|
612
|
+
const nRows = orderedCols.reduce(
|
|
613
|
+
(min, c) => Math.min(min, blob[c].length),
|
|
614
|
+
Number.POSITIVE_INFINITY,
|
|
615
|
+
);
|
|
616
|
+
const finiteRows = Number.isFinite(nRows) ? (nRows as number) : 0;
|
|
617
|
+
|
|
618
|
+
// Materialise rows on-demand. With virtual scrolling, only the
|
|
619
|
+
// visible window is in the DOM, but PrimeReact's virtualScroller
|
|
620
|
+
// wants an array — building it lazily with `Array.from` is
|
|
621
|
+
// O(n_visible) per render.
|
|
622
|
+
const rows = Array.from({ length: finiteRows }, (_, i) => {
|
|
623
|
+
const row: Record<string, any> = { __i: i };
|
|
624
|
+
for (const c of orderedCols) row[c] = blob[c][i];
|
|
625
|
+
return row;
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
const headerFor = (col: string) => {
|
|
629
|
+
const u = rawData.units?.[col];
|
|
630
|
+
return u ? `${col} [${u}]` : col;
|
|
631
|
+
};
|
|
632
|
+
|
|
633
|
+
return (
|
|
634
|
+
<DataTable
|
|
635
|
+
value={rows}
|
|
636
|
+
scrollable
|
|
637
|
+
scrollHeight="60vh"
|
|
638
|
+
virtualScrollerOptions={{ itemSize: 32 }}
|
|
639
|
+
emptyMessage={emptyMessage ?? 'No data.'}
|
|
640
|
+
size="small"
|
|
641
|
+
stripedRows
|
|
642
|
+
>
|
|
643
|
+
<Column
|
|
644
|
+
field="__i"
|
|
645
|
+
header="#"
|
|
646
|
+
style={{ width: '5rem', textAlign: 'right' }}
|
|
647
|
+
bodyStyle={{ fontVariantNumeric: 'tabular-nums', textAlign: 'right' }}
|
|
648
|
+
/>
|
|
649
|
+
{orderedCols.map(c => (
|
|
650
|
+
<Column
|
|
651
|
+
key={c}
|
|
652
|
+
field={c}
|
|
653
|
+
header={headerFor(c)}
|
|
654
|
+
style={{ minWidth: '8rem' }}
|
|
655
|
+
bodyStyle={{ fontVariantNumeric: 'tabular-nums', textAlign: 'right' }}
|
|
656
|
+
body={(row) => formatNumeric(row[c])}
|
|
657
|
+
/>
|
|
658
|
+
))}
|
|
659
|
+
</DataTable>
|
|
660
|
+
);
|
|
661
|
+
};
|
|
662
|
+
|
|
663
|
+
const formatNumeric = (v: any): string => {
|
|
664
|
+
if (v === null || v === undefined) return '';
|
|
665
|
+
if (typeof v === 'number') {
|
|
666
|
+
if (!Number.isFinite(v)) return String(v);
|
|
667
|
+
// 6 sig figs strikes a balance: enough precision for most
|
|
668
|
+
// engineering signals, narrow enough that columns don't grow
|
|
669
|
+
// wildly for nice round numbers like 0.001.
|
|
670
|
+
return Number.parseFloat(v.toPrecision(6)).toString();
|
|
671
|
+
}
|
|
672
|
+
return String(v);
|
|
673
|
+
};
|
|
674
|
+
|
|
382
675
|
const ResultsGrid: React.FC<{ schema: TestFieldDef[]; values: any }> = ({ schema, values }) => {
|
|
383
676
|
if (!values || Object.keys(values).length === 0) {
|
|
384
677
|
return <div style={{ color: 'var(--text-secondary-color)' }}>No results yet.</div>;
|