@adcops/autocore-react 3.3.50 → 3.3.57
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/index.d.ts +2 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +1 -1
- package/dist/components/tis/ResultHistoryTable.d.ts +14 -2
- package/dist/components/tis/ResultHistoryTable.d.ts.map +1 -1
- package/dist/components/tis/ResultHistoryTable.js +1 -1
- package/dist/components/tis/TestDataView.d.ts +9 -5
- 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 +9 -5
- 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 +15 -4
- 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 +93 -0
- package/dist/components/tis/TisProvider.d.ts.map +1 -0
- package/dist/components/tis/TisProvider.js +1 -0
- package/dist/hub/HubWebSocket.d.ts +13 -0
- package/dist/hub/HubWebSocket.d.ts.map +1 -1
- package/dist/hub/HubWebSocket.js +1 -1
- package/package.json +1 -1
- package/src/components/index.ts +22 -1
- package/src/components/tis/ResultHistoryTable.tsx +133 -48
- package/src/components/tis/TestDataView.tsx +70 -36
- package/src/components/tis/TestRawDataView.tsx +39 -22
- package/src/components/tis/TestSetupForm.tsx +155 -96
- package/src/components/tis/TisProvider.tsx +405 -0
- package/src/hub/HubWebSocket.ts +66 -3
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Automated Design Corp. All Rights Reserved.
|
|
3
|
+
*
|
|
4
|
+
* <TisProvider> — wires the four TIS HMI components into the running
|
|
5
|
+
* server without any manual prop-threading. It owns:
|
|
6
|
+
*
|
|
7
|
+
* 1. The schema registry (loaded once via `tis.list_schemas`).
|
|
8
|
+
* 2. The four live readiness scalars (`tis.staged*`, `tis.active*`).
|
|
9
|
+
* 3. The selection state (project, method, sample, run) — each field
|
|
10
|
+
* follows its `active_*` scalar by default until a component pins
|
|
11
|
+
* it explicitly via `setSelection`.
|
|
12
|
+
* 4. A run cache that tracks live cycle/results broadcasts so detail
|
|
13
|
+
* views re-render in place when the control program appends data.
|
|
14
|
+
*
|
|
15
|
+
* Drop it once at the top of the HMI; the four TIS components below it
|
|
16
|
+
* read from context and need no props.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import React, {
|
|
20
|
+
createContext,
|
|
21
|
+
useCallback,
|
|
22
|
+
useContext,
|
|
23
|
+
useEffect,
|
|
24
|
+
useMemo,
|
|
25
|
+
useReducer,
|
|
26
|
+
useRef,
|
|
27
|
+
useState,
|
|
28
|
+
type ReactNode,
|
|
29
|
+
} from 'react';
|
|
30
|
+
import { EventEmitterContext } from '../../core/EventEmitterContext';
|
|
31
|
+
import { MessageType } from '../../hub/CommandMessage';
|
|
32
|
+
|
|
33
|
+
// -------------------------------------------------------------------------
|
|
34
|
+
// Types
|
|
35
|
+
// -------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* One slot in the schema registry. Kept as an opaque object so the
|
|
39
|
+
* provider doesn't have to mirror every schema field — components that
|
|
40
|
+
* need typed access import the schema constants from the per-project
|
|
41
|
+
* generated `autocore/tis.ts` instead.
|
|
42
|
+
*/
|
|
43
|
+
export type TisMethodSchema = any;
|
|
44
|
+
|
|
45
|
+
export type SchemaRegistry = { [methodId: string]: TisMethodSchema };
|
|
46
|
+
|
|
47
|
+
export interface TisLiveState {
|
|
48
|
+
staged: boolean;
|
|
49
|
+
stagedProjectId: string;
|
|
50
|
+
stagedMethodId: string;
|
|
51
|
+
stagedSampleId: string;
|
|
52
|
+
|
|
53
|
+
active: boolean;
|
|
54
|
+
activeProjectId: string;
|
|
55
|
+
activeMethodId: string;
|
|
56
|
+
activeSampleId: string;
|
|
57
|
+
activeRunId: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface TisSelection {
|
|
61
|
+
projectId: string;
|
|
62
|
+
methodId: string;
|
|
63
|
+
sampleId: string;
|
|
64
|
+
runId: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Partial setter — pass `null` for a field to clear the pin and let
|
|
68
|
+
* the active scalar drive that field again. */
|
|
69
|
+
export type TisSelectionPatch = {
|
|
70
|
+
projectId?: string | null;
|
|
71
|
+
methodId?: string | null;
|
|
72
|
+
sampleId?: string | null;
|
|
73
|
+
runId?: string | null;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export interface TisRunCacheEntry {
|
|
77
|
+
meta: any | null;
|
|
78
|
+
cycles: any[];
|
|
79
|
+
results: any;
|
|
80
|
+
rawData: { [blobName: string]: any };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface TisContextValue {
|
|
84
|
+
schemas: SchemaRegistry;
|
|
85
|
+
defaultMethodId: string;
|
|
86
|
+
schemasLoaded: boolean;
|
|
87
|
+
|
|
88
|
+
state: TisLiveState;
|
|
89
|
+
|
|
90
|
+
selection: TisSelection;
|
|
91
|
+
setSelection: (patch: TisSelectionPatch) => void;
|
|
92
|
+
|
|
93
|
+
/** Fetch the run list for a (project, method?) pair. Method may be
|
|
94
|
+
* omitted to aggregate runs across every method in the project —
|
|
95
|
+
* the History tab uses this. */
|
|
96
|
+
fetchRuns: (projectId: string, methodId?: string) => Promise<any[]>;
|
|
97
|
+
/** Fetch the full bundle (test.json + cycles + results) for one run.
|
|
98
|
+
* Subsequent broadcast deltas land in the cache automatically. */
|
|
99
|
+
fetchRun: (projectId: string, methodId: string, runId: string) => Promise<TisRunCacheEntry | null>;
|
|
100
|
+
runCache: { [runId: string]: TisRunCacheEntry };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// -------------------------------------------------------------------------
|
|
104
|
+
// Defaults
|
|
105
|
+
// -------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
const EMPTY_STATE: TisLiveState = {
|
|
108
|
+
staged: false, stagedProjectId: '', stagedMethodId: '', stagedSampleId: '',
|
|
109
|
+
active: false, activeProjectId: '', activeMethodId: '', activeSampleId: '', activeRunId: '',
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const EMPTY_SELECTION: TisSelection = {
|
|
113
|
+
projectId: '', methodId: '', sampleId: '', runId: '',
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const TisContext = createContext<TisContextValue>({
|
|
117
|
+
schemas: {},
|
|
118
|
+
defaultMethodId: '',
|
|
119
|
+
schemasLoaded: false,
|
|
120
|
+
state: EMPTY_STATE,
|
|
121
|
+
selection: EMPTY_SELECTION,
|
|
122
|
+
setSelection: () => {},
|
|
123
|
+
fetchRuns: async () => [],
|
|
124
|
+
fetchRun: async () => null,
|
|
125
|
+
runCache: {},
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// -------------------------------------------------------------------------
|
|
129
|
+
// Reducer for live broadcast state
|
|
130
|
+
// -------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
type StateAction =
|
|
133
|
+
| { kind: 'staged'; value: boolean }
|
|
134
|
+
| { kind: 'staged_project_id'; value: string }
|
|
135
|
+
| { kind: 'staged_method_id'; value: string }
|
|
136
|
+
| { kind: 'staged_sample_id'; value: string }
|
|
137
|
+
| { kind: 'active'; value: boolean }
|
|
138
|
+
| { kind: 'active_project_id'; value: string }
|
|
139
|
+
| { kind: 'active_method_id'; value: string }
|
|
140
|
+
| { kind: 'active_sample_id'; value: string }
|
|
141
|
+
| { kind: 'active_run_id'; value: string };
|
|
142
|
+
|
|
143
|
+
function liveReducer(s: TisLiveState, a: StateAction): TisLiveState {
|
|
144
|
+
switch (a.kind) {
|
|
145
|
+
case 'staged': return { ...s, staged: a.value };
|
|
146
|
+
case 'staged_project_id': return { ...s, stagedProjectId: a.value };
|
|
147
|
+
case 'staged_method_id': return { ...s, stagedMethodId: a.value };
|
|
148
|
+
case 'staged_sample_id': return { ...s, stagedSampleId: a.value };
|
|
149
|
+
case 'active': return { ...s, active: a.value };
|
|
150
|
+
case 'active_project_id': return { ...s, activeProjectId: a.value };
|
|
151
|
+
case 'active_method_id': return { ...s, activeMethodId: a.value };
|
|
152
|
+
case 'active_sample_id': return { ...s, activeSampleId: a.value };
|
|
153
|
+
case 'active_run_id': return { ...s, activeRunId: a.value };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// -------------------------------------------------------------------------
|
|
158
|
+
// Provider
|
|
159
|
+
// -------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
export interface TisProviderProps {
|
|
162
|
+
children: ReactNode;
|
|
163
|
+
/**
|
|
164
|
+
* Initial method_id when the schemas haven't loaded yet, or when the
|
|
165
|
+
* server's reported `default_method_id` is empty. Falls through to
|
|
166
|
+
* the first key of `tis.list_schemas` if not supplied.
|
|
167
|
+
*/
|
|
168
|
+
defaultMethodId?: string;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export const TisProvider: React.FC<TisProviderProps> = ({ children, defaultMethodId: initialDefault }) => {
|
|
172
|
+
const { invoke, subscribe, unsubscribe } = useContext(EventEmitterContext);
|
|
173
|
+
|
|
174
|
+
const [schemas, setSchemas] = useState<SchemaRegistry>({});
|
|
175
|
+
const [defaultMethodId, setDefaultMethodId] = useState<string>(initialDefault ?? '');
|
|
176
|
+
const [schemasLoaded, setSchemasLoaded] = useState(false);
|
|
177
|
+
|
|
178
|
+
const [state, dispatch] = useReducer(liveReducer, EMPTY_STATE);
|
|
179
|
+
|
|
180
|
+
// Selection has two layers: explicit pins (set via setSelection) and
|
|
181
|
+
// an "active follower" fallback. Storing pins as `null` means
|
|
182
|
+
// "follow active"; storing `''` means "explicitly empty" (e.g.,
|
|
183
|
+
// user cleared the field). Only `null` triggers the follow.
|
|
184
|
+
const [pins, setPins] = useState<{
|
|
185
|
+
projectId: string | null;
|
|
186
|
+
methodId: string | null;
|
|
187
|
+
sampleId: string | null;
|
|
188
|
+
runId: string | null;
|
|
189
|
+
}>({
|
|
190
|
+
projectId: null, methodId: null, sampleId: null, runId: null,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// -----------------------------------------------------------------
|
|
194
|
+
// Schema load — once on mount. The Hub's `invoke()` queues sends
|
|
195
|
+
// while the WS is still CONNECTING and flushes them on `onopen`,
|
|
196
|
+
// so this works whether or not the handshake has finished yet.
|
|
197
|
+
// -----------------------------------------------------------------
|
|
198
|
+
useEffect(() => {
|
|
199
|
+
let cancelled = false;
|
|
200
|
+
(async () => {
|
|
201
|
+
try {
|
|
202
|
+
const resp: any = await invoke('tis.list_schemas' as any, MessageType.Request, {} as any);
|
|
203
|
+
if (cancelled) return;
|
|
204
|
+
if (resp?.success && resp.data) {
|
|
205
|
+
const methods = (resp.data.test_methods ?? {}) as SchemaRegistry;
|
|
206
|
+
const dflt = (resp.data.default_method_id ?? '') as string;
|
|
207
|
+
setSchemas(methods);
|
|
208
|
+
if (!initialDefault && dflt) setDefaultMethodId(dflt);
|
|
209
|
+
setSchemasLoaded(true);
|
|
210
|
+
} else {
|
|
211
|
+
console.warn('[TisProvider] tis.list_schemas failed:', resp?.error_message);
|
|
212
|
+
}
|
|
213
|
+
} catch (e) {
|
|
214
|
+
console.error('[TisProvider] tis.list_schemas threw:', e);
|
|
215
|
+
}
|
|
216
|
+
})();
|
|
217
|
+
return () => { cancelled = true; };
|
|
218
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
219
|
+
}, []);
|
|
220
|
+
|
|
221
|
+
// -----------------------------------------------------------------
|
|
222
|
+
// Live readiness scalars — subscribe to every tis.* broadcast
|
|
223
|
+
// -----------------------------------------------------------------
|
|
224
|
+
useEffect(() => {
|
|
225
|
+
const subs = [
|
|
226
|
+
subscribe('tis.staged', (v: any) => dispatch({ kind: 'staged', value: !!v })),
|
|
227
|
+
subscribe('tis.staged_project_id', (v: any) => dispatch({ kind: 'staged_project_id', value: String(v ?? '') })),
|
|
228
|
+
subscribe('tis.staged_method_id', (v: any) => dispatch({ kind: 'staged_method_id', value: String(v ?? '') })),
|
|
229
|
+
subscribe('tis.staged_sample_id', (v: any) => dispatch({ kind: 'staged_sample_id', value: String(v ?? '') })),
|
|
230
|
+
subscribe('tis.active', (v: any) => dispatch({ kind: 'active', value: !!v })),
|
|
231
|
+
subscribe('tis.active_project_id', (v: any) => dispatch({ kind: 'active_project_id', value: String(v ?? '') })),
|
|
232
|
+
subscribe('tis.active_method_id', (v: any) => dispatch({ kind: 'active_method_id', value: String(v ?? '') })),
|
|
233
|
+
subscribe('tis.active_sample_id', (v: any) => dispatch({ kind: 'active_sample_id', value: String(v ?? '') })),
|
|
234
|
+
subscribe('tis.active_run_id', (v: any) => dispatch({ kind: 'active_run_id', value: String(v ?? '') })),
|
|
235
|
+
];
|
|
236
|
+
return () => { subs.forEach(unsubscribe); };
|
|
237
|
+
}, [subscribe, unsubscribe]);
|
|
238
|
+
|
|
239
|
+
// -----------------------------------------------------------------
|
|
240
|
+
// Run cache — broadcasts of cycles/results land here so detail
|
|
241
|
+
// views can subscribe via useTisRun() instead of re-fetching.
|
|
242
|
+
// -----------------------------------------------------------------
|
|
243
|
+
const cacheRef = useRef<{ [runId: string]: TisRunCacheEntry }>({});
|
|
244
|
+
// bumpCacheVersion is a numeric tick; we expose `runCache` as a
|
|
245
|
+
// memoised value derived from the ref so React rerenders dependent
|
|
246
|
+
// components when any run entry changes.
|
|
247
|
+
const [cacheVersion, setCacheVersion] = useState(0);
|
|
248
|
+
const bumpCache = useCallback(() => setCacheVersion(v => v + 1), []);
|
|
249
|
+
|
|
250
|
+
const upsertCycle = useCallback((runId: string, cycle: any) => {
|
|
251
|
+
if (!runId) return;
|
|
252
|
+
const prev = cacheRef.current[runId] ?? { meta: null, cycles: [], results: {}, rawData: {} };
|
|
253
|
+
cacheRef.current[runId] = { ...prev, cycles: [...prev.cycles, cycle] };
|
|
254
|
+
bumpCache();
|
|
255
|
+
}, [bumpCache]);
|
|
256
|
+
|
|
257
|
+
const upsertResults = useCallback((runId: string, results: any) => {
|
|
258
|
+
if (!runId) return;
|
|
259
|
+
const prev = cacheRef.current[runId] ?? { meta: null, cycles: [], results: {}, rawData: {} };
|
|
260
|
+
cacheRef.current[runId] = { ...prev, results };
|
|
261
|
+
bumpCache();
|
|
262
|
+
}, [bumpCache]);
|
|
263
|
+
|
|
264
|
+
useEffect(() => {
|
|
265
|
+
const onCycle = (payload: any) => {
|
|
266
|
+
if (!payload?.run_id || !payload.cycle) return;
|
|
267
|
+
upsertCycle(payload.run_id, payload.cycle);
|
|
268
|
+
};
|
|
269
|
+
const onResults = (payload: any) => {
|
|
270
|
+
if (!payload?.run_id) return;
|
|
271
|
+
upsertResults(payload.run_id, payload.results ?? {});
|
|
272
|
+
};
|
|
273
|
+
const id1 = subscribe('tis.cycle_added', onCycle);
|
|
274
|
+
const id2 = subscribe('tis.results_updated', onResults);
|
|
275
|
+
return () => { unsubscribe(id1); unsubscribe(id2); };
|
|
276
|
+
}, [subscribe, unsubscribe, upsertCycle, upsertResults]);
|
|
277
|
+
|
|
278
|
+
// -----------------------------------------------------------------
|
|
279
|
+
// Selection: pins override active
|
|
280
|
+
// -----------------------------------------------------------------
|
|
281
|
+
const selection: TisSelection = useMemo(() => ({
|
|
282
|
+
projectId: pins.projectId ?? state.activeProjectId,
|
|
283
|
+
methodId: pins.methodId ?? (state.activeMethodId || defaultMethodId),
|
|
284
|
+
sampleId: pins.sampleId ?? state.activeSampleId,
|
|
285
|
+
runId: pins.runId ?? state.activeRunId,
|
|
286
|
+
}), [pins, state, defaultMethodId]);
|
|
287
|
+
|
|
288
|
+
const setSelection = useCallback((patch: TisSelectionPatch) => {
|
|
289
|
+
setPins(prev => ({
|
|
290
|
+
projectId: patch.projectId === undefined ? prev.projectId : patch.projectId,
|
|
291
|
+
methodId: patch.methodId === undefined ? prev.methodId : patch.methodId,
|
|
292
|
+
sampleId: patch.sampleId === undefined ? prev.sampleId : patch.sampleId,
|
|
293
|
+
runId: patch.runId === undefined ? prev.runId : patch.runId,
|
|
294
|
+
}));
|
|
295
|
+
}, []);
|
|
296
|
+
|
|
297
|
+
// -----------------------------------------------------------------
|
|
298
|
+
// Fetchers
|
|
299
|
+
// -----------------------------------------------------------------
|
|
300
|
+
const fetchRuns = useCallback(async (projectId: string, methodId?: string): Promise<any[]> => {
|
|
301
|
+
if (!projectId) return [];
|
|
302
|
+
const payload: any = { project_id: projectId };
|
|
303
|
+
if (methodId) payload.method_id = methodId;
|
|
304
|
+
try {
|
|
305
|
+
const resp: any = await invoke('tis.list_tests' as any, MessageType.Request, payload);
|
|
306
|
+
if (resp?.success && resp.data?.tests) return resp.data.tests as any[];
|
|
307
|
+
} catch (e) {
|
|
308
|
+
console.error('[TisProvider] tis.list_tests failed:', e);
|
|
309
|
+
}
|
|
310
|
+
return [];
|
|
311
|
+
}, [invoke]);
|
|
312
|
+
|
|
313
|
+
const fetchRun = useCallback(async (
|
|
314
|
+
projectId: string, methodId: string, runId: string,
|
|
315
|
+
): Promise<TisRunCacheEntry | null> => {
|
|
316
|
+
if (!projectId || !methodId || !runId) return null;
|
|
317
|
+
try {
|
|
318
|
+
const meta: any = await invoke('tis.read_test' as any, MessageType.Request, {
|
|
319
|
+
project_id: projectId, method_id: methodId, run_id: runId,
|
|
320
|
+
} as any);
|
|
321
|
+
const cy: any = await invoke('tis.read_cycles' as any, MessageType.Request, {
|
|
322
|
+
project_id: projectId, method_id: methodId, run_id: runId,
|
|
323
|
+
offset: 0, limit: 1000, order: 'asc',
|
|
324
|
+
} as any);
|
|
325
|
+
if (!meta?.success) return null;
|
|
326
|
+
const entry: TisRunCacheEntry = {
|
|
327
|
+
meta: meta.data ?? null,
|
|
328
|
+
cycles: (cy?.success ? (cy.data?.cycles ?? []) : []) as any[],
|
|
329
|
+
results: meta.data?.results ?? {},
|
|
330
|
+
rawData: cacheRef.current[runId]?.rawData ?? {},
|
|
331
|
+
};
|
|
332
|
+
cacheRef.current[runId] = entry;
|
|
333
|
+
bumpCache();
|
|
334
|
+
return entry;
|
|
335
|
+
} catch (e) {
|
|
336
|
+
console.error('[TisProvider] fetchRun failed:', e);
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
}, [invoke, bumpCache]);
|
|
340
|
+
|
|
341
|
+
const runCache = useMemo(() => ({ ...cacheRef.current }), [cacheVersion]);
|
|
342
|
+
|
|
343
|
+
const value: TisContextValue = useMemo(() => ({
|
|
344
|
+
schemas, defaultMethodId, schemasLoaded,
|
|
345
|
+
state, selection, setSelection,
|
|
346
|
+
fetchRuns, fetchRun, runCache,
|
|
347
|
+
}), [schemas, defaultMethodId, schemasLoaded, state, selection, setSelection, fetchRuns, fetchRun, runCache]);
|
|
348
|
+
|
|
349
|
+
return <TisContext.Provider value={value}>{children}</TisContext.Provider>;
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
// -------------------------------------------------------------------------
|
|
353
|
+
// Hooks
|
|
354
|
+
// -------------------------------------------------------------------------
|
|
355
|
+
|
|
356
|
+
export const useTis = () => useContext(TisContext);
|
|
357
|
+
export const useTisSchemas = () => useContext(TisContext).schemas;
|
|
358
|
+
export const useTisState = () => useContext(TisContext).state;
|
|
359
|
+
export const useTisSelection = () => {
|
|
360
|
+
const { selection, setSelection } = useContext(TisContext);
|
|
361
|
+
return [selection, setSelection] as const;
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
export const useTisRuns = (projectId?: string, methodId?: string) => {
|
|
365
|
+
const { fetchRuns } = useContext(TisContext);
|
|
366
|
+
const [runs, setRuns] = useState<any[]>([]);
|
|
367
|
+
const [loading, setLoading] = useState(false);
|
|
368
|
+
const refresh = useCallback(async () => {
|
|
369
|
+
if (!projectId) { setRuns([]); return; }
|
|
370
|
+
setLoading(true);
|
|
371
|
+
try { setRuns(await fetchRuns(projectId, methodId)); }
|
|
372
|
+
finally { setLoading(false); }
|
|
373
|
+
}, [projectId, methodId, fetchRuns]);
|
|
374
|
+
useEffect(() => { void refresh(); }, [refresh]);
|
|
375
|
+
return { runs, loading, refresh };
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
export const useTisRun = (runId?: string) => {
|
|
379
|
+
const { selection, fetchRun, runCache } = useContext(TisContext);
|
|
380
|
+
const [loading, setLoading] = useState(false);
|
|
381
|
+
const targetRunId = runId ?? selection.runId;
|
|
382
|
+
|
|
383
|
+
useEffect(() => {
|
|
384
|
+
if (!targetRunId) return;
|
|
385
|
+
if (runCache[targetRunId]?.meta) return; // already cached
|
|
386
|
+
const projectId = selection.projectId;
|
|
387
|
+
const methodId = selection.methodId;
|
|
388
|
+
if (!projectId || !methodId) return;
|
|
389
|
+
setLoading(true);
|
|
390
|
+
void fetchRun(projectId, methodId, targetRunId).finally(() => setLoading(false));
|
|
391
|
+
}, [targetRunId, selection.projectId, selection.methodId, fetchRun, runCache]);
|
|
392
|
+
|
|
393
|
+
const entry = targetRunId ? runCache[targetRunId] : null;
|
|
394
|
+
return {
|
|
395
|
+
meta: entry?.meta ?? null,
|
|
396
|
+
cycles: entry?.cycles ?? [],
|
|
397
|
+
results: entry?.results ?? {},
|
|
398
|
+
rawData: entry?.rawData ?? {},
|
|
399
|
+
loading,
|
|
400
|
+
};
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
// Re-export the context so app code that needs direct access can grab
|
|
404
|
+
// it without importing the hooks (rare).
|
|
405
|
+
export { TisContext };
|
package/src/hub/HubWebSocket.ts
CHANGED
|
@@ -98,13 +98,27 @@ function downloadBlob(blob: Blob, filename: string): void {
|
|
|
98
98
|
export class HubWebSocket extends HubBase {
|
|
99
99
|
private socket: WebSocket;
|
|
100
100
|
private requestId = 0;
|
|
101
|
-
|
|
102
|
-
/**
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
103
|
* Map of pending Transaction ID -> Promise Resolvers.
|
|
104
104
|
* Used to match asynchronous WebSocket responses to their original requests.
|
|
105
105
|
*/
|
|
106
106
|
private pendingRequests = new Map<number, RequestRecord>();
|
|
107
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Messages queued while the WebSocket is still in CONNECTING state.
|
|
110
|
+
* Flushed in `socket.onopen`. Without this queue, an `invoke()` fired
|
|
111
|
+
* from a React `useEffect` on first mount races the WS handshake —
|
|
112
|
+
* `socket.send()` on a non-OPEN socket throws a synchronous
|
|
113
|
+
* DOMException ("object not, or is no longer, usable"), which the
|
|
114
|
+
* caller sees as an opaque rejection and the underlying request is
|
|
115
|
+
* never sent.
|
|
116
|
+
*
|
|
117
|
+
* Each entry carries the request id so we can reject the matching
|
|
118
|
+
* pending Promise if the WS closes before we ever flush.
|
|
119
|
+
*/
|
|
120
|
+
private sendQueue: Array<{ id: number; json: string }> = [];
|
|
121
|
+
|
|
108
122
|
/**
|
|
109
123
|
* Initializes the WebSocket connection immediately.
|
|
110
124
|
*/
|
|
@@ -127,6 +141,18 @@ export class HubWebSocket extends HubBase {
|
|
|
127
141
|
this.socket.onopen = function () {
|
|
128
142
|
console.log("WebSocket connection established.");
|
|
129
143
|
getDebugPanel().wsOpened(wsUrl);
|
|
144
|
+
// Flush any messages that `invoke()` queued while the WS
|
|
145
|
+
// was still in CONNECTING. Order is preserved by Array push
|
|
146
|
+
// / for-of iteration. Done BEFORE publishing HUB/connected
|
|
147
|
+
// and setIsConnected(true) so any subscriber that fires its
|
|
148
|
+
// own invoke() in response sees the queue already drained.
|
|
149
|
+
if (self.sendQueue.length > 0) {
|
|
150
|
+
console.log(`[HubWebSocket] Flushing ${self.sendQueue.length} queued message(s)`);
|
|
151
|
+
for (const { json } of self.sendQueue) {
|
|
152
|
+
self.socket.send(json);
|
|
153
|
+
}
|
|
154
|
+
self.sendQueue = [];
|
|
155
|
+
}
|
|
130
156
|
// Notify app that we are online
|
|
131
157
|
self.publish("HUB/connected", true);
|
|
132
158
|
self.setIsConnected(true);
|
|
@@ -195,6 +221,19 @@ export class HubWebSocket extends HubBase {
|
|
|
195
221
|
self.setIsConnected(false);
|
|
196
222
|
console.log('WebSocket connection closed.');
|
|
197
223
|
getDebugPanel().wsClosed(event.code, event.reason || 'No reason');
|
|
224
|
+
|
|
225
|
+
// Anything queued but never sent must reject — its Promise
|
|
226
|
+
// is in pendingRequests and would otherwise hang forever.
|
|
227
|
+
if (self.sendQueue.length > 0) {
|
|
228
|
+
for (const { id } of self.sendQueue) {
|
|
229
|
+
const req = self.pendingRequests.get(id);
|
|
230
|
+
if (req) {
|
|
231
|
+
req.reject(new Error('WebSocket closed before message could be sent'));
|
|
232
|
+
self.pendingRequests.delete(id);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
self.sendQueue = [];
|
|
236
|
+
}
|
|
198
237
|
};
|
|
199
238
|
}
|
|
200
239
|
|
|
@@ -224,7 +263,31 @@ export class HubWebSocket extends HubBase {
|
|
|
224
263
|
};
|
|
225
264
|
|
|
226
265
|
getDebugPanel().messageSent(MessageType[messageType], topic, id);
|
|
227
|
-
|
|
266
|
+
const json = JSON.stringify(cm);
|
|
267
|
+
|
|
268
|
+
// Branch on the WS state. The browser throws a synchronous
|
|
269
|
+
// DOMException if you send() on a socket that isn't OPEN, so
|
|
270
|
+
// every consumer would otherwise need its own
|
|
271
|
+
// `subscribe('HUB/connected', ...)` guard. Centralising it
|
|
272
|
+
// here means useEffect-on-mount calls "just work" — they
|
|
273
|
+
// get queued and flushed when the handshake completes.
|
|
274
|
+
switch (this.socket.readyState) {
|
|
275
|
+
case WebSocket.OPEN:
|
|
276
|
+
this.socket.send(json);
|
|
277
|
+
break;
|
|
278
|
+
case WebSocket.CONNECTING:
|
|
279
|
+
this.sendQueue.push({ id, json });
|
|
280
|
+
break;
|
|
281
|
+
default:
|
|
282
|
+
// CLOSING / CLOSED — the WS is gone, no point queuing.
|
|
283
|
+
// Fail fast so the caller sees a real error instead
|
|
284
|
+
// of hanging.
|
|
285
|
+
this.pendingRequests.delete(id);
|
|
286
|
+
reject(new Error(
|
|
287
|
+
`WebSocket not open (readyState=${this.socket.readyState}); ` +
|
|
288
|
+
`cannot send '${topic}'`
|
|
289
|
+
));
|
|
290
|
+
}
|
|
228
291
|
});
|
|
229
292
|
}
|
|
230
293
|
|