@adcops/autocore-react 3.3.82 → 3.3.84

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 (30) hide show
  1. package/dist/components/ams/AssetDetailView.d.ts.map +1 -1
  2. package/dist/components/ams/AssetDetailView.js +1 -1
  3. package/dist/components/ams/AssetEditDialog.d.ts +13 -0
  4. package/dist/components/ams/AssetEditDialog.d.ts.map +1 -0
  5. package/dist/components/ams/AssetEditDialog.js +1 -0
  6. package/dist/components/ams/index.d.ts +1 -0
  7. package/dist/components/ams/index.d.ts.map +1 -1
  8. package/dist/components/ams/index.js +1 -1
  9. package/dist/components/network/NetworkPanel.d.ts.map +1 -1
  10. package/dist/components/network/NetworkPanel.js +1 -1
  11. package/dist/components/tis/TestDataView.d.ts +8 -0
  12. package/dist/components/tis/TestDataView.d.ts.map +1 -1
  13. package/dist/components/tis/TestDataView.js +1 -1
  14. package/dist/components/tis/TestRawDataView.d.ts.map +1 -1
  15. package/dist/components/tis/TestRawDataView.js +1 -1
  16. package/dist/components/tis/TestSetupForm.d.ts +15 -1
  17. package/dist/components/tis/TestSetupForm.d.ts.map +1 -1
  18. package/dist/components/tis/TestSetupForm.js +1 -1
  19. package/dist/components/tis/useRawCycleData.d.ts +39 -0
  20. package/dist/components/tis/useRawCycleData.d.ts.map +1 -0
  21. package/dist/components/tis/useRawCycleData.js +1 -0
  22. package/package.json +1 -1
  23. package/src/components/ams/AssetDetailView.tsx +31 -0
  24. package/src/components/ams/AssetEditDialog.tsx +463 -0
  25. package/src/components/ams/index.ts +1 -0
  26. package/src/components/network/NetworkPanel.tsx +13 -1
  27. package/src/components/tis/TestDataView.tsx +256 -84
  28. package/src/components/tis/TestRawDataView.tsx +15 -97
  29. package/src/components/tis/TestSetupForm.tsx +60 -6
  30. package/src/components/tis/useRawCycleData.ts +258 -0
@@ -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,
@@ -43,6 +60,11 @@ export interface TestFieldDef {
43
60
  units?: string;
44
61
  required?: boolean;
45
62
  source?: string;
63
+ /** Optional display-time scale multiplier. `display = raw * scale`,
64
+ * default 1.0 = no conversion. Cycle Data and Results panels
65
+ * apply this when formatting numeric cells. Charts plot raw
66
+ * values (axis labels already carry units). Storage stays raw. */
67
+ scale?: number;
46
68
  }
47
69
 
48
70
  export interface ChartAxis { field?: string; column?: string; label?: string; }
@@ -88,6 +110,9 @@ export interface TestDataViewProps {
88
110
  throttleMs?: number;
89
111
  /** Fixed cycle-table scroll height. Default "400px". */
90
112
  cycleTableHeight?: string;
113
+ /** Height of the unified chart panel (any CSS length). Default "320px".
114
+ * Set to e.g. "50vh" for a taller chart on a single-test page. */
115
+ chartHeight?: string;
91
116
  }
92
117
 
93
118
  // -------------------------------------------------------------------------
