@adcops/autocore-react 3.3.79 → 3.3.83
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.map +1 -1
- package/dist/components/ams/AmsProvider.js +1 -1
- package/dist/components/ams/AssetDetailView.d.ts.map +1 -1
- package/dist/components/ams/AssetDetailView.js +1 -1
- package/dist/components/ams/CalibrationEntryDialog.d.ts +15 -0
- package/dist/components/ams/CalibrationEntryDialog.d.ts.map +1 -1
- package/dist/components/ams/CalibrationEntryDialog.js +1 -1
- package/dist/components/tis/TestDataView.d.ts +3 -0
- 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.map +1 -1
- package/dist/components/tis/TestSetupForm.js +1 -1
- package/dist/components/tis/TisProvider.d.ts +24 -0
- package/dist/components/tis/TisProvider.d.ts.map +1 -1
- package/dist/components/tis/TisProvider.js +1 -1
- package/dist/components/tis/useRawCycleData.d.ts +39 -0
- package/dist/components/tis/useRawCycleData.d.ts.map +1 -0
- package/dist/components/tis/useRawCycleData.js +1 -0
- package/package.json +1 -1
- package/src/components/ams/AmsProvider.tsx +4 -3
- package/src/components/ams/AssetDetailView.tsx +24 -2
- package/src/components/ams/CalibrationEntryDialog.tsx +75 -13
- package/src/components/tis/TestDataView.tsx +176 -68
- package/src/components/tis/TestRawDataView.tsx +15 -97
- package/src/components/tis/TestSetupForm.tsx +25 -9
- package/src/components/tis/TisProvider.tsx +33 -2
- package/src/components/tis/useRawCycleData.ts +157 -0
|
@@ -24,6 +24,11 @@ export const AssetDetailView: React.FC = () => {
|
|
|
24
24
|
const [cals, setCals] = useState<any[]>([]);
|
|
25
25
|
const [usage, setUsage] = useState<any | null>(null);
|
|
26
26
|
const [calDialogOpen, setCalDialogOpen] = useState(false);
|
|
27
|
+
/** When non-null, the dialog opens in edit mode pre-seeded from this
|
|
28
|
+
* Calibration record. Only the asset's current calibration is
|
|
29
|
+
* editable (server enforces); the Edit button only renders on that
|
|
30
|
+
* row, so this is always either null or the current cal. */
|
|
31
|
+
const [editingCal, setEditingCal] = useState<any | null>(null);
|
|
27
32
|
const [deleting, setDeleting] = useState(false);
|
|
28
33
|
|
|
29
34
|
/** Two-step asset retirement: flip status to retired via update_asset.
|
|
@@ -234,7 +239,7 @@ export const AssetDetailView: React.FC = () => {
|
|
|
234
239
|
/>
|
|
235
240
|
)}
|
|
236
241
|
<Button label="+ Calibration" icon="pi pi-plus"
|
|
237
|
-
onClick={() => setCalDialogOpen(true)} />
|
|
242
|
+
onClick={() => { setEditingCal(null); setCalDialogOpen(true); }} />
|
|
238
243
|
</div>
|
|
239
244
|
</div>
|
|
240
245
|
<ConfirmDialog />
|
|
@@ -253,13 +258,30 @@ export const AssetDetailView: React.FC = () => {
|
|
|
253
258
|
<Column header="Values"
|
|
254
259
|
body={(r) => <code style={{ fontSize: '0.75rem' }}>{JSON.stringify(r.values)}</code>}
|
|
255
260
|
/>
|
|
261
|
+
<Column header="" style={{ width: '5rem' }}
|
|
262
|
+
body={(r) => (
|
|
263
|
+
// Edit is only offered for the asset's current
|
|
264
|
+
// calibration — historical records are frozen
|
|
265
|
+
// (server enforces too). Customers asked for
|
|
266
|
+
// this so typos can be fixed without minting a
|
|
267
|
+
// new cal_id every time.
|
|
268
|
+
r.cal_id === asset.current_calibration_id ? (
|
|
269
|
+
<Button icon="pi pi-pencil" text rounded size="small"
|
|
270
|
+
tooltip="Edit this calibration (fixes a typo in place)."
|
|
271
|
+
tooltipOptions={{ position: 'left' }}
|
|
272
|
+
onClick={() => { setEditingCal(r); setCalDialogOpen(true); }}
|
|
273
|
+
/>
|
|
274
|
+
) : null
|
|
275
|
+
)}
|
|
276
|
+
/>
|
|
256
277
|
</DataTable>
|
|
257
278
|
|
|
258
279
|
<CalibrationEntryDialog
|
|
259
280
|
visible={calDialogOpen}
|
|
260
281
|
assetId={asset.asset_id}
|
|
261
282
|
assetType={asset.asset_type}
|
|
262
|
-
|
|
283
|
+
editing={editingCal}
|
|
284
|
+
onHide={() => { setCalDialogOpen(false); setEditingCal(null); }}
|
|
263
285
|
onAdded={() => { refresh(); }}
|
|
264
286
|
/>
|
|
265
287
|
</div>
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* <CalibrationEntryDialog> — form for adding a calibration
|
|
3
|
-
*
|
|
2
|
+
* <CalibrationEntryDialog> — form for adding *or editing* a calibration
|
|
3
|
+
* record. Renders a dynamic field set built from the asset_type's
|
|
4
4
|
* `calibration_fields` schema, plus a few standard meta fields.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
* `
|
|
8
|
-
*
|
|
6
|
+
* Two modes:
|
|
7
|
+
* - Add: `editing` prop absent. Submits via `ams.add_calibration`,
|
|
8
|
+
* which mints a new cal_id and bumps the asset's current_calibration_id.
|
|
9
|
+
* - Edit: `editing` prop carries the existing Calibration record. Form
|
|
10
|
+
* fields are pre-seeded from it; submit hits `ams.update_calibration`
|
|
11
|
+
* which overwrites the file in place (cal_id and the rest of the
|
|
12
|
+
* history are preserved). The server enforces that only the asset's
|
|
13
|
+
* *current* calibration is editable, so the parent should only open
|
|
14
|
+
* the dialog in edit mode for the current row.
|
|
9
15
|
*/
|
|
10
16
|
|
|
11
17
|
import React, { useContext, useEffect, useState } from 'react';
|
|
@@ -24,7 +30,22 @@ export interface CalibrationEntryDialogProps {
|
|
|
24
30
|
assetId: string;
|
|
25
31
|
assetType: string;
|
|
26
32
|
onHide: () => void;
|
|
33
|
+
/** Fires after a successful add OR edit. The cal_id is the same as
|
|
34
|
+
* the editing record on edit, or a freshly minted id on add. */
|
|
27
35
|
onAdded?: (calId: string) => void;
|
|
36
|
+
/** When set, the dialog opens in edit mode pre-seeded from this
|
|
37
|
+
* Calibration record. cal_id is preserved through the round-trip.
|
|
38
|
+
* Caller is responsible for only passing the asset's *current*
|
|
39
|
+
* calibration — the server rejects edits to historical records. */
|
|
40
|
+
editing?: {
|
|
41
|
+
cal_id: string;
|
|
42
|
+
performed_at?: string;
|
|
43
|
+
performed_by?: string;
|
|
44
|
+
expires_at?: string | null;
|
|
45
|
+
cert_ref?: string;
|
|
46
|
+
notes?: string;
|
|
47
|
+
values: any;
|
|
48
|
+
} | null;
|
|
28
49
|
}
|
|
29
50
|
|
|
30
51
|
interface FieldSpec {
|
|
@@ -50,8 +71,9 @@ interface SubLocationsSchema {
|
|
|
50
71
|
}
|
|
51
72
|
|
|
52
73
|
export const CalibrationEntryDialog: React.FC<CalibrationEntryDialogProps> = ({
|
|
53
|
-
visible, assetId, assetType, onHide, onAdded,
|
|
74
|
+
visible, assetId, assetType, onHide, onAdded, editing,
|
|
54
75
|
}) => {
|
|
76
|
+
const isEdit = !!editing;
|
|
55
77
|
const schemas = useAmsSchemas();
|
|
56
78
|
const { invoke } = useContext(EventEmitterContext);
|
|
57
79
|
|
|
@@ -81,17 +103,46 @@ export const CalibrationEntryDialog: React.FC<CalibrationEntryDialogProps> = ({
|
|
|
81
103
|
const [submitting, setSubmitting] = useState(false);
|
|
82
104
|
const [error, setError] = useState<string | null>(null);
|
|
83
105
|
|
|
106
|
+
// Reset / pre-seed when the dialog opens. In edit mode the form
|
|
107
|
+
// mirrors the existing record so the operator only has to change
|
|
108
|
+
// what's wrong; in add mode the form starts blank.
|
|
84
109
|
useEffect(() => {
|
|
85
|
-
if (visible)
|
|
110
|
+
if (!visible) return;
|
|
111
|
+
setError(null);
|
|
112
|
+
if (editing) {
|
|
113
|
+
// The values payload shape mirrors what the server stores:
|
|
114
|
+
// per-axis types use `{ <key>: { <field>: ... } }`; flat
|
|
115
|
+
// types use `{ <field>: ... }`. Route into the right slot
|
|
116
|
+
// based on the schema, not the payload, so a corrupt or
|
|
117
|
+
// legacy shape doesn't crosstalk into the wrong UI mode.
|
|
118
|
+
if (isPerAxis) {
|
|
119
|
+
setValues({});
|
|
120
|
+
setPerAxisValues(
|
|
121
|
+
(editing.values && typeof editing.values === 'object')
|
|
122
|
+
? editing.values as { [k: string]: { [f: string]: any } }
|
|
123
|
+
: {}
|
|
124
|
+
);
|
|
125
|
+
} else {
|
|
126
|
+
setValues(
|
|
127
|
+
(editing.values && typeof editing.values === 'object')
|
|
128
|
+
? editing.values as { [k: string]: any }
|
|
129
|
+
: {}
|
|
130
|
+
);
|
|
131
|
+
setPerAxisValues({});
|
|
132
|
+
}
|
|
133
|
+
setPerformedBy(editing.performed_by ?? '');
|
|
134
|
+
setExpiresAt(editing.expires_at ? new Date(editing.expires_at) : null);
|
|
135
|
+
setCertRef(editing.cert_ref ?? '');
|
|
136
|
+
setNotes(editing.notes ?? '');
|
|
137
|
+
} else {
|
|
86
138
|
setValues({});
|
|
87
139
|
setPerAxisValues({});
|
|
88
140
|
setPerformedBy('');
|
|
89
141
|
setExpiresAt(null);
|
|
90
142
|
setCertRef('');
|
|
91
143
|
setNotes('');
|
|
92
|
-
setError(null);
|
|
93
144
|
}
|
|
94
|
-
}, [visible]);
|
|
145
|
+
}, [visible, editing, isPerAxis]);
|
|
95
146
|
|
|
96
147
|
const renderField = (f: FieldSpec) => {
|
|
97
148
|
const labelText = `${f.label ?? f.name}${f.units ? ` [${f.units}]` : ''}${f.required ? ' *' : ''}`;
|
|
@@ -176,19 +227,28 @@ export const CalibrationEntryDialog: React.FC<CalibrationEntryDialogProps> = ({
|
|
|
176
227
|
setSubmitting(true);
|
|
177
228
|
setError(null);
|
|
178
229
|
try {
|
|
179
|
-
|
|
230
|
+
// Edit and add share the same payload shape; the topic
|
|
231
|
+
// differentiates them. On edit we also pin the cal_id so
|
|
232
|
+
// the server overwrites the right record rather than
|
|
233
|
+
// minting a new one.
|
|
234
|
+
const topic = isEdit ? 'ams.update_calibration' : 'ams.add_calibration';
|
|
235
|
+
const payload: any = {
|
|
180
236
|
asset_id: assetId,
|
|
181
237
|
performed_by: performedBy,
|
|
182
238
|
expires_at: expiresAt ? expiresAt.toISOString() : null,
|
|
183
239
|
cert_ref: certRef,
|
|
184
240
|
notes,
|
|
185
241
|
values: valuesPayload,
|
|
186
|
-
}
|
|
242
|
+
};
|
|
243
|
+
if (isEdit && editing) {
|
|
244
|
+
payload.cal_id = editing.cal_id;
|
|
245
|
+
}
|
|
246
|
+
const resp: any = await invoke(topic as any, MessageType.Request, payload);
|
|
187
247
|
if (resp?.success) {
|
|
188
248
|
onAdded?.(resp.data.cal_id);
|
|
189
249
|
onHide();
|
|
190
250
|
} else {
|
|
191
|
-
setError(resp?.error_message ??
|
|
251
|
+
setError(resp?.error_message ?? `${topic} failed`);
|
|
192
252
|
}
|
|
193
253
|
} catch (e: any) {
|
|
194
254
|
setError(String(e?.message ?? e));
|
|
@@ -199,7 +259,9 @@ export const CalibrationEntryDialog: React.FC<CalibrationEntryDialogProps> = ({
|
|
|
199
259
|
|
|
200
260
|
return (
|
|
201
261
|
<Dialog
|
|
202
|
-
header={
|
|
262
|
+
header={isEdit
|
|
263
|
+
? `Edit Calibration ${editing!.cal_id} — ${assetId}`
|
|
264
|
+
: `Add Calibration — ${assetId}`}
|
|
203
265
|
visible={visible}
|
|
204
266
|
onHide={onHide}
|
|
205
267
|
style={{ width: '40rem' }}
|
|
@@ -2,10 +2,26 @@
|
|
|
2
2
|
* Copyright (C) 2026 Automated Design Corp. All Rights Reserved.
|
|
3
3
|
*
|
|
4
4
|
* TestDataView — standardized test-detail view for the Test Information
|
|
5
|
-
* System. Renders
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* System. Renders, top-to-bottom:
|
|
6
|
+
*
|
|
7
|
+
* - metadata header (sample, project/method/run, "View Raw Data" btn)
|
|
8
|
+
* - **unified chart panel**: one dropdown lists every view declared in
|
|
9
|
+
* the schema (any type). The chart area dispatches on `view.type`:
|
|
10
|
+
* * `raw_trace` — plots columns from the per-cycle raw blob,
|
|
11
|
+
* shows a cycle picker when >1 cycle exists.
|
|
12
|
+
* * `cycle_scatter` — plots per-cycle scalars across the full run.
|
|
13
|
+
* Raw blob fetching is lazy: nothing's pulled until a raw_trace view
|
|
14
|
+
* is actually selected, so scatter-only runs don't pay the round trip.
|
|
15
|
+
* - virtual-scroll cycle table
|
|
16
|
+
* - results table
|
|
17
|
+
*
|
|
18
|
+
* Subscribes to live `tis.cycle_added` and `tis.results_updated`
|
|
19
|
+
* broadcasts so cycle data + scatter chart update as the control
|
|
20
|
+
* program appends cycles.
|
|
21
|
+
*
|
|
22
|
+
* Sibling <TestRawDataView> stays exported for callers that want a
|
|
23
|
+
* focused trace-only viewer with no scatter / cycle-table / results
|
|
24
|
+
* sections.
|
|
9
25
|
*/
|
|
10
26
|
|
|
11
27
|
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
|
@@ -26,6 +42,7 @@ import { Line } from 'react-chartjs-2';
|
|
|
26
42
|
import { EventEmitterContext } from '../../core/EventEmitterContext';
|
|
27
43
|
import { MessageType } from '../../hub/CommandMessage';
|
|
28
44
|
import { useTis } from './TisProvider';
|
|
45
|
+
import { useRawCycleData } from './useRawCycleData';
|
|
29
46
|
|
|
30
47
|
ChartJS.register(
|
|
31
48
|
CategoryScale, LinearScale, PointElement, LineElement,
|
|
@@ -88,6 +105,9 @@ export interface TestDataViewProps {
|
|
|
88
105
|
throttleMs?: number;
|
|
89
106
|
/** Fixed cycle-table scroll height. Default "400px". */
|
|
90
107
|
cycleTableHeight?: string;
|
|
108
|
+
/** Height of the unified chart panel (any CSS length). Default "320px".
|
|
109
|
+
* Set to e.g. "50vh" for a taller chart on a single-test page. */
|
|
110
|
+
chartHeight?: string;
|
|
91
111
|
}
|
|
92
112
|
|
|
93
113
|
// -------------------------------------------------------------------------
|
|
@@ -98,7 +118,7 @@ export const TestDataView: React.FC<TestDataViewProps> = (props) => {
|
|
|
98
118
|
const methodId = props.methodId ?? tis.selection.methodId;
|
|
99
119
|
const runId = props.runId ?? tis.selection.runId;
|
|
100
120
|
const schema = props.schema ?? (methodId ? (tis.schemas[methodId] as TestMethod) : undefined);
|
|
101
|
-
const { throttleMs = 100, cycleTableHeight = '400px' } = props;
|
|
121
|
+
const { throttleMs = 100, cycleTableHeight = '400px', chartHeight = '320px' } = props;
|
|
102
122
|
const { invoke, subscribe, unsubscribe } = useContext(EventEmitterContext);
|
|
103
123
|
|
|
104
124
|
const [meta, setMeta] = useState<any>(null);
|
|
@@ -128,37 +148,42 @@ export const TestDataView: React.FC<TestDataViewProps> = (props) => {
|
|
|
128
148
|
const [availableCycles, setAvailableCycles] = useState<number[]>([]);
|
|
129
149
|
const [selectedCycle, setSelectedCycle] = useState<number | null>(null);
|
|
130
150
|
|
|
131
|
-
//
|
|
132
|
-
|
|
151
|
+
// All views, any type, in declaration order. The dropdown lists
|
|
152
|
+
// every one; the chart area dispatches on `view.type` to render
|
|
153
|
+
// either a scatter or a raw_trace chart from the appropriate data.
|
|
154
|
+
const allViews = useMemo(() => {
|
|
133
155
|
const out: { name: string; view: ChartView }[] = [];
|
|
134
156
|
for (const [name, v] of Object.entries(schema?.views ?? {})) {
|
|
135
|
-
|
|
157
|
+
out.push({ name, view: v as ChartView });
|
|
136
158
|
}
|
|
137
159
|
return out;
|
|
138
160
|
}, [schema]);
|
|
139
161
|
|
|
140
162
|
const [selectedView, setSelectedView] = useState<string | null>(
|
|
141
|
-
|
|
163
|
+
allViews.length > 0 ? allViews[0].name : null,
|
|
142
164
|
);
|
|
143
165
|
|
|
144
166
|
// Default to the first available view as soon as the schema loads.
|
|
145
167
|
// The useState initializer above only runs on first mount, when
|
|
146
|
-
//
|
|
168
|
+
// allViews is typically still empty (schema fetch in flight).
|
|
147
169
|
// Without this effect, selectedView stays null and the chart
|
|
148
170
|
// stays blank until the operator opens the dropdown — almost
|
|
149
171
|
// nobody does, so they email asking why the chart is broken.
|
|
150
172
|
//
|
|
151
173
|
// Also handles the case where the schema changes and the
|
|
152
|
-
// currently-selected view is no longer in
|
|
153
|
-
//
|
|
174
|
+
// currently-selected view is no longer in allViews — falls back
|
|
175
|
+
// to the new first view rather than rendering nothing.
|
|
154
176
|
useEffect(() => {
|
|
155
|
-
if (
|
|
177
|
+
if (allViews.length === 0) return;
|
|
156
178
|
const stillValid = selectedView !== null
|
|
157
|
-
&&
|
|
179
|
+
&& allViews.some(v => v.name === selectedView);
|
|
158
180
|
if (!stillValid) {
|
|
159
|
-
setSelectedView(
|
|
181
|
+
setSelectedView(allViews[0].name);
|
|
160
182
|
}
|
|
161
|
-
}, [
|
|
183
|
+
}, [allViews, selectedView]);
|
|
184
|
+
|
|
185
|
+
const selectedViewDef = allViews.find(v => v.name === selectedView)?.view;
|
|
186
|
+
const isRawTraceView = selectedViewDef?.type === 'raw_trace';
|
|
162
187
|
|
|
163
188
|
// Pending updates coalesced by a throttle window — keeps React
|
|
164
189
|
// re-renders at <= 1 / throttleMs even if cycles stream faster.
|
|
@@ -245,58 +270,102 @@ export const TestDataView: React.FC<TestDataViewProps> = (props) => {
|
|
|
245
270
|
}, [projectId, methodId, runId, throttleMs]);
|
|
246
271
|
|
|
247
272
|
// -----------------------------------------------------------------
|
|
248
|
-
//
|
|
273
|
+
// Raw-trace data fetch (lazy)
|
|
274
|
+
//
|
|
275
|
+
// Only ever pulls a blob when the active chart view is a raw_trace.
|
|
276
|
+
// Switching to a cycle_scatter view leaves the previously-loaded
|
|
277
|
+
// raw state alone (still in memory but unused), so flipping back
|
|
278
|
+
// and forth is instant after the first fetch.
|
|
279
|
+
// -----------------------------------------------------------------
|
|
280
|
+
const traceBlobName = schema?.raw_data?.blob_name ?? 'trace';
|
|
281
|
+
const traceFetch = useRawCycleData({
|
|
282
|
+
projectId, methodId, runId,
|
|
283
|
+
blobName: traceBlobName,
|
|
284
|
+
enabled: isRawTraceView,
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// -----------------------------------------------------------------
|
|
288
|
+
// Chart data — dispatches on view.type so one panel handles both
|
|
289
|
+
// shapes. Returns null when the active view's input data isn't
|
|
290
|
+
// ready (e.g., raw blob still loading); the render block treats
|
|
291
|
+
// null as "show overlay instead of an empty chart."
|
|
249
292
|
// -----------------------------------------------------------------
|
|
250
293
|
const chartData = useMemo(() => {
|
|
251
|
-
if (!
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
294
|
+
if (!selectedViewDef) return null;
|
|
295
|
+
if (selectedViewDef.type === 'cycle_scatter') {
|
|
296
|
+
const xField = selectedViewDef.x.field;
|
|
297
|
+
if (!xField) return null;
|
|
298
|
+
const asc = [...cycles].reverse(); // state is newest-first; charts want oldest-first
|
|
299
|
+
const xs = asc.map(c => c[xField]);
|
|
300
|
+
const datasets = selectedViewDef.y.map((s, idx) => ({
|
|
301
|
+
label: s.label ?? s.field,
|
|
302
|
+
data: asc.map(c => c[s.field!]),
|
|
303
|
+
yAxisID: s.y_axis === 'right' ? 'y1' : 'y',
|
|
304
|
+
borderColor: palette(idx),
|
|
305
|
+
backgroundColor: palette(idx),
|
|
306
|
+
tension: 0.1,
|
|
307
|
+
pointRadius: 2,
|
|
308
|
+
}));
|
|
309
|
+
return { labels: xs, datasets };
|
|
310
|
+
}
|
|
311
|
+
if (selectedViewDef.type === 'raw_trace') {
|
|
312
|
+
if (!traceFetch.raw) return null;
|
|
313
|
+
const xCol = selectedViewDef.x.column;
|
|
314
|
+
if (!xCol) return null;
|
|
315
|
+
const xs = traceFetch.raw[xCol] ?? [];
|
|
316
|
+
const datasets = selectedViewDef.y.map((s, idx) => ({
|
|
317
|
+
label: s.label ?? s.column,
|
|
318
|
+
data: (traceFetch.raw![s.column!] ?? []).map((y, i) => ({ x: xs[i], y })),
|
|
319
|
+
yAxisID: s.y_axis === 'right' ? 'y1' : 'y',
|
|
320
|
+
borderColor: palette(idx),
|
|
321
|
+
backgroundColor: palette(idx),
|
|
322
|
+
pointRadius: 0,
|
|
323
|
+
borderWidth: 1.5,
|
|
324
|
+
showLine: true,
|
|
325
|
+
}));
|
|
326
|
+
return { datasets };
|
|
327
|
+
}
|
|
328
|
+
return null;
|
|
329
|
+
}, [selectedViewDef, cycles, traceFetch.raw]);
|
|
330
|
+
|
|
273
331
|
const usesRightAxis = selectedViewDef?.y.some(s => s.y_axis === 'right') ?? false;
|
|
274
332
|
|
|
275
|
-
const chartOptions = useMemo(() =>
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
333
|
+
const chartOptions = useMemo(() => {
|
|
334
|
+
const isTrace = selectedViewDef?.type === 'raw_trace';
|
|
335
|
+
return {
|
|
336
|
+
responsive: true,
|
|
337
|
+
maintainAspectRatio: false,
|
|
338
|
+
// raw_trace datasets are pre-built `{x, y}` points so
|
|
339
|
+
// chart.js shouldn't try to parse them; cycle_scatter uses
|
|
340
|
+
// the `labels` + per-dataset `data: number[]` shape and
|
|
341
|
+
// needs default parsing on.
|
|
342
|
+
parsing: isTrace ? (false as const) : undefined,
|
|
343
|
+
scales: {
|
|
344
|
+
x: isTrace
|
|
345
|
+
? { type: 'linear' as const,
|
|
346
|
+
title: { display: !!selectedViewDef?.x.label, text: selectedViewDef?.x.label } }
|
|
347
|
+
: { title: { display: !!selectedViewDef?.x.label, text: selectedViewDef?.x.label } },
|
|
348
|
+
y: { position: 'left' as const,
|
|
349
|
+
title: { display: true, text: leftAxisLabel(selectedViewDef) } },
|
|
350
|
+
...(usesRightAxis ? {
|
|
351
|
+
y1: { position: 'right' as const,
|
|
352
|
+
grid: { drawOnChartArea: false },
|
|
353
|
+
title: { display: true, text: rightAxisLabel(selectedViewDef) } },
|
|
354
|
+
} : {}),
|
|
355
|
+
},
|
|
356
|
+
plugins: {
|
|
357
|
+
legend: { display: true },
|
|
292
358
|
zoom: {
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
359
|
+
pan: { enabled: true, mode: 'xy' as const },
|
|
360
|
+
zoom: {
|
|
361
|
+
wheel: { enabled: true },
|
|
362
|
+
pinch: { enabled: true },
|
|
363
|
+
mode: 'xy' as const,
|
|
364
|
+
},
|
|
296
365
|
},
|
|
297
366
|
},
|
|
298
|
-
}
|
|
299
|
-
}
|
|
367
|
+
};
|
|
368
|
+
}, [selectedViewDef, usesRightAxis]);
|
|
300
369
|
|
|
301
370
|
// -----------------------------------------------------------------
|
|
302
371
|
// View Raw Data dialog: lazy-fetch raw + filtered blobs the first
|
|
@@ -439,17 +508,41 @@ export const TestDataView: React.FC<TestDataViewProps> = (props) => {
|
|
|
439
508
|
affordance instead of silence. The dropdown is disabled
|
|
440
509
|
in that case; the chart area renders empty. */}
|
|
441
510
|
<div className="p-card" style={{ padding: '1rem' }}>
|
|
442
|
-
<div className="flex" style={{ gap: '1rem', alignItems: 'center', marginBottom: '0.5rem' }}>
|
|
511
|
+
<div className="flex" style={{ gap: '1rem', alignItems: 'center', marginBottom: '0.5rem', flexWrap: 'wrap' }}>
|
|
443
512
|
<Dropdown
|
|
444
513
|
value={selectedView}
|
|
445
|
-
options={
|
|
514
|
+
options={allViews.map(v => ({ label: v.view.title ?? v.name, value: v.name }))}
|
|
446
515
|
onChange={(e) => setSelectedView(e.value)}
|
|
447
|
-
placeholder={
|
|
448
|
-
disabled={
|
|
516
|
+
placeholder={allViews.length === 0 ? 'No view defined' : 'Select a view'}
|
|
517
|
+
disabled={allViews.length === 0}
|
|
449
518
|
/>
|
|
450
519
|
<h3 style={{ margin: 0 }}>{selectedViewDef?.title ?? ''}</h3>
|
|
520
|
+
{/* Cycle picker — only visible for raw_trace views
|
|
521
|
+
AND when more than one cycle exists. The hook
|
|
522
|
+
handles the cycle-list discovery and "default
|
|
523
|
+
to latest" behaviour so this stays declarative. */}
|
|
524
|
+
{isRawTraceView && traceFetch.cycles.length > 1 && (
|
|
525
|
+
<>
|
|
526
|
+
<label htmlFor="chart-cycle-picker"
|
|
527
|
+
style={{ color: 'var(--text-secondary-color)' }}>Cycle:</label>
|
|
528
|
+
<Dropdown
|
|
529
|
+
inputId="chart-cycle-picker"
|
|
530
|
+
value={traceFetch.selectedCycle}
|
|
531
|
+
options={traceFetch.cycles.map(c => ({ label: `Cycle ${c}`, value: c }))}
|
|
532
|
+
onChange={(e) => traceFetch.setSelectedCycle(Number(e.value))}
|
|
533
|
+
style={{ minWidth: '8rem' }}
|
|
534
|
+
/>
|
|
535
|
+
<span style={{ color: 'var(--text-secondary-color)' }}>
|
|
536
|
+
of {traceFetch.cycles.length}
|
|
537
|
+
</span>
|
|
538
|
+
</>
|
|
539
|
+
)}
|
|
451
540
|
</div>
|
|
452
|
-
<div style={{ height:
|
|
541
|
+
<div style={{ height: chartHeight, position: 'relative' }}>
|
|
542
|
+
{isRawTraceView && traceFetch.loading &&
|
|
543
|
+
<ChartOverlay>Loading raw data…</ChartOverlay>}
|
|
544
|
+
{isRawTraceView && traceFetch.error &&
|
|
545
|
+
<ChartOverlay>{traceFetch.error}</ChartOverlay>}
|
|
453
546
|
{chartData && <Line data={chartData} options={chartOptions} />}
|
|
454
547
|
</div>
|
|
455
548
|
</div>
|
|
@@ -864,10 +957,25 @@ const CHART_COLORS = [
|
|
|
864
957
|
];
|
|
865
958
|
const palette = (i: number) => CHART_COLORS[i % CHART_COLORS.length];
|
|
866
959
|
|
|
960
|
+
// Loading / error wash drawn over the chart area while a raw_trace
|
|
961
|
+
// fetch is in flight. Centered, pointer-events-none so the operator
|
|
962
|
+
// can still interact with the dropdown above.
|
|
963
|
+
const ChartOverlay: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
|
964
|
+
<div style={{ position: 'absolute', inset: 0, display: 'flex',
|
|
965
|
+
alignItems: 'center', justifyContent: 'center',
|
|
966
|
+
color: 'var(--text-secondary-color)', pointerEvents: 'none' }}>
|
|
967
|
+
{children}
|
|
968
|
+
</div>
|
|
969
|
+
);
|
|
970
|
+
|
|
971
|
+
// Axis labels work for both scatter (s.field) and raw_trace (s.column)
|
|
972
|
+
// series. Whichever the view declared, that's what's used as a label
|
|
973
|
+
// fallback when the explicit `label` is absent.
|
|
974
|
+
const seriesLabel = (s: ChartSeries) => s.label ?? s.field ?? s.column ?? '';
|
|
867
975
|
const leftAxisLabel = (v?: ChartView) =>
|
|
868
|
-
v?.y.filter(s => s.y_axis !== 'right').map(
|
|
976
|
+
v?.y.filter(s => s.y_axis !== 'right').map(seriesLabel).join(' / ') ?? '';
|
|
869
977
|
const rightAxisLabel = (v?: ChartView) =>
|
|
870
|
-
v?.y.filter(s => s.y_axis === 'right').map(
|
|
978
|
+
v?.y.filter(s => s.y_axis === 'right').map(seriesLabel).join(' / ') ?? '';
|
|
871
979
|
|
|
872
980
|
const formatCell = (v: any, type: string): string => {
|
|
873
981
|
if (v === null || v === undefined) return '';
|