@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.
Files changed (29) hide show
  1. package/dist/components/ams/AmsProvider.d.ts.map +1 -1
  2. package/dist/components/ams/AmsProvider.js +1 -1
  3. package/dist/components/ams/AssetDetailView.d.ts.map +1 -1
  4. package/dist/components/ams/AssetDetailView.js +1 -1
  5. package/dist/components/ams/CalibrationEntryDialog.d.ts +15 -0
  6. package/dist/components/ams/CalibrationEntryDialog.d.ts.map +1 -1
  7. package/dist/components/ams/CalibrationEntryDialog.js +1 -1
  8. package/dist/components/tis/TestDataView.d.ts +3 -0
  9. package/dist/components/tis/TestDataView.d.ts.map +1 -1
  10. package/dist/components/tis/TestDataView.js +1 -1
  11. package/dist/components/tis/TestRawDataView.d.ts.map +1 -1
  12. package/dist/components/tis/TestRawDataView.js +1 -1
  13. package/dist/components/tis/TestSetupForm.d.ts.map +1 -1
  14. package/dist/components/tis/TestSetupForm.js +1 -1
  15. package/dist/components/tis/TisProvider.d.ts +24 -0
  16. package/dist/components/tis/TisProvider.d.ts.map +1 -1
  17. package/dist/components/tis/TisProvider.js +1 -1
  18. package/dist/components/tis/useRawCycleData.d.ts +39 -0
  19. package/dist/components/tis/useRawCycleData.d.ts.map +1 -0
  20. package/dist/components/tis/useRawCycleData.js +1 -0
  21. package/package.json +1 -1
  22. package/src/components/ams/AmsProvider.tsx +4 -3
  23. package/src/components/ams/AssetDetailView.tsx +24 -2
  24. package/src/components/ams/CalibrationEntryDialog.tsx +75 -13
  25. package/src/components/tis/TestDataView.tsx +176 -68
  26. package/src/components/tis/TestRawDataView.tsx +15 -97
  27. package/src/components/tis/TestSetupForm.tsx +25 -9
  28. package/src/components/tis/TisProvider.tsx +33 -2
  29. 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
- onHide={() => setCalDialogOpen(false)}
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 record to
3
- * an asset. Renders a dynamic field set built from the asset_type's
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
- * Triggered by <AssetDetailView>'s "+ Calibration" button. Submits via
7
- * `ams.add_calibration`; on success the AmsProvider's
8
- * `ams.calibration_added` subscription refreshes the registry.
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
- const resp: any = await invoke('ams.add_calibration' as any, MessageType.Request, {
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
- } as any);
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 ?? 'add_calibration failed');
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={`Add Calibration — ${assetId}`}
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 metadata header + cycle-scatter chart + virtual-scroll
6
- * cycle table + results table, and subscribes to live `tis.cycle_added`
7
- * and `tis.results_updated` broadcasts so the display updates as the
8
- * control program appends cycles.
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
- // Scatter-capable views only raw_trace lives in <TestRawDataView>.
132
- const scatterViews = useMemo(() => {
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
- if ((v as ChartView).type === 'cycle_scatter') out.push({ name, view: v as ChartView });
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
- scatterViews.length > 0 ? scatterViews[0].name : null,
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
- // scatterViews is typically still empty (schema fetch in flight).
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 scatterViews — falls
153
- // back to the new first view rather than rendering nothing.
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 (scatterViews.length === 0) return;
177
+ if (allViews.length === 0) return;
156
178
  const stillValid = selectedView !== null
157
- && scatterViews.some(v => v.name === selectedView);
179
+ && allViews.some(v => v.name === selectedView);
158
180
  if (!stillValid) {
159
- setSelectedView(scatterViews[0].name);
181
+ setSelectedView(allViews[0].name);
160
182
  }
161
- }, [scatterViews, selectedView]);
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
- // Chart data
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 (!selectedView || scatterViews.length === 0) return null;
252
- const view = scatterViews.find(v => v.name === selectedView)?.view;
253
- if (!view) return null;
254
-
255
- const xField = view.x.field!;
256
- const asc = [...cycles].reverse(); // cycles state is newest-first; charts want oldest-first
257
- const xs = asc.map(c => c[xField]);
258
-
259
- const datasets = view.y.map((s, idx) => ({
260
- label: s.label ?? s.field,
261
- data: asc.map(c => c[s.field!]),
262
- yAxisID: s.y_axis === 'right' ? 'y1' : 'y',
263
- borderColor: palette(idx),
264
- backgroundColor: palette(idx),
265
- tension: 0.1,
266
- pointRadius: 2,
267
- }));
268
-
269
- return { labels: xs, datasets };
270
- }, [cycles, selectedView, scatterViews]);
271
-
272
- const selectedViewDef = scatterViews.find(v => v.name === selectedView)?.view;
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
- responsive: true,
277
- maintainAspectRatio: false,
278
- scales: {
279
- x: { title: { display: !!selectedViewDef?.x.label, text: selectedViewDef?.x.label } },
280
- y: { position: 'left' as const,
281
- title: { display: true, text: leftAxisLabel(selectedViewDef) } },
282
- ...(usesRightAxis ? {
283
- y1: { position: 'right' as const,
284
- grid: { drawOnChartArea: false },
285
- title: { display: true, text: rightAxisLabel(selectedViewDef) } },
286
- } : {}),
287
- },
288
- plugins: {
289
- legend: { display: true },
290
- zoom: {
291
- pan: { enabled: true, mode: 'xy' as const },
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
- wheel: { enabled: true },
294
- pinch: { enabled: true },
295
- mode: 'xy' as const,
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
- }), [selectedViewDef, usesRightAxis]);
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={scatterViews.map(v => ({ label: v.view.title ?? v.name, value: v.name }))}
514
+ options={allViews.map(v => ({ label: v.view.title ?? v.name, value: v.name }))}
446
515
  onChange={(e) => setSelectedView(e.value)}
447
- placeholder={scatterViews.length === 0 ? 'No view defined' : 'Select a view'}
448
- disabled={scatterViews.length === 0}
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: 320 }}>
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(s => s.label ?? s.field).join(' / ') ?? '';
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(s => s.label ?? s.field).join(' / ') ?? '';
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 '';