@@ -98,7 +123,7 @@ export const TestDataView: React.FC<TestDataViewProps> = (props) => {
98
123
  const methodId = props.methodId ?? tis.selection.methodId;
99
124
  const runId = props.runId ?? tis.selection.runId;
100
125
  const schema = props.schema ?? (methodId ? (tis.schemas[methodId] as TestMethod) : undefined);
101
- const { throttleMs = 100, cycleTableHeight = '400px' } = props;
126
+ const { throttleMs = 100, cycleTableHeight = '400px', chartHeight = '320px' } = props;
102
127
  const { invoke, subscribe, unsubscribe } = useContext(EventEmitterContext);
103
128
 
104
129
  const [meta, setMeta] = useState<any>(null);
@@ -107,6 +132,13 @@ export const TestDataView: React.FC<TestDataViewProps> = (props) => {
107
132
  const [rawOpen, setRawOpen] = useState(false);
108
133
  const [configOpen, setConfigOpen] = useState(false);
109
134
 
135
+ // Direct handle on the chart.js instance so the toolbar's reset-
136
+ // zoom button can call chart.resetZoom() — the zoom plugin's only
137
+ // imperative API. Customers like the wheel/pinch/drag zoom but
138
+ // can get lost in the chart with no obvious way back; the icon
139
+ // button in the chart's header row is the escape hatch.
140
+ const chartRef = useRef<any>(null);
141
+
110
142
  // Lazy-loaded blobs for the View Raw Data dialog. Fetched only
111
143
  // when the dialog opens, and re-fetched if the operator pins a
112
144
  // different run / cycle while the dialog is closed.
@@ -128,37 +160,42 @@ export const TestDataView: React.FC<TestDataViewProps> = (props) => {
128
160
  const [availableCycles, setAvailableCycles] = useState<number[]>([]);
129
161
  const [selectedCycle, setSelectedCycle] = useState<number | null>(null);
130
162
 
131
- // Scatter-capable views only raw_trace lives in <TestRawDataView>.
132
- const scatterViews = useMemo(() => {
163
+ // All views, any type, in declaration order. The dropdown lists
164
+ // every one; the chart area dispatches on `view.type` to render
165
+ // either a scatter or a raw_trace chart from the appropriate data.
166
+ const allViews = useMemo(() => {
133
167
  const out: { name: string; view: ChartView }[] = [];
134
168
  for (const [name, v] of Object.entries(schema?.views ?? {})) {
135
- if ((v as ChartView).type === 'cycle_scatter') out.push({ name, view: v as ChartView });
169
+ out.push({ name, view: v as ChartView });
136
170
  }
137
171
  return out;
138
172
  }, [schema]);
139
173
 
140
174
  const [selectedView, setSelectedView] = useState<string | null>(
141
- scatterViews.length > 0 ? scatterViews[0].name : null,
175
+ allViews.length > 0 ? allViews[0].name : null,
142
176
  );
143
177
 
144
178
  // Default to the first available view as soon as the schema loads.
145
179
  // The useState initializer above only runs on first mount, when
146
- // scatterViews is typically still empty (schema fetch in flight).
180
+ // allViews is typically still empty (schema fetch in flight).
147
181
  // Without this effect, selectedView stays null and the chart
148
182
  // stays blank until the operator opens the dropdown — almost
149
183
  // nobody does, so they email asking why the chart is broken.
150
184
  //
151
185
  // 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.
186
+ // currently-selected view is no longer in allViews — falls back
187
+ // to the new first view rather than rendering nothing.
154
188
  useEffect(() => {
155
- if (scatterViews.length === 0) return;
189
+ if (allViews.length === 0) return;
156
190
  const stillValid = selectedView !== null
157
- && scatterViews.some(v => v.name === selectedView);
191
+ && allViews.some(v => v.name === selectedView);
158
192
  if (!stillValid) {
159
- setSelectedView(scatterViews[0].name);
193
+ setSelectedView(allViews[0].name);
160
194
  }
161
- }, [scatterViews, selectedView]);
195
+ }, [allViews, selectedView]);
196
+
197
+ const selectedViewDef = allViews.find(v => v.name === selectedView)?.view;
198
+ const isRawTraceView = selectedViewDef?.type === 'raw_trace';
162
199
 
163
200
  // Pending updates coalesced by a throttle window — keeps React
164
201
  // re-renders at <= 1 / throttleMs even if cycles stream faster.
@@ -245,58 +282,102 @@ export const TestDataView: React.FC<TestDataViewProps> = (props) => {
245
282
  }, [projectId, methodId, runId, throttleMs]);
246
283
 
247
284
  // -----------------------------------------------------------------
248
- // Chart data
285
+ // Raw-trace data fetch (lazy)
286
+ //
287
+ // Only ever pulls a blob when the active chart view is a raw_trace.
288
+ // Switching to a cycle_scatter view leaves the previously-loaded
289
+ // raw state alone (still in memory but unused), so flipping back
290
+ // and forth is instant after the first fetch.
291
+ // -----------------------------------------------------------------
292
+ const traceBlobName = schema?.raw_data?.blob_name ?? 'trace';
293
+ const traceFetch = useRawCycleData({
294
+ projectId, methodId, runId,
295
+ blobName: traceBlobName,
296
+ enabled: isRawTraceView,
297
+ });
298
+
299
+ // -----------------------------------------------------------------
300
+ // Chart data — dispatches on view.type so one panel handles both
301
+ // shapes. Returns null when the active view's input data isn't
302
+ // ready (e.g., raw blob still loading); the render block treats
303
+ // null as "show overlay instead of an empty chart."
249
304
  // -----------------------------------------------------------------
250
305
  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;
306
+ if (!selectedViewDef) return null;
307
+ if (selectedViewDef.type === 'cycle_scatter') {
308
+ const xField = selectedViewDef.x.field;
309
+ if (!xField) return null;
310
+ const asc = [...cycles].reverse(); // state is newest-first; charts want oldest-first
311
+ const xs = asc.map(c => c[xField]);
312
+ const datasets = selectedViewDef.y.map((s, idx) => ({
313
+ label: s.label ?? s.field,
314
+ data: asc.map(c => c[s.field!]),
315
+ yAxisID: s.y_axis === 'right' ? 'y1' : 'y',
316
+ borderColor: palette(idx),
317
+ backgroundColor: palette(idx),
318
+ tension: 0.1,
319
+ pointRadius: 2,
320
+ }));
321
+ return { labels: xs, datasets };
322
+ }
323
+ if (selectedViewDef.type === 'raw_trace') {
324
+ if (!traceFetch.raw) return null;
325
+ const xCol = selectedViewDef.x.column;
326
+ if (!xCol) return null;
327
+ const xs = traceFetch.raw[xCol] ?? [];
328
+ const datasets = selectedViewDef.y.map((s, idx) => ({
329
+ label: s.label ?? s.column,
330
+ data: (traceFetch.raw![s.column!] ?? []).map((y, i) => ({ x: xs[i], y })),
331
+ yAxisID: s.y_axis === 'right' ? 'y1' : 'y',
332
+ borderColor: palette(idx),
333
+ backgroundColor: palette(idx),
334
+ pointRadius: 0,
335
+ borderWidth: 1.5,
336
+ showLine: true,
337
+ }));
338
+ return { datasets };
339
+ }
340
+ return null;
341
+ }, [selectedViewDef, cycles, traceFetch.raw]);
342
+
273
343
  const usesRightAxis = selectedViewDef?.y.some(s => s.y_axis === 'right') ?? false;
