@adcops/autocore-react 3.3.83 → 3.3.85
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/AssetDetailView.d.ts.map +1 -1
- package/dist/components/ams/AssetDetailView.js +1 -1
- package/dist/components/ams/AssetEditDialog.d.ts +13 -0
- package/dist/components/ams/AssetEditDialog.d.ts.map +1 -0
- package/dist/components/ams/AssetEditDialog.js +1 -0
- package/dist/components/ams/index.d.ts +1 -0
- package/dist/components/ams/index.d.ts.map +1 -1
- package/dist/components/ams/index.js +1 -1
- package/dist/components/network/NetworkPanel.d.ts.map +1 -1
- package/dist/components/network/NetworkPanel.js +1 -1
- package/dist/components/tis/TestDataView.d.ts +5 -0
- package/dist/components/tis/TestDataView.d.ts.map +1 -1
- package/dist/components/tis/TestDataView.js +1 -1
- package/dist/components/tis/TestSetupForm.d.ts +15 -1
- package/dist/components/tis/TestSetupForm.d.ts.map +1 -1
- package/dist/components/tis/TestSetupForm.js +1 -1
- package/dist/components/tis/useRawCycleData.d.ts.map +1 -1
- package/dist/components/tis/useRawCycleData.js +1 -1
- package/package.json +1 -1
- package/src/components/ams/AssetDetailView.tsx +31 -0
- package/src/components/ams/AssetEditDialog.tsx +800 -0
- package/src/components/ams/index.ts +1 -0
- package/src/components/network/NetworkPanel.tsx +13 -1
- package/src/components/tis/TestDataView.tsx +80 -16
- package/src/components/tis/TestSetupForm.tsx +60 -6
- package/src/components/tis/useRawCycleData.ts +132 -31
|
@@ -10,6 +10,7 @@ export type { AmsRole, AmsRoleRegistry } from './AmsProvider';
|
|
|
10
10
|
export { AssetRegistryTable } from './AssetRegistryTable';
|
|
11
11
|
export { AssetDetailView } from './AssetDetailView';
|
|
12
12
|
export { CalibrationEntryDialog } from './CalibrationEntryDialog';
|
|
13
|
+
export { AssetEditDialog } from './AssetEditDialog';
|
|
13
14
|
export { SubLocationPicker } from './SubLocationPicker';
|
|
14
15
|
export { PlaceholderHealthPanel } from './PlaceholderHealthPanel';
|
|
15
16
|
export { MissingAssetsBanner } from './MissingAssetsBanner';
|
|
@@ -118,7 +118,19 @@ export const NetworkPanel: React.FC<NetworkPanelProps> = ({ className }) => {
|
|
|
118
118
|
}}>
|
|
119
119
|
<div>
|
|
120
120
|
<h3 style={{ margin: 0 }}>Network</h3>
|
|
121
|
-
{
|
|
121
|
+
{/* Three states for the status line:
|
|
122
|
+
* - status hasn't loaded yet: "Loading…" — keeps
|
|
123
|
+
* the panel from flashing "No WiFi device" while
|
|
124
|
+
* the initial nw.list_interfaces is in flight.
|
|
125
|
+
* - status loaded, a wifi device exists: show its
|
|
126
|
+
* connection / state / IP.
|
|
127
|
+
* - status loaded, no wifi device: the genuine
|
|
128
|
+
* "No WiFi device detected" message. */}
|
|
129
|
+
{!net.statusLoaded ? (
|
|
130
|
+
<div style={{ fontSize: '0.875rem', color: '#9ca3af', marginTop: '0.25rem' }}>
|
|
131
|
+
Loading network status…
|
|
132
|
+
</div>
|
|
133
|
+
) : activeWifi ? (
|
|
122
134
|
<div style={{ fontSize: '0.875rem', color: '#9ca3af', marginTop: '0.25rem' }}>
|
|
123
135
|
{activeWifi.state === 'connected'
|
|
124
136
|
? <>Connected to <code>{activeWifi.connection || '(unnamed)'}</code> on <code>{activeWifi.device}</code></>
|
|
@@ -60,6 +60,11 @@ export interface TestFieldDef {
|
|
|
60
60
|
units?: string;
|
|
61
61
|
required?: boolean;
|
|
62
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;
|
|
63
68
|
}
|
|
64
69
|
|
|
65
70
|
export interface ChartAxis { field?: string; column?: string; label?: string; }
|
|
@@ -127,6 +132,13 @@ export const TestDataView: React.FC<TestDataViewProps> = (props) => {
|
|
|
127
132
|
const [rawOpen, setRawOpen] = useState(false);
|
|
128
133
|
const [configOpen, setConfigOpen] = useState(false);
|
|
129
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
|
+
|
|
130
142
|
// Lazy-loaded blobs for the View Raw Data dialog. Fetched only
|
|
131
143
|
// when the dialog opens, and re-fetched if the operator pins a
|
|
132
144
|
// different run / cycle while the dialog is closed.
|
|
@@ -537,31 +549,63 @@ export const TestDataView: React.FC<TestDataViewProps> = (props) => {
|
|
|
537
549
|
</span>
|
|
538
550
|
</>
|
|
539
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
|
+
/>
|
|
540
572
|
</div>
|
|
541
573
|
<div style={{ height: chartHeight, position: 'relative' }}>
|
|
542
574
|
{isRawTraceView && traceFetch.loading &&
|
|
543
575
|
<ChartOverlay>Loading raw data…</ChartOverlay>}
|
|
544
576
|
{isRawTraceView && traceFetch.error &&
|
|
545
577
|
<ChartOverlay>{traceFetch.error}</ChartOverlay>}
|
|
546
|
-
{chartData && <Line data={chartData} options={chartOptions} />}
|
|
578
|
+
{chartData && <Line ref={chartRef} data={chartData} options={chartOptions} />}
|
|
547
579
|
</div>
|
|
548
580
|
</div>
|
|
549
581
|
|
|
550
582
|
<div className="p-card" style={{ padding: '1rem' }}>
|
|
551
583
|
<h3 style={{ marginTop: 0 }}>Cycle Data ({cycles.length})</h3>
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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
|
+
})()}
|
|
565
609
|
</div>
|
|
566
610
|
|
|
567
611
|
<div className="p-card" style={{ padding: '1rem' }}>
|
|
@@ -944,13 +988,22 @@ const ResultsGrid: React.FC<{ schema: TestFieldDef[]; values: any }> = ({ schema
|
|
|
944
988
|
<div style={{ fontSize: '0.8em', color: 'var(--text-secondary-color)' }}>
|
|
945
989
|
{f.name}{f.units ? ` (${f.units})` : ''}
|
|
946
990
|
</div>
|
|
947
|
-
<div>{formatCell(values[f.name], f.type)}</div>
|
|
991
|
+
<div>{formatCell(values[f.name], f.type, f.scale)}</div>
|
|
948
992
|
</div>
|
|
949
993
|
))}
|
|
950
994
|
</div>
|
|
951
995
|
);
|
|
952
996
|
};
|
|
953
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
|
+
|
|
954
1007
|
const CHART_COLORS = [
|
|
955
1008
|
'#4ea8de', '#f59e0b', '#22c55e', '#a855f7',
|
|
956
1009
|
'#ef4444', '#14b8a6', '#eab308', '#ec4899',
|
|
@@ -977,8 +1030,19 @@ const leftAxisLabel = (v?: ChartView) =>
|
|
|
977
1030
|
const rightAxisLabel = (v?: ChartView) =>
|
|
978
1031
|
v?.y.filter(s => s.y_axis === 'right').map(seriesLabel).join(' / ') ?? '';
|
|
979
1032
|
|
|
980
|
-
|
|
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 => {
|
|
981
1042
|
if (v === null || v === undefined) return '';
|
|
1043
|
+
if (scale && scale !== 1 && typeof v === 'number' && Number.isFinite(v)) {
|
|
1044
|
+
v = v * scale;
|
|
1045
|
+
}
|
|
982
1046
|
if (type === 'f32' || type === 'f64') {
|
|
983
1047
|
return typeof v === 'number' ? v.toFixed(4) : String(v);
|
|
984
1048
|
}
|
|
@@ -43,8 +43,22 @@ export interface TestFieldDef {
|
|
|
43
43
|
* the control program sees it; for non-source fields it's stashed
|
|
44
44
|
* straight into stagedConfig. Operator edits override per-stage,
|
|
45
45
|
* but the schema default never mutates — re-selecting the method
|
|
46
|
-
* re-applies the default.
|
|
46
|
+
* re-applies the default. **Authored in display units** when
|
|
47
|
+
* `scale` is set — the form divides by `scale` before writing to
|
|
48
|
+
* GM / stagedConfig so the underlying storage stays raw. */
|
|
47
49
|
default?: any;
|
|
50
|
+
/** Optional unit-conversion multiplier. Mirrors AutoCoreTagContext:
|
|
51
|
+
* ```
|
|
52
|
+
* display = raw * scale
|
|
53
|
+
* raw = display / scale
|
|
54
|
+
* ```
|
|
55
|
+
* Example: backend stores degrees, operator enters revolutions →
|
|
56
|
+
* `scale: 0.00277778`. None / 1.0 = no conversion.
|
|
57
|
+
*
|
|
58
|
+
* Storage stays raw — the form scales only on input and display.
|
|
59
|
+
* Cycle and results values are scaled by the corresponding paths
|
|
60
|
+
* in TestDataView; the server scales CSV exports too. */
|
|
61
|
+
scale?: number;
|
|
48
62
|
}
|
|
49
63
|
|
|
50
64
|
export interface TestMethod {
|
|
@@ -104,6 +118,30 @@ const labelOf = (f: TestFieldDef): string => {
|
|
|
104
118
|
return f.units ? `${base} [${f.units}]` : base;
|
|
105
119
|
};
|
|
106
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Two helpers for the display ↔ raw boundary on numeric fields.
|
|
123
|
+
*
|
|
124
|
+
* Convention: `display = raw * scale`, `raw = display / scale`. Matches
|
|
125
|
+
* AutoCoreTagContext. Default scale = 1.0 (no conversion). Non-numeric
|
|
126
|
+
* input values (string, null, undefined, NaN) pass through unchanged
|
|
127
|
+
* — the form has fields of other types that share the change handler.
|
|
128
|
+
*
|
|
129
|
+
* Storage (config map, GM, on-disk JSON) always holds the raw value.
|
|
130
|
+
* Only the inputs the operator looks at and the cells in TestDataView
|
|
131
|
+
* use the display value. This way scales can change in project.json
|
|
132
|
+
* without invalidating historical records.
|
|
133
|
+
*/
|
|
134
|
+
const rawToDisplay = (raw: any, scale: number | undefined): any => {
|
|
135
|
+
if (!scale || scale === 1) return raw;
|
|
136
|
+
if (typeof raw !== 'number' || !Number.isFinite(raw)) return raw;
|
|
137
|
+
return raw * scale;
|
|
138
|
+
};
|
|
139
|
+
const displayToRaw = (display: any, scale: number | undefined): any => {
|
|
140
|
+
if (!scale || scale === 1) return display;
|
|
141
|
+
if (typeof display !== 'number' || !Number.isFinite(display)) return display;
|
|
142
|
+
return display / scale;
|
|
143
|
+
};
|
|
144
|
+
|
|
107
145
|
const hasDescription = (f: TestFieldDef): boolean =>
|
|
108
146
|
typeof f.description === 'string' && f.description.length > 0;
|
|
109
147
|
|
|
@@ -265,14 +303,20 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
|
|
|
265
303
|
if (field.name === 'sample_id') continue;
|
|
266
304
|
if (field.default === undefined || field.default === null) continue;
|
|
267
305
|
if (next === prev) next = { ...prev };
|
|
268
|
-
|
|
306
|
+
// Schema defaults are authored in DISPLAY units (per
|
|
307
|
+
// the agreed convention) so the value the author reads
|
|
308
|
+
// in project.json matches the field's `units` label.
|
|
309
|
+
// Convert to raw before storing in stagedConfig / GM
|
|
310
|
+
// so the rest of the pipeline sees the canonical value.
|
|
311
|
+
const rawDefault = displayToRaw(field.default, field.scale);
|
|
312
|
+
next[field.name] = rawDefault;
|
|
269
313
|
if (field.source) {
|
|
270
314
|
// Mirror handleFieldChange: write to GM so the
|
|
271
315
|
// control program sees the default. Errors here are
|
|
272
316
|
// logged but non-fatal — the form still reflects
|
|
273
317
|
// the default locally.
|
|
274
318
|
void Promise.resolve()
|
|
275
|
-
.then(() => write(field.source!,
|
|
319
|
+
.then(() => write(field.source!, rawDefault))
|
|
276
320
|
.catch(e => console.error(
|
|
277
321
|
`[TestSetupForm] Failed to seed default for ${field.name}:`, e));
|
|
278
322
|
}
|
|
@@ -360,9 +404,13 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
|
|
|
360
404
|
};
|
|
361
405
|
|
|
362
406
|
const handleFieldChange = async (field: TestFieldDef, val: any) => {
|
|
363
|
-
|
|
407
|
+
// The operator typed a DISPLAY value; convert to RAW before
|
|
408
|
+
// storing or writing to GM. Non-numeric fields and identity
|
|
409
|
+
// scales pass through unchanged via the helper.
|
|
410
|
+
const rawVal = displayToRaw(val, field.scale);
|
|
411
|
+
setConfig({ ...config, [field.name]: rawVal });
|
|
364
412
|
if (field.source) {
|
|
365
|
-
try { await write(field.source,
|
|
413
|
+
try { await write(field.source, rawVal); }
|
|
366
414
|
catch (e) { console.error('Failed to write to source:', e); }
|
|
367
415
|
}
|
|
368
416
|
};
|
|
@@ -417,7 +465,13 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
|
|
|
417
465
|
) : isNum ? (
|
|
418
466
|
<ValueInput
|
|
419
467
|
label={undefined}
|
|
420
|
-
|
|
468
|
+
// Storage is RAW; render the DISPLAY value so
|
|
469
|
+
// operator sees the units they configured. The
|
|
470
|
+
// change handler runs the inverse before
|
|
471
|
+
// committing back to storage.
|
|
472
|
+
value={config[field.name] != null
|
|
473
|
+
? Number(rawToDisplay(Number(config[field.name]), field.scale))
|
|
474
|
+
: null}
|
|
421
475
|
onValueChanged={(val) => handleFieldChange(field, val)}
|
|
422
476
|
className={!valid ? 'p-invalid' : ''}
|
|
423
477
|
/>
|
|
@@ -11,12 +11,27 @@
|
|
|
11
11
|
* (set `enabled` to false to short-circuit while
|
|
12
12
|
* a cycle_scatter view is active).
|
|
13
13
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* Live updates
|
|
15
|
+
* ------------
|
|
16
|
+
* The hook subscribes to the `tis.raw_data_added` broadcast and refreshes
|
|
17
|
+
* the cycle list whenever a new raw blob lands on disk for the matching
|
|
18
|
+
* (project, method, run, blob). When the operator is already viewing the
|
|
19
|
+
* latest cycle, the picker advances to the new latest and the chart
|
|
20
|
+
* re-paints — that's the customer-facing "chart updates each cycle"
|
|
21
|
+
* behaviour. When the operator has manually pinned an earlier cycle (e.g.
|
|
22
|
+
* examining cycle 3 while cycle 47 streams in), the picker stays put so
|
|
23
|
+
* the examination isn't interrupted.
|
|
24
|
+
*
|
|
25
|
+
* The hook intentionally suppresses `read_raw` until at least one cycle
|
|
26
|
+
* is known to be on disk for this slice. Otherwise the server returns
|
|
27
|
+
* "no raw_data file found" between `tis.start_test` and the first
|
|
28
|
+
* `add_raw_data` write, and the chart flashes an error overlay during
|
|
29
|
+
* what's really just "test just started, no data yet" — confusing for
|
|
30
|
+
* the operator. With the suppression the chart sits empty (or in a
|
|
31
|
+
* loading state) until the first raw_data_added broadcast arrives.
|
|
17
32
|
*/
|
|
18
33
|
|
|
19
|
-
import { useContext, useEffect, useState } from 'react';
|
|
34
|
+
import { useCallback, useContext, useEffect, useRef, useState } from 'react';
|
|
20
35
|
import { EventEmitterContext } from '../../core/EventEmitterContext';
|
|
21
36
|
import { MessageType } from '../../hub/CommandMessage';
|
|
22
37
|
|
|
@@ -50,7 +65,7 @@ export interface UseRawCycleDataResult {
|
|
|
50
65
|
|
|
51
66
|
export function useRawCycleData(opts: UseRawCycleDataOptions): UseRawCycleDataResult {
|
|
52
67
|
const { projectId, methodId, runId, blobName, enabled } = opts;
|
|
53
|
-
const { invoke } = useContext(EventEmitterContext);
|
|
68
|
+
const { invoke, subscribe, unsubscribe } = useContext(EventEmitterContext);
|
|
54
69
|
|
|
55
70
|
const [cycles, setCycles] = useState<number[]>([]);
|
|
56
71
|
const [selectedCycle, setSelectedCycle] = useState<number | null>(null);
|
|
@@ -59,14 +74,49 @@ export function useRawCycleData(opts: UseRawCycleDataOptions): UseRawCycleDataRe
|
|
|
59
74
|
const [loading, setLoading] = useState(false);
|
|
60
75
|
const [error, setError] = useState<string | null>(null);
|
|
61
76
|
|
|
77
|
+
// Track whether the user has explicitly pinned a cycle. When false,
|
|
78
|
+
// the live-follow path is free to advance the picker to the newest
|
|
79
|
+
// cycle as it arrives. Flipping to true on a manual pick freezes
|
|
80
|
+
// the picker so examining cycle 3 while cycle 47 streams in doesn't
|
|
81
|
+
// get yanked back to 47.
|
|
82
|
+
//
|
|
83
|
+
// Lives in a ref so updating it doesn't re-trigger any effect — it's
|
|
84
|
+
// a behavioural latch, not state the renderer cares about.
|
|
85
|
+
const userPinnedRef = useRef<boolean>(false);
|
|
86
|
+
|
|
87
|
+
/** Discover the per-cycle indices currently on disk for this slice.
|
|
88
|
+
* Returns the sorted-ascending list; never throws (a listing error
|
|
89
|
+
* is logged-as-empty rather than surfaced — the rendering layer
|
|
90
|
+
* has its own empty state). */
|
|
91
|
+
const listCycles = useCallback(async (): Promise<number[]> => {
|
|
92
|
+
if (!projectId || !methodId || !runId) return [];
|
|
93
|
+
try {
|
|
94
|
+
const resp: any = await invoke(
|
|
95
|
+
'tis.list_raw' as any, MessageType.Request as any,
|
|
96
|
+
{ project_id: projectId, method_id: methodId, run_id: runId } as any,
|
|
97
|
+
);
|
|
98
|
+
if (!resp?.success) return [];
|
|
99
|
+
const list: any[] = resp.data?.cycles ?? [];
|
|
100
|
+
return list
|
|
101
|
+
.filter(c => c?.name === blobName && typeof c?.cycle_index === 'number')
|
|
102
|
+
.map(c => c.cycle_index as number)
|
|
103
|
+
.sort((a, b) => a - b);
|
|
104
|
+
} catch {
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
}, [projectId, methodId, runId, blobName, invoke]);
|
|
108
|
+
|
|
62
109
|
// Reset cycle state when the run identity (or blob) changes — the
|
|
63
110
|
// new slice has its own cycle list and its own "latest" target.
|
|
111
|
+
// Clearing userPinnedRef too so the next slice is free to follow
|
|
112
|
+
// the latest cycle automatically.
|
|
64
113
|
useEffect(() => {
|
|
65
114
|
setCycles([]);
|
|
66
115
|
setSelectedCycle(null);
|
|
67
116
|
setRaw(null);
|
|
68
117
|
setEnvelope(null);
|
|
69
118
|
setError(null);
|
|
119
|
+
userPinnedRef.current = false;
|
|
70
120
|
}, [projectId, methodId, runId, blobName]);
|
|
71
121
|
|
|
72
122
|
// Cycle-list discovery. Runs ahead of the data fetch so the cycle
|
|
@@ -75,47 +125,86 @@ export function useRawCycleData(opts: UseRawCycleDataOptions): UseRawCycleDataRe
|
|
|
75
125
|
if (!enabled || !projectId || !methodId || !runId) return;
|
|
76
126
|
let cancelled = false;
|
|
77
127
|
(async () => {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
);
|
|
83
|
-
if (cancelled || !resp?.success) return;
|
|
84
|
-
const list: any[] = resp.data?.cycles ?? [];
|
|
85
|
-
const indices = list
|
|
86
|
-
.filter(c => c?.name === blobName && typeof c?.cycle_index === 'number')
|
|
87
|
-
.map(c => c.cycle_index as number)
|
|
88
|
-
.sort((a, b) => a - b);
|
|
89
|
-
setCycles(indices);
|
|
90
|
-
if (indices.length > 0) {
|
|
91
|
-
setSelectedCycle(prev => prev ?? indices[indices.length - 1]);
|
|
92
|
-
}
|
|
93
|
-
} catch {
|
|
94
|
-
// Listing failure is non-fatal — the data fetch below
|
|
95
|
-
// still tries "latest" and the picker just stays empty.
|
|
128
|
+
const indices = await listCycles();
|
|
129
|
+
if (cancelled) return;
|
|
130
|
+
setCycles(indices);
|
|
131
|
+
if (indices.length > 0) {
|
|
132
|
+
setSelectedCycle(prev => prev ?? indices[indices.length - 1]);
|
|
96
133
|
}
|
|
97
134
|
})();
|
|
98
135
|
return () => { cancelled = true; };
|
|
99
|
-
}, [enabled, projectId, methodId, runId, blobName,
|
|
136
|
+
}, [enabled, projectId, methodId, runId, blobName, listCycles]);
|
|
137
|
+
|
|
138
|
+
// Live updates: each new raw_data file landing on disk triggers a
|
|
139
|
+
// re-listing. The handler closes over `selectedCycle` via the
|
|
140
|
+
// functional setter and the live `cycles` snapshot, so we use refs
|
|
141
|
+
// to avoid re-subscribing every time those state values change.
|
|
142
|
+
const cyclesRef = useRef<number[]>(cycles);
|
|
143
|
+
cyclesRef.current = cycles;
|
|
144
|
+
const selectedRef = useRef<number | null>(selectedCycle);
|
|
145
|
+
selectedRef.current = selectedCycle;
|
|
146
|
+
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
if (!enabled || !projectId || !methodId || !runId) return;
|
|
149
|
+
const onRawAdded = async (payload: any) => {
|
|
150
|
+
// Filter to the slice this hook instance is bound to.
|
|
151
|
+
// The server can also fire raw_data_added for the same
|
|
152
|
+
// run+method+project but a different blob name (rare, but
|
|
153
|
+
// legal); the blob-name check keeps us from refreshing
|
|
154
|
+
// for an unrelated dataset.
|
|
155
|
+
if (payload?.project_id !== projectId) return;
|
|
156
|
+
if (payload?.method_id !== methodId) return;
|
|
157
|
+
if (payload?.run_id !== runId) return;
|
|
158
|
+
if (payload?.name !== blobName) return;
|
|
159
|
+
|
|
160
|
+
const fresh = await listCycles();
|
|
161
|
+
if (fresh.length === 0) return;
|
|
162
|
+
setCycles(fresh);
|
|
163
|
+
// Auto-follow "latest" iff the operator hasn't manually
|
|
164
|
+
// moved away from it. The "was viewing the previous
|
|
165
|
+
// latest" check uses the snapshot taken via the ref so the
|
|
166
|
+
// closure stays stable.
|
|
167
|
+
const prevLatest = cyclesRef.current.length > 0
|
|
168
|
+
? cyclesRef.current[cyclesRef.current.length - 1]
|
|
169
|
+
: null;
|
|
170
|
+
const newLatest = fresh[fresh.length - 1];
|
|
171
|
+
const onLatest = !userPinnedRef.current
|
|
172
|
+
&& (selectedRef.current === null || selectedRef.current === prevLatest);
|
|
173
|
+
if (onLatest && newLatest !== selectedRef.current) {
|
|
174
|
+
setSelectedCycle(newLatest);
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
const id = subscribe('tis.raw_data_added', onRawAdded);
|
|
178
|
+
return () => { unsubscribe(id); };
|
|
179
|
+
}, [enabled, projectId, methodId, runId, blobName, listCycles, subscribe, unsubscribe]);
|
|
100
180
|
|
|
101
181
|
// Lazy blob fetch — runs whenever identifiers / selectedCycle change.
|
|
182
|
+
// Suppressed entirely when no cycle is selected (e.g. test just
|
|
183
|
+
// started, no raw_data on disk yet): no point asking the server
|
|
184
|
+
// for a file that's known to not exist, and surfacing the resulting
|
|
185
|
+
// error would flash an unfriendly overlay over an otherwise valid
|
|
186
|
+
// "waiting for data" state.
|
|
102
187
|
useEffect(() => {
|
|
103
188
|
if (!enabled || !projectId || !methodId || !runId) {
|
|
104
189
|
setRaw(null); setEnvelope(null); setLoading(false); setError(null);
|
|
105
190
|
return;
|
|
106
191
|
}
|
|
192
|
+
if (selectedCycle == null) {
|
|
193
|
+
setRaw(null); setEnvelope(null); setLoading(false); setError(null);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
107
196
|
let cancelled = false;
|
|
108
197
|
setLoading(true);
|
|
109
198
|
setError(null);
|
|
110
199
|
(async () => {
|
|
111
200
|
try {
|
|
112
|
-
const args: Record<string, any> = {
|
|
113
|
-
project_id: projectId, method_id: methodId,
|
|
114
|
-
run_id: runId, name: blobName,
|
|
115
|
-
};
|
|
116
|
-
if (selectedCycle != null) args.cycle_index = selectedCycle;
|
|
117
201
|
const resp: any = await invoke(
|
|
118
|
-
'tis.read_raw' as any, MessageType.Request as any,
|
|
202
|
+
'tis.read_raw' as any, MessageType.Request as any,
|
|
203
|
+
{
|
|
204
|
+
project_id: projectId, method_id: methodId,
|
|
205
|
+
run_id: runId, name: blobName,
|
|
206
|
+
cycle_index: selectedCycle,
|
|
207
|
+
} as any,
|
|
119
208
|
);
|
|
120
209
|
if (cancelled) return;
|
|
121
210
|
if (resp?.success) {
|
|
@@ -134,7 +223,19 @@ export function useRawCycleData(opts: UseRawCycleDataOptions): UseRawCycleDataRe
|
|
|
134
223
|
return () => { cancelled = true; };
|
|
135
224
|
}, [enabled, projectId, methodId, runId, blobName, selectedCycle, invoke]);
|
|
136
225
|
|
|
137
|
-
|
|
226
|
+
// Public setter wraps setSelectedCycle and flips userPinnedRef so
|
|
227
|
+
// an operator's manual pick freezes the picker for the rest of the
|
|
228
|
+
// run. Re-mounting (new run) clears userPinnedRef in the
|
|
229
|
+
// identifier-change effect above.
|
|
230
|
+
const pickCycle = useCallback((n: number | null) => {
|
|
231
|
+
userPinnedRef.current = n != null;
|
|
232
|
+
setSelectedCycle(n);
|
|
233
|
+
}, []);
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
cycles, selectedCycle, setSelectedCycle: pickCycle,
|
|
237
|
+
raw, envelope, loading, error,
|
|
238
|
+
};
|
|
138
239
|
}
|
|
139
240
|
|
|
140
241
|
/**
|