@adcops/autocore-react 3.3.61 → 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 +7 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +1 -1
- 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/TisProvider.d.ts +17 -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 +27 -0
- package/src/components/tis/TestDataView.tsx +321 -28
- package/src/components/tis/TisProvider.tsx +44 -0
- 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,146 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* <SubLocationPicker> — grid view for an asset's sub_locations
|
|
3
|
+
* (typically surface lanes). Each cell shows the sub_location's status
|
|
4
|
+
* and lets the operator click to mark it in_use / worn / available, or
|
|
5
|
+
* select it for the active test.
|
|
6
|
+
*
|
|
7
|
+
* Pure context-driven: reads selection.assetId from <AmsProvider>.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import React, { useContext, useEffect, useState } from 'react';
|
|
11
|
+
import { Button } from 'primereact/button';
|
|
12
|
+
import { Dialog } from 'primereact/dialog';
|
|
13
|
+
import { Dropdown } from 'primereact/dropdown';
|
|
14
|
+
import { EventEmitterContext } from '../../core/EventEmitterContext';
|
|
15
|
+
import { MessageType } from '../../hub/CommandMessage';
|
|
16
|
+
import { useAms } from './AmsProvider';
|
|
17
|
+
|
|
18
|
+
interface SubLocationItem {
|
|
19
|
+
id: string;
|
|
20
|
+
[k: string]: any;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface SubLocations {
|
|
24
|
+
name: string;
|
|
25
|
+
items: SubLocationItem[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const SubLocationPicker: React.FC = () => {
|
|
29
|
+
const { selection } = useAms();
|
|
30
|
+
const { invoke } = useContext(EventEmitterContext);
|
|
31
|
+
const [subs, setSubs] = useState<SubLocations | null>(null);
|
|
32
|
+
const [editing, setEditing] = useState<{ id: string; status: string } | null>(null);
|
|
33
|
+
|
|
34
|
+
const refresh = async () => {
|
|
35
|
+
if (!selection.assetId) {
|
|
36
|
+
setSubs(null);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
const resp: any = await invoke('ams.list_sub_locations' as any, MessageType.Request, {
|
|
41
|
+
asset_id: selection.assetId,
|
|
42
|
+
} as any);
|
|
43
|
+
if (resp?.success) {
|
|
44
|
+
const sl = resp.data?.sub_locations;
|
|
45
|
+
if (sl && Array.isArray(sl.items)) {
|
|
46
|
+
setSubs(sl as SubLocations);
|
|
47
|
+
} else {
|
|
48
|
+
setSubs(null);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
} catch (e) {
|
|
52
|
+
console.error('[SubLocationPicker] list_sub_locations:', e);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
useEffect(() => { refresh(); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [selection.assetId]);
|
|
56
|
+
|
|
57
|
+
if (!selection.assetId) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
if (!subs) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const onSave = async () => {
|
|
65
|
+
if (!editing) return;
|
|
66
|
+
try {
|
|
67
|
+
await invoke('ams.update_sub_location' as any, MessageType.Request, {
|
|
68
|
+
asset_id: selection.assetId!,
|
|
69
|
+
location_id: editing.id,
|
|
70
|
+
partial: { status: editing.status },
|
|
71
|
+
} as any);
|
|
72
|
+
setEditing(null);
|
|
73
|
+
await refresh();
|
|
74
|
+
} catch (e) {
|
|
75
|
+
console.error('[SubLocationPicker] update:', e);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const colorFor = (status: string): string => {
|
|
80
|
+
switch (status) {
|
|
81
|
+
case 'available': return '#22c55e';
|
|
82
|
+
case 'in_use': return '#3b82f6';
|
|
83
|
+
case 'worn': return '#f59e0b';
|
|
84
|
+
case 'retired': return '#6b7280';
|
|
85
|
+
default: return '#9ca3af';
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<div>
|
|
91
|
+
<h4 style={{ marginBottom: '0.5rem', textTransform: 'capitalize' }}>{subs.name}</h4>
|
|
92
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(7rem, 1fr))', gap: '0.5rem' }}>
|
|
93
|
+
{subs.items.map(item => {
|
|
94
|
+
const status = String(item.status ?? '');
|
|
95
|
+
return (
|
|
96
|
+
<button
|
|
97
|
+
key={item.id}
|
|
98
|
+
onClick={() => setEditing({ id: item.id, status })}
|
|
99
|
+
style={{
|
|
100
|
+
padding: '0.75rem',
|
|
101
|
+
border: `2px solid ${colorFor(status)}`,
|
|
102
|
+
borderRadius: 6,
|
|
103
|
+
background: 'transparent',
|
|
104
|
+
color: 'inherit',
|
|
105
|
+
cursor: 'pointer',
|
|
106
|
+
textAlign: 'left',
|
|
107
|
+
}}
|
|
108
|
+
>
|
|
109
|
+
<div style={{ fontWeight: 700 }}>{item.id}</div>
|
|
110
|
+
<div style={{ fontSize: '0.875rem', color: colorFor(status) }}>{status}</div>
|
|
111
|
+
{item.cycles_used !== undefined && (
|
|
112
|
+
<div style={{ fontSize: '0.75rem', color: '#9ca3af' }}>
|
|
113
|
+
{Number(item.cycles_used).toLocaleString()} cycles
|
|
114
|
+
</div>
|
|
115
|
+
)}
|
|
116
|
+
</button>
|
|
117
|
+
);
|
|
118
|
+
})}
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
<Dialog
|
|
122
|
+
header={editing ? `Edit ${editing.id}` : 'Edit'}
|
|
123
|
+
visible={!!editing}
|
|
124
|
+
onHide={() => setEditing(null)}
|
|
125
|
+
style={{ width: '24rem' }}
|
|
126
|
+
footer={
|
|
127
|
+
<>
|
|
128
|
+
<Button label="Cancel" severity="secondary" onClick={() => setEditing(null)} />
|
|
129
|
+
<Button label="Save" icon="pi pi-check" onClick={onSave} />
|
|
130
|
+
</>
|
|
131
|
+
}
|
|
132
|
+
>
|
|
133
|
+
{editing && (
|
|
134
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '0.5rem 1rem' }}>
|
|
135
|
+
<label>Status</label>
|
|
136
|
+
<Dropdown
|
|
137
|
+
value={editing.status}
|
|
138
|
+
options={['available', 'in_use', 'worn', 'retired'].map(v => ({ label: v, value: v }))}
|
|
139
|
+
onChange={(e) => setEditing(s => s ? { ...s, status: e.value } : s)}
|
|
140
|
+
/>
|
|
141
|
+
</div>
|
|
142
|
+
)}
|
|
143
|
+
</Dialog>
|
|
144
|
+
</div>
|
|
145
|
+
);
|
|
146
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Public surface of the AMS component family. Drop <AmsProvider> at
|
|
3
|
+
* the top of your HMI; the rest are zero-prop and read from context.
|
|
4
|
+
*
|
|
5
|
+
* See autocore-server/doc/ams_product_plan.md for the full design.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export { AmsProvider, useAms, useAmsSchemas, useAmsAlerts, useAmsAssets, useAmsSelection } from './AmsProvider';
|
|
9
|
+
export { AssetRegistryTable } from './AssetRegistryTable';
|
|
10
|
+
export { AssetDetailView } from './AssetDetailView';
|
|
11
|
+
export { CalibrationEntryDialog } from './CalibrationEntryDialog';
|
|
12
|
+
export { SubLocationPicker } from './SubLocationPicker';
|
package/src/components/index.ts
CHANGED
|
@@ -47,3 +47,30 @@ export type { TestDataViewProps, ChartAxis, ChartSeries, ChartView, RawDataShape
|
|
|
47
47
|
|
|
48
48
|
export { TestRawDataView } from './tis/TestRawDataView';
|
|
49
49
|
export type { TestRawDataViewProps } from './tis/TestRawDataView';
|
|
50
|
+
|
|
51
|
+
// -----------------------------------------------------------------------
|
|
52
|
+
// Asset Management System — see autocore-server/doc/ams_product_plan.md
|
|
53
|
+
// Drop <AmsProvider> at the top of your HMI; the rest are zero-prop.
|
|
54
|
+
// -----------------------------------------------------------------------
|
|
55
|
+
export {
|
|
56
|
+
AmsProvider,
|
|
57
|
+
useAms,
|
|
58
|
+
useAmsSchemas,
|
|
59
|
+
useAmsAlerts,
|
|
60
|
+
useAmsAssets,
|
|
61
|
+
useAmsSelection,
|
|
62
|
+
} from './ams/AmsProvider';
|
|
63
|
+
export type {
|
|
64
|
+
AmsProviderProps,
|
|
65
|
+
AmsContextValue,
|
|
66
|
+
AmsAlerts,
|
|
67
|
+
AmsAssetEntry,
|
|
68
|
+
AmsSelection,
|
|
69
|
+
AmsSchemaRegistry,
|
|
70
|
+
AmsTypeSchema,
|
|
71
|
+
} from './ams/AmsProvider';
|
|
72
|
+
export { AssetRegistryTable } from './ams/AssetRegistryTable';
|
|
73
|
+
export { AssetDetailView } from './ams/AssetDetailView';
|
|
74
|
+
export { CalibrationEntryDialog } from './ams/CalibrationEntryDialog';
|
|
75
|
+
export type { CalibrationEntryDialogProps } from './ams/CalibrationEntryDialog';
|
|
76
|
+
export { SubLocationPicker } from './ams/SubLocationPicker';
|
|
@@ -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>;
|