274
344
 
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 },
345
+ const chartOptions = useMemo(() => {
346
+ const isTrace = selectedViewDef?.type === 'raw_trace';
347
+ return {
348
+ responsive: true,
349
+ maintainAspectRatio: false,
350
+ // raw_trace datasets are pre-built `{x, y}` points so
351
+ // chart.js shouldn't try to parse them; cycle_scatter uses
352
+ // the `labels` + per-dataset `data: number[]` shape and
353
+ // needs default parsing on.
354
+ parsing: isTrace ? (false as const) : undefined,
355
+ scales: {
356
+ x: isTrace
357
+ ? { type: 'linear' as const,
358
+ title: { display: !!selectedViewDef?.x.label, text: selectedViewDef?.x.label } }
359
+ : { title: { display: !!selectedViewDef?.x.label, text: selectedViewDef?.x.label } },
360
+ y: { position: 'left' as const,
361
+ title: { display: true, text: leftAxisLabel(selectedViewDef) } },
362
+ ...(usesRightAxis ? {
363
+ y1: { position: 'right' as const,
364
+ grid: { drawOnChartArea: false },
365
+ title: { display: true, text: rightAxisLabel(selectedViewDef) } },
366
+ } : {}),
367
+ },
368
+ plugins: {
369
+ legend: { display: true },
292
370
  zoom: {
293
- wheel: { enabled: true },
294
- pinch: { enabled: true },
295
- mode: 'xy' as const,
371
+ pan: { enabled: true, mode: 'xy' as const },
372
+ zoom: {
373
+ wheel: { enabled: true },
374
+ pinch: { enabled: true },
375
+ mode: 'xy' as const,
376
+ },
296
377
  },
297
378
  },
298
- },
299
- }), [selectedViewDef, usesRightAxis]);
379
+ };
380
+ }, [selectedViewDef, usesRightAxis]);
300
381
 
301
382
  // -----------------------------------------------------------------
302
383
  // View Raw Data dialog: lazy-fetch raw + filtered blobs the first
@@ -439,36 +520,92 @@ export const TestDataView: React.FC<TestDataViewProps> = (props) => {
439
520
  affordance instead of silence. The dropdown is disabled
440
521
  in that case; the chart area renders empty. */}
441
522
  <div className="p-card" style={{ padding: '1rem' }}>
442
- <div className="flex" style={{ gap: '1rem', alignItems: 'center', marginBottom: '0.5rem' }}>
523
+ <div className="flex" style={{ gap: '1rem', alignItems: 'center', marginBottom: '0.5rem', flexWrap: 'wrap' }}>
443
524
  <Dropdown
444
525
  value={selectedView}
445
- options={scatterViews.map(v => ({ label: v.view.title ?? v.name, value: v.name }))}
526
+ options={allViews.map(v => ({ label: v.view.title ?? v.name, value: v.name }))}
446
527
  onChange={(e) => setSelectedView(e.value)}
447
- placeholder={scatterViews.length === 0 ? 'No view defined' : 'Select a view'}
448
- disabled={scatterViews.length === 0}
528
+ placeholder={allViews.length === 0 ? 'No view defined' : 'Select a view'}
529
+ disabled={allViews.length === 0}
449
530
  />
450
531
  <h3 style={{ margin: 0 }}>{selectedViewDef?.title ?? ''}</h3>
532
+ {/* Cycle picker — only visible for raw_trace views
533
+ AND when more than one cycle exists. The hook
534
+ handles the cycle-list discovery and "default
535
+ to latest" behaviour so this stays declarative. */}
536
+ {isRawTraceView && traceFetch.cycles.length > 1 && (
537
+ <>
538
+ <label htmlFor="chart-cycle-picker"
539
+ style={{ color: 'var(--text-secondary-color)' }}>Cycle:</label>
540
+ <Dropdown
541
+ inputId="chart-cycle-picker"
542
+ value={traceFetch.selectedCycle}
543
+ options={traceFetch.cycles.map(c => ({ label: `Cycle ${c}`, value: c }))}
544
+ onChange={(e) => traceFetch.setSelectedCycle(Number(e.value))}
545
+ style={{ minWidth: '8rem' }}
546
+ />
547
+ <span style={{ color: 'var(--text-secondary-color)' }}>
548
+ of {traceFetch.cycles.length}
549
+ </span>
550
+ </>
551
+ )}
552
+ {/* Spacer pushes the reset-zoom button to the right
553
+ edge of the row, opposite the dropdown. The
554
+ pi-th-large icon mirrors a "view all" / "fit"
555
+ affordance — the chart.js zoom plugin calls
556
+ this resetZoom and we wire it on the chartRef
557
+ below. Customers wanted the affordance because
558
+ the chart's wheel/pinch zoom is easy to lose
559
+ track of mid-test. */}
560
+ <div style={{ flex: 1 }} />
561
+ <Button
562
+ icon="pi pi-th-large"
563
+ outlined
564
+ rounded
565
+ size="small"
566
+ onClick={() => chartRef.current?.resetZoom?.()}
567
+ disabled={!chartData}
568
+ tooltip="Reset chart zoom"
569
+ tooltipOptions={{ position: 'left' }}
570
+ aria-label="Reset chart zoom"
571
+ />
451
572
  </div>
452
- <div style={{ height: 320 }}>
453
- {chartData && <Line data={chartData} options={chartOptions} />}
573
+ <div style={{ height: chartHeight, position: 'relative' }}>
574
+ {isRawTraceView && traceFetch.loading &&
575
+ <ChartOverlay>Loading raw data…</ChartOverlay>}
576
+ {isRawTraceView && traceFetch.error &&
577
+ <ChartOverlay>{traceFetch.error}</ChartOverlay>}
578
+ {chartData && <Line ref={chartRef} data={chartData} options={chartOptions} />}
454
579
  </div>
455
580
  </div>
456
581
 
457
582
  <div className="p-card" style={{ padding: '1rem' }}>
458
583
  <h3 style={{ marginTop: 0 }}>Cycle Data ({cycles.length})</h3>
459
- <DataTable
460
- value={cycles}
461
- scrollable
462
- scrollHeight={cycleTableHeight}
463
- virtualScrollerOptions={{ itemSize: 38 }}
464
- emptyMessage="No cycles yet."
465
- >
466
- {schema.cycle_fields.map(f => (
467
- <Column key={f.name} field={f.name}
468
- header={f.units ? `${f.name} (${f.units})` : f.name}
469
- body={(row) => formatCell(row[f.name], f.type)} />
470
- ))}
471
- </DataTable>
584
+ {/* Size-to-content for small runs (≤ CYCLE_VIRTUAL_THRESHOLD
585
+ rows) so the Results panel below isn't pushed off-screen
586
+ on tests with one or two cycles. Larger runs flip to
587
+ virtual scrolling against `cycleTableHeight` so a
588
+ 1000-cycle test doesn't render 1000 row elements at
589
+ once. Operators on test methods that always run a
590
+ fixed-size short cycle list see no scrollbar at all. */}
591
+ {(() => {
592
+ const useVirtual = cycles.length > CYCLE_VIRTUAL_THRESHOLD;
593
+ return (
594
+ <DataTable
595
+ value={cycles}
596
+ scrollable={useVirtual}
597
+ scrollHeight={useVirtual ? cycleTableHeight : undefined}
598
+ virtualScrollerOptions={useVirtual ? { itemSize: 38 } : undefined}
599
+ emptyMessage="No cycles yet."
600
+ >
601
+ {schema.cycle_fields.map(f => (
602
+ <Column key={f.name} field={f.name}
603
+ header={f.units ? `${f.name} (${f.units})` : f.name}
604
+ body={(row) => formatCell(row[f.name], f.type, f.scale)} />
605
+ ))}
606
+ </DataTable>
607
+ );
608
+ })()}
472
609
  </div>
473
610
 
474
611
  <div className="p-card" style={{ padding: '1rem' }}>
@@ -851,26 +988,61 @@ const ResultsGrid: React.FC<{ schema: TestFieldDef[]; values: any }> = ({ schema
851
988
  <div style={{ fontSize: '0.8em', color: 'var(--text-secondary-color)' }}>
852
989
  {f.name}{f.units ? ` (${f.units})` : ''}
853
990
  </div>
854
- <div>{formatCell(values[f.name], f.type)}</div>
991
+ <div>{formatCell(values[f.name], f.type, f.scale)}</div>
855
992
  </div>
856
993
  ))}
857
994
  </div>
858
995
  );
859
996
  };
860
997
 
998
+ /** Row count above which the Cycle Data table switches into virtual-
999
+ * scroll mode with a fixed `cycleTableHeight`. At or below the
1000
+ * threshold the table sizes to its content so the Results panel
1001
+ * below doesn't get pushed off-screen on short runs. 30 is enough to
1002
+ * comfortably show a typical hand-tuned test session without
1003
+ * scrolling but small enough that virtualization kicks in well
1004
+ * before performance becomes a concern. */
1005
+ const CYCLE_VIRTUAL_THRESHOLD = 30;
1006
+
861
1007
  const CHART_COLORS = [
862
1008
  '#4ea8de', '#f59e0b', '#22c55e', '#a855f7',
863
1009
  '#ef4444', '#14b8a6', '#eab308', '#ec4899',
864
1010
  ];
865
1011
  const palette = (i: number) => CHART_COLORS[i % CHART_COLORS.length];
866
1012
 
1013
+ // Loading / error wash drawn over the chart area while a raw_trace
1014
+ // fetch is in flight. Centered, pointer-events-none so the operator
1015
+ // can still interact with the dropdown above.
1016
+ const ChartOverlay: React.FC<{ children: React.ReactNode }> = ({ children }) => (
1017
+ <div style={{ position: 'absolute', inset: 0, display: 'flex',
1018
+ alignItems: 'center', justifyContent: 'center',
1019
+ color: 'var(--text-secondary-color)', pointerEvents: 'none' }}>
1020
+ {children}
1021
+ </div>
1022
+ );
1023
+
1024
+ // Axis labels work for both scatter (s.field) and raw_trace (s.column)
1025
+ // series. Whichever the view declared, that's what's used as a label
1026
+ // fallback when the explicit `label` is absent.
1027
+ const seriesLabel = (s: ChartSeries) => s.label ?? s.field ?? s.column ?? '';
867
1028
  const leftAxisLabel = (v?: ChartView) =>
868
- v?.y.filter(s => s.y_axis !== 'right').map(s => s.label ?? s.field).join(' / ') ?? '';
1029
+ v?.y.filter(s => s.y_axis !== 'right').map(seriesLabel).join(' / ') ?? '';
869
1030
  const rightAxisLabel = (v?: ChartView) =>
870
- v?.y.filter(s => s.y_axis === 'right').map(s => s.label ?? s.field).join(' / ') ?? '';
1031
+ v?.y.filter(s => s.y_axis === 'right').map(seriesLabel).join(' / ') ?? '';
871
1032
 
872
- const formatCell = (v: any, type: string): string => {
1033
+ /**
1034
+ * Format one value for a cycle / results / config cell.
1035
+ *
1036
+ * `scale`, when present and != 1, is applied to numeric values so the
1037
+ * cell displays in operator units while storage stays raw. Mirrors
1038
+ * the convention used by AutoCoreTagContext: `display = raw * scale`.
1039
+ * Non-numeric values pass through unchanged.
1040
+ */
1041
+ const formatCell = (v: any, type: string, scale?: number): string => {
873
1042
  if (v === null || v === undefined) return '';
1043
+ if (scale && scale !== 1 && typeof v === 'number' && Number.isFinite(v)) {
1044
+ v = v * scale;
1045
+ }
874
1046
  if (type === 'f32' || type === 'f64') {
875
1047
  return typeof v === 'number' ? v.toFixed(4) : String(v);
876
1048
  }
@@ -10,7 +10,7 @@
10
10
  * built-in dialog inside <TestDataView>.
11
11
  */
12
12
 
13
- import React, { useContext, useEffect, useMemo, useRef, useState } from 'react';
13
+ import React, { useMemo, useRef, useState } from 'react';
14
14
  import { Button } from 'primereact/button';
15
15
  import { Dropdown } from 'primereact/dropdown';
16
16
 
@@ -21,10 +21,9 @@ import { Chart as ChartJS,
21
21
  import zoomPlugin from 'chartjs-plugin-zoom';
22
22
  import { Line } from 'react-chartjs-2';
23
23
 
24
- import { EventEmitterContext } from '../../core/EventEmitterContext';
25
- import { MessageType } from '../../hub/CommandMessage';
26
24
  import type { ChartView, TestMethod } from './TestDataView';
27
25
  import { useTis } from './TisProvider';
26
+ import { useRawCycleData } from './useRawCycleData';
28
27
 
29
28
  ChartJS.register(
30
29
  CategoryScale, LinearScale, PointElement, LineElement,
@@ -53,19 +52,8 @@ export const TestRawDataView: React.FC<TestRawDataViewProps> = (props) => {
53
52
  const runId = props.runId ?? tis.selection.runId;
54
53
  const schema = props.schema ?? (methodId ? (tis.schemas[methodId] as TestMethod) : undefined);
55
54
  const { blobName, chartHeight = '60vh' } = props;
56
- const { invoke } = useContext(EventEmitterContext);
57
-
58
- const [raw, setRaw] = useState<Record<string, number[]> | null>(null);
59
- const [envelope, setEnvelope] = useState<any | null>(null);
60
- const [loading, setLoading] = useState(true);
61
- const [error, setError] = useState<string | null>(null);
62
55
  const chartRef = useRef<any>(null);
63
56
 
64
- // Per-test cycle picker. Default to latest cycle on disk; the
65
- // operator can flip backward through earlier cycles.
66
- const [cycles, setCycles] = useState<number[]>([]);
67
- const [selectedCycle, setSelectedCycle] = useState<number | null>(null);
68
-
69
57
  // raw_trace-capable views only — cycle scatter lives in <TestDataView>.
70
58
  const traceViews = useMemo(() => {
71
59
  const out: { name: string; view: ChartView }[] = [];
@@ -81,75 +69,19 @@ export const TestRawDataView: React.FC<TestRawDataViewProps> = (props) => {
81
69
 
82
70
  const effectiveBlobName = blobName ?? schema?.raw_data?.blob_name ?? 'trace';
83
71
 
84
- // Reset cycle state when the run identity changes; the new run has
85
- // its own cycle list and "latest" target.
86
- useEffect(() => {
87
- setCycles([]);
88
- setSelectedCycle(null);
89
- }, [projectId, methodId, runId, effectiveBlobName]);
90
-
91
- // Discover available cycles for this run/blob. Runs ahead of the
92
- // data fetch so the cycle picker can render immediately.
93
- useEffect(() => {
94
- if (!projectId || !methodId || !runId) return;
95
- let cancelled = false;
96
- (async () => {
97
- try {
98
- const resp: any = await invoke(
99
- 'tis.list_raw' as any, MessageType.Request as any,
100
- { project_id: projectId, method_id: methodId, run_id: runId } as any);
101
- if (cancelled || !resp?.success) return;
102
- const list: any[] = resp.data?.cycles ?? [];
103
- const indices = list
104
- .filter(c => c?.name === effectiveBlobName && typeof c?.cycle_index === 'number')
105
- .map(c => c.cycle_index as number)
106
- .sort((a, b) => a - b);
107
- setCycles(indices);
108
- if (indices.length > 0) {
109
- setSelectedCycle(prev => prev ?? indices[indices.length - 1]);
110
- }
111
- } catch {
112
- // Listing failure is non-fatal — the data fetch below
113
- // still tries "latest" and the picker just stays empty.
114
- }
115
- })();
116
- return () => { cancelled = true; };
117
- }, [projectId, methodId, runId, effectiveBlobName, invoke]);
118
-
119
- // Lazy fetch — runs on mount / when identifiers / selectedCycle change.
120
- useEffect(() => {
121
- if (!projectId || !methodId || !runId) {
122
- setRaw(null); setEnvelope(null); setLoading(false); setError(null);
123
- return;
124
- }
125
- let cancelled = false;
126
- setLoading(true);
127
- setError(null);
128
- (async () => {
129
- try {
130
- const args: Record<string, any> = {
131
- project_id: projectId, method_id: methodId,
132
- run_id: runId, name: effectiveBlobName,
133
- };
134
- if (selectedCycle != null) args.cycle_index = selectedCycle;
135
- const resp: any = await invoke(
136
- 'tis.read_raw' as any, MessageType.Request as any, args as any);
137
- if (cancelled) return;
138
- if (resp?.success) {
139
- const payload = resp.data ?? {};
140
- setEnvelope(payload);
141
- setRaw(unwrapEnvelope(payload));
142
- } else {
143
- setError(resp?.error_message ?? 'Failed to read raw data');
144
- }
145
- } catch (e: any) {
146
- if (!cancelled) setError(String(e?.message ?? e));
147
- } finally {
148
- if (!cancelled) setLoading(false);
149
- }
150
- })();
151
- return () => { cancelled = true; };
152
- }, [projectId, methodId, runId, effectiveBlobName, selectedCycle, invoke]);
72
+ // Cycle discovery + per-cycle blob fetch live in the shared hook so
73
+ // <TestDataView>'s unified panel can reuse the exact same fetch path
74
+ // when a raw_trace view is selected there.
75
+ const { cycles, selectedCycle, setSelectedCycle, raw, envelope, loading, error } =
76
+ useRawCycleData({
77
+ projectId, methodId, runId,
78
+ blobName: effectiveBlobName,
79
+ // This component only renders raw_trace charts, so always
80
+ // fetch as long as the run identity is in scope. The early
81
+ // returns below cover the "no test selected" / "no schema"
82
+ // cases that would otherwise be wasted requests.
83
+ enabled: !!projectId && !!methodId && !!runId,
84
+ });
153
85
 
154
86
  const chartData = useMemo(() => {
155
87
  if (!raw || !selectedView) return null;
@@ -279,20 +211,6 @@ const EmptyState: React.FC<{ message: string }> = ({ message }) => (
279
211
  <div style={{ padding: '1rem', color: 'var(--text-secondary-color)' }}>{message}</div>
280
212
  );
281
213
 
282
- // tis.read_raw returns one of:
283
- // - per-cycle envelope: { cycle_index, cycle_fields, context, data }
284
- // - legacy flat blob: { col: number[] }
285
- // Chart/CSV code only wants the columnar payload — strip the envelope
286
- // when present, otherwise pass the blob through.
287
- const unwrapEnvelope = (blob: any): Record<string, number[]> => {
288
- if (!blob || typeof blob !== 'object') return {};
289
- if ('data' in blob && blob.data && typeof blob.data === 'object'
290
- && Object.values(blob.data).some(v => Array.isArray(v))) {
291
- return blob.data;
292
- }
293
- return blob;
294
- };
295
-
296
214
  // Strip of cycle_index + cycle_fields + context, rendered above the
297
215
  // chart so the operator can see *which* cycle they're looking at and
298
216
  // what schema-declared metric values were active for it. Renders