@adcops/autocore-react 3.3.59 → 3.3.63

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 (53) hide show
  1. package/dist/components/ams/AmsProvider.d.ts +45 -0
  2. package/dist/components/ams/AmsProvider.d.ts.map +1 -0
  3. package/dist/components/ams/AmsProvider.js +1 -0
  4. package/dist/components/ams/AssetDetailView.d.ts +3 -0
  5. package/dist/components/ams/AssetDetailView.d.ts.map +1 -0
  6. package/dist/components/ams/AssetDetailView.js +1 -0
  7. package/dist/components/ams/AssetRegistryTable.d.ts +3 -0
  8. package/dist/components/ams/AssetRegistryTable.d.ts.map +1 -0
  9. package/dist/components/ams/AssetRegistryTable.js +1 -0
  10. package/dist/components/ams/CalibrationEntryDialog.d.ts +10 -0
  11. package/dist/components/ams/CalibrationEntryDialog.d.ts.map +1 -0
  12. package/dist/components/ams/CalibrationEntryDialog.js +1 -0
  13. package/dist/components/ams/SubLocationPicker.d.ts +3 -0
  14. package/dist/components/ams/SubLocationPicker.d.ts.map +1 -0
  15. package/dist/components/ams/SubLocationPicker.js +1 -0
  16. package/dist/components/ams/index.d.ts +6 -0
  17. package/dist/components/ams/index.d.ts.map +1 -0
  18. package/dist/components/ams/index.js +1 -0
  19. package/dist/components/index.d.ts +9 -0
  20. package/dist/components/index.d.ts.map +1 -1
  21. package/dist/components/index.js +1 -1
  22. package/dist/components/tis/ProjectSelector.d.ts +15 -0
  23. package/dist/components/tis/ProjectSelector.d.ts.map +1 -0
  24. package/dist/components/tis/ProjectSelector.js +1 -0
  25. package/dist/components/tis/TestDataView.d.ts +9 -1
  26. package/dist/components/tis/TestDataView.d.ts.map +1 -1
  27. package/dist/components/tis/TestDataView.js +1 -1
  28. package/dist/components/tis/TestSetupForm.d.ts +8 -4
  29. package/dist/components/tis/TestSetupForm.d.ts.map +1 -1
  30. package/dist/components/tis/TestSetupForm.js +1 -1
  31. package/dist/components/tis/TisProvider.d.ts +45 -0
  32. package/dist/components/tis/TisProvider.d.ts.map +1 -1
  33. package/dist/components/tis/TisProvider.js +1 -1
  34. package/dist/core/AutoCoreTagContext.d.ts +16 -0
  35. package/dist/core/AutoCoreTagContext.d.ts.map +1 -1
  36. package/dist/core/AutoCoreTagContext.js +1 -1
  37. package/dist/themes/adc-dark/blue/theme.css +67 -37
  38. package/dist/themes/adc-dark/blue/theme.css.map +1 -1
  39. package/package.json +1 -1
  40. package/src/components/ams/AmsProvider.tsx +219 -0
  41. package/src/components/ams/AssetDetailView.tsx +101 -0
  42. package/src/components/ams/AssetRegistryTable.tsx +171 -0
  43. package/src/components/ams/CalibrationEntryDialog.tsx +197 -0
  44. package/src/components/ams/SubLocationPicker.tsx +146 -0
  45. package/src/components/ams/index.ts +12 -0
  46. package/src/components/index.ts +30 -0
  47. package/src/components/tis/ProjectSelector.tsx +190 -0
  48. package/src/components/tis/TestDataView.tsx +321 -28
  49. package/src/components/tis/TestSetupForm.tsx +66 -253
  50. package/src/components/tis/TisProvider.tsx +192 -1
  51. package/src/core/AutoCoreTagContext.tsx +114 -16
  52. package/src/themes/adc-dark/_extensions.scss +15 -0
  53. package/src/themes/adc-dark/blue/adc_theme.scss +56 -10
@@ -0,0 +1,190 @@
1
+ /*
2
+ * Copyright (C) 2026 Automated Design Corp. All Rights Reserved.
3
+ *
4
+ * <ProjectSelector> — standalone Project ID picker, designed to live
5
+ * on a "Project" tab alongside <ResultHistoryTable>. Was previously
6
+ * the first row of <TestSetupForm>; lifted out so a user can browse a
7
+ * project's history without being forced through the full test-setup
8
+ * UI.
9
+ *
10
+ * The component is intentionally narrow:
11
+ *
12
+ * - One AutoComplete bound to `useTisSelection().projectId`.
13
+ * - A `+` button that opens the Create-Project dialog.
14
+ * - A `✏️` button that opens the Edit-Project-Information dialog.
15
+ *
16
+ * State (the existing projects list, the just-created set, the
17
+ * project_fields cache) all lives in <TisProvider>, so the form on
18
+ * the Test tab and this picker on the Project tab agree on what's
19
+ * known.
20
+ */
21
+
22
+ import React, { useContext, useState, useMemo } from 'react';
23
+ import { AutoComplete } from 'primereact/autocomplete';
24
+ import type { AutoCompleteCompleteEvent } from 'primereact/autocomplete';
25
+ import { Button } from 'primereact/button';
26
+ import { EventEmitterContext } from '../../core/EventEmitterContext';
27
+ import { useTis } from './TisProvider';
28
+ import { ProjectInfoDialog } from './ProjectInfoDialog';
29
+
30
+ // Project IDs follow the same character class as the server's
31
+ // `tis.create_project` validator. Keep these in sync — see
32
+ // `src/tis_servelet.rs::create_project`.
33
+ const PROJECT_ID_RE = /^[A-Za-z0-9_-]+$/;
34
+ const isValidProjectIdFormat = (id: string) => PROJECT_ID_RE.test(id);
35
+
36
+ export interface ProjectSelectorProps {
37
+ /**
38
+ * Optional override of the method whose `project_fields` are shown
39
+ * in the create / edit dialog. By default the dialog uses the
40
+ * provider's selected method (which is what you want — the
41
+ * project's metadata schema is per-method, and the form on the
42
+ * Test tab is going to use that same method anyway). Passing this
43
+ * is only useful if you have a "view-only" Project tab in a
44
+ * read-only HMI and want to lock the dialog to a specific method.
45
+ */
46
+ methodIdOverride?: string;
47
+ }
48
+
49
+ export const ProjectSelector: React.FC<ProjectSelectorProps> = ({ methodIdOverride }) => {
50
+ const tis = useTis();
51
+ const { invoke: _invoke } = useContext(EventEmitterContext);
52
+ void _invoke; // EventEmitterContext is consumed via ProjectInfoDialog; no direct call here.
53
+
54
+ const projectId = tis.selection.projectId;
55
+ const dialogMethodId =
56
+ methodIdOverride
57
+ ?? tis.selection.methodId
58
+ ?? tis.defaultMethodId
59
+ ?? Object.keys(tis.schemas)[0]
60
+ ?? '';
61
+ const dialogSchema = dialogMethodId ? tis.schemas[dialogMethodId] : undefined;
62
+ const projectFieldsSchema = dialogSchema?.project_fields ?? [];
63
+
64
+ const [filteredProjects, setFilteredProjects] = useState<string[]>([]);
65
+ const [newOpen, setNewOpen] = useState(false);
66
+ const [editOpen, setEditOpen] = useState(false);
67
+
68
+ const projectExists = projectId.trim() !== '' && tis.projectKnown(projectId.trim());
69
+ const projectIdFormatValid = isValidProjectIdFormat(projectId.trim());
70
+ const canCreateProject =
71
+ projectId.trim() !== ''
72
+ && projectIdFormatValid
73
+ && !tis.projectKnown(projectId.trim());
74
+
75
+ const search = (event: AutoCompleteCompleteEvent) => {
76
+ const q = event.query.toLowerCase();
77
+ setFilteredProjects(tis.existingProjects.filter(p => p.toLowerCase().includes(q)));
78
+ };
79
+
80
+ const handleChange = (value: string | null | undefined) => {
81
+ const sanitized = (value || '').replace(/[^a-zA-Z0-9_-]/g, '');
82
+ tis.setSelection({ projectId: sanitized });
83
+ };
84
+
85
+ // -----------------------------------------------------------------
86
+ // Plumbing for the create + edit dialogs. We treat the "create"
87
+ // result as authoritative for project_fields; the dialog sends
88
+ // them straight to `tis.create_project` so the persisted file
89
+ // already has the values. Stash them in the provider's cache so
90
+ // the form on the Test tab folds them into stage_test without an
91
+ // extra read_project round trip.
92
+ // -----------------------------------------------------------------
93
+ const handleSubmitted = (pid: string, fields: Record<string, any>) => {
94
+ tis.markProjectJustCreated(pid);
95
+ tis.setProjectFields(pid, fields);
96
+ // Refresh the dropdown so the new ID appears in future
97
+ // suggestions, and surface the new project as the current
98
+ // selection — operator's intent on `+` is "set up this
99
+ // project and start using it."
100
+ void tis.refreshProjects();
101
+ if (tis.selection.projectId !== pid) tis.setSelection({ projectId: pid });
102
+ };
103
+
104
+ const headerStatus = useMemo(() => {
105
+ if (projectExists) return { color: 'var(--green-500)', icon: 'pi-check-circle' };
106
+ if (projectId.trim() === '') return { color: 'var(--text-secondary-color)', icon: 'pi-info-circle' };
107
+ return { color: 'var(--red-500)', icon: 'pi-exclamation-circle' };
108
+ }, [projectExists, projectId]);
109
+
110
+ const gridStyle: React.CSSProperties = {
111
+ padding: '1.25rem',
112
+ gridTemplateColumns: 'auto 1fr 1.75rem 1.75rem',
113
+ };
114
+
115
+ return (
116
+ <div className="ac-form-grid" style={gridStyle}>
117
+ <h3 className="ac-form-section" style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
118
+ Project
119
+ <span style={{ color: headerStatus.color }}>
120
+ <i className={`pi ${headerStatus.icon}`} />
121
+ </span>
122
+ </h3>
123
+
124
+ <span className="ac-form-label">Project ID</span>
125
+ <div className="p-inputgroup" style={{ flex: 1 }}>
126
+ <AutoComplete
127
+ value={projectId}
128
+ suggestions={filteredProjects}
129
+ completeMethod={search}
130
+ onChange={(e) => handleChange(e.value)}
131
+ dropdown
132
+ placeholder="Select an existing Project ID, or type a new one and click +"
133
+ className={projectId.trim() && !projectExists ? 'p-invalid' : ''}
134
+ style={{ flex: 1 }}
135
+ />
136
+ <Button
137
+ icon="pi pi-plus"
138
+ type="button"
139
+ onClick={() => setNewOpen(true)}
140
+ disabled={!canCreateProject}
141
+ tooltip={
142
+ !projectId.trim() ? 'Type a project ID first' :
143
+ !projectIdFormatValid ? 'Letters, digits, _ and - only' :
144
+ tis.projectKnown(projectId.trim()) ? 'Project already exists' :
145
+ `Create project "${projectId.trim()}"`
146
+ }
147
+ tooltipOptions={{ position: 'top' }}
148
+ />
149
+ <Button
150
+ icon="pi pi-pencil"
151
+ type="button"
152
+ onClick={() => setEditOpen(true)}
153
+ disabled={!projectExists}
154
+ tooltip={projectExists
155
+ ? `Edit information for "${projectId.trim()}"`
156
+ : 'Select an existing project to edit'}
157
+ tooltipOptions={{ position: 'top' }}
158
+ />
159
+ </div>
160
+ <span aria-hidden="true" />
161
+ <span style={{
162
+ color: projectExists ? 'var(--green-500)' :
163
+ projectId.trim() === '' ? 'var(--text-secondary-color)' : 'var(--red-500)',
164
+ display: 'flex', alignItems: 'center',
165
+ }}>
166
+ <i className={projectExists ? 'pi pi-check' : projectId.trim() === '' ? 'pi pi-minus' : 'pi pi-times'} />
167
+ </span>
168
+
169
+ {/* Both dialogs are mounted unconditionally and gated by
170
+ their own `visible` prop. Cheap, and lets PrimeReact's
171
+ portal layering manage its own lifecycle. */}
172
+ <ProjectInfoDialog
173
+ visible={newOpen}
174
+ onHide={() => setNewOpen(false)}
175
+ mode="create"
176
+ projectId={projectId.trim()}
177
+ projectFields={projectFieldsSchema}
178
+ onSubmitted={handleSubmitted}
179
+ />
180
+ <ProjectInfoDialog
181
+ visible={editOpen}
182
+ onHide={() => setEditOpen(false)}
183
+ mode="edit"
184
+ projectId={projectId.trim()}
185
+ projectFields={projectFieldsSchema}
186
+ onSubmitted={handleSubmitted}
187
+ />
188
+ </div>
189
+ );
190
+ };
@@ -8,12 +8,13 @@
8
8
  * control program appends cycles.
9
9
  */
10
10
 
11
- import React, { useContext, useEffect, useMemo, useRef, useState } from 'react';
11
+ import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
12
12
  import { Button } from 'primereact/button';
13
13
  import { Column } from 'primereact/column';
14
14
  import { DataTable } from 'primereact/datatable';
15
15
  import { Dialog } from 'primereact/dialog';
16
16
  import { Dropdown } from 'primereact/dropdown';
17
+ import { TabView, TabPanel } from 'primereact/tabview';
17
18
 
18
19
  import { Chart as ChartJS,
19
20
  CategoryScale, LinearScale, PointElement, LineElement,
@@ -24,7 +25,6 @@ import { Line } from 'react-chartjs-2';
24
25
 
25
26
  import { EventEmitterContext } from '../../core/EventEmitterContext';
26
27
  import { MessageType } from '../../hub/CommandMessage';
27
- import { TestRawDataView } from './TestRawDataView';
28
28
  import { useTis } from './TisProvider';
29
29
 
30
30
  ChartJS.register(
@@ -53,9 +53,13 @@ export interface ChartView {
53
53
  x: ChartAxis;
54
54
  y: ChartSeries[];
55
55
  }
56
+ export interface RawColumn { source: string; }
56
57
  export interface RawDataShape {
57
58
  blob_name: string;
58
- columns: string[];
59
+ /** Map of column name → declared source. Iteration order over the
60
+ * keys defines the column order in the View Raw Data dialog's
61
+ * tables and in the raw_trace charts. */
62
+ columns: { [col: string]: RawColumn };
59
63
  units?: { [col: string]: string };
60
64
  }
61
65
  export interface TestMethod {
@@ -101,6 +105,17 @@ export const TestDataView: React.FC<TestDataViewProps> = (props) => {
101
105
  const [cycles, setCycles] = useState<any[]>([]);
102
106
  const [results, setResults] = useState<any>({});
103
107
  const [rawOpen, setRawOpen] = useState(false);
108
+ const [configOpen, setConfigOpen] = useState(false);
109
+
110
+ // Lazy-loaded blobs for the View Raw Data dialog. Fetched only
111
+ // when the dialog opens, and re-fetched if the operator pins a
112
+ // different run while the dialog is closed.
113
+ const [rawBlob, setRawBlob] = useState<any | null>(null);
114
+ const [rawError, setRawError] = useState<string | null>(null);
115
+ const [rawLoading, setRawLoading] = useState(false);
116
+ const [filteredBlob, setFilteredBlob] = useState<any | null>(null);
117
+ const [filteredError, setFilteredError] = useState<string | null>(null);
118
+ const [filteredLoading, setFilteredLoading] = useState(false);
104
119
 
105
120
  // Scatter-capable views only — raw_trace lives in <TestRawDataView>.
106
121
  const scatterViews = useMemo(() => {
@@ -253,6 +268,78 @@ export const TestDataView: React.FC<TestDataViewProps> = (props) => {
253
268
  },
254
269
  }), [selectedViewDef, usesRightAxis]);
255
270
 
271
+ // -----------------------------------------------------------------
272
+ // View Raw Data dialog: lazy-fetch raw + filtered blobs the first
273
+ // time the dialog opens (and re-fetch if the operator switches
274
+ // runs while it's closed). The blob shape is `{ col: number[] }`,
275
+ // streamed in full — these are the same files the per-row CSV
276
+ // download in <ResultHistoryTable> pulls.
277
+ // -----------------------------------------------------------------
278
+ const blobName = schema?.raw_data?.blob_name ?? 'trace';
279
+ const fetchKeyRef = useRef<string>(''); // last (project, method, run, blob) we fetched
280
+
281
+ const loadBlobs = useCallback(async () => {
282
+ if (!projectId || !methodId || !runId) return;
283
+ const key = `${projectId}|${methodId}|${runId}|${blobName}`;
284
+ if (fetchKeyRef.current === key) return; // already loaded for this run
285
+ fetchKeyRef.current = key;
286
+
287
+ // Raw — must succeed for the dialog to be useful, but a
288
+ // missing file is logged and surfaced rather than aborted.
289
+ setRawLoading(true);
290
+ setRawError(null);
291
+ setRawBlob(null);
292
+ try {
293
+ const resp: any = await invoke(
294
+ 'tis.read_raw' as any, MessageType.Request,
295
+ { project_id: projectId, method_id: methodId, run_id: runId, name: blobName } as any,
296
+ );
297
+ if (resp?.success) {
298
+ setRawBlob(resp.data ?? {});
299
+ } else {
300
+ setRawError(resp?.error_message ?? 'No raw data on disk for this run.');
301
+ }
302
+ } catch (e: any) {
303
+ setRawError(String(e?.message ?? e));
304
+ } finally {
305
+ setRawLoading(false);
306
+ }
307
+
308
+ // Filtered is optional. The 'no filtered data' case is the
309
+ // common one (only the post-processing pipeline writes it),
310
+ // and we render a friendly message rather than an error tone.
311
+ setFilteredLoading(true);
312
+ setFilteredError(null);
313
+ setFilteredBlob(null);
314
+ try {
315
+ const resp: any = await invoke(
316
+ 'tis.read_filtered' as any, MessageType.Request,
317
+ { project_id: projectId, method_id: methodId, run_id: runId, name: blobName } as any,
318
+ );
319
+ if (resp?.success) {
320
+ setFilteredBlob(resp.data ?? {});
321
+ } else {
322
+ setFilteredError(resp?.error_message ?? 'No filtered data on disk for this run.');
323
+ }
324
+ } catch (e: any) {
325
+ setFilteredError(String(e?.message ?? e));
326
+ } finally {
327
+ setFilteredLoading(false);
328
+ }
329
+ }, [projectId, methodId, runId, blobName, invoke]);
330
+
331
+ // When the run changes, drop the cache key so the next dialog
332
+ // open refetches. Don't auto-fetch — that's wasteful if the
333
+ // operator never opens the dialog.
334
+ useEffect(() => {
335
+ fetchKeyRef.current = '';
336
+ }, [projectId, methodId, runId, blobName]);
337
+
338
+ const openRawDialog = () => {
339
+ setRawOpen(true);
340
+ void loadBlobs();
341
+ };
342
+
256
343
  // -----------------------------------------------------------------
257
344
  // Render
258
345
  // -----------------------------------------------------------------
@@ -269,7 +356,8 @@ export const TestDataView: React.FC<TestDataViewProps> = (props) => {
269
356
  <Header meta={meta} config={meta?.config} runId={runId}
270
357
  projectId={projectId} methodId={methodId}
271
358
  canViewRaw={!!schema.raw_data}
272
- onViewRaw={() => setRawOpen(true)} />
359
+ onViewRaw={openRawDialog}
360
+ onShowConfig={() => setConfigOpen(true)} />
273
361
 
274
362
  {scatterViews.length > 0 && (
275
363
  <div className="p-card" style={{ padding: '1rem' }}>
@@ -310,22 +398,62 @@ export const TestDataView: React.FC<TestDataViewProps> = (props) => {
310
398
  <ResultsGrid schema={schema.results_fields} values={results} />
311
399
  </div>
312
400
 
401
+ {/*
402
+ * View Raw Data dialog — tabular listing of the raw and
403
+ * filtered columnar blobs. Charts of cycle data are
404
+ * already shown in the main view above; this dialog is
405
+ * specifically the per-sample numerical view that the
406
+ * operator needs for spot-checking, copy/paste, or
407
+ * confirming a post-processing pipeline ran.
408
+ */}
313
409
  {schema.raw_data && (
314
410
  <Dialog
315
411
  visible={rawOpen}
316
412
  onHide={() => setRawOpen(false)}
317
- header="Raw Data"
413
+ header={`Run Data — ${runId}`}
318
414
  style={{ width: '90vw', height: '80vh' }}
319
415
  maximizable
320
416
  >
321
- <TestRawDataView
322
- projectId={projectId}
323
- methodId={methodId}
324
- runId={runId}
325
- schema={schema}
326
- />
417
+ <TabView style={{ height: '100%' }}>
418
+ <TabPanel header="Raw Data">
419
+ <DataBlobTable
420
+ blob={rawBlob}
421
+ loading={rawLoading}
422
+ error={rawError}
423
+ rawData={schema.raw_data}
424
+ />
425
+ </TabPanel>
426
+ <TabPanel header="Filtered Data">
427
+ <DataBlobTable
428
+ blob={filteredBlob}
429
+ loading={filteredLoading}
430
+ error={filteredError}
431
+ rawData={schema.raw_data}
432
+ emptyMessage="Filtered data is written by post-processing — none on disk for this run yet."
433
+ />
434
+ </TabPanel>
435
+ </TabView>
327
436
  </Dialog>
328
437
  )}
438
+
439
+ {/*
440
+ * Test Configuration dialog — surfaces the staged config
441
+ * map (everything the operator typed into the Setup tab,
442
+ * plus any project_fields the manager attached). Was
443
+ * previously inline under the header; moved into a dialog
444
+ * because it's reference material the operator only
445
+ * occasionally needs to consult, and inline space is at
446
+ * a premium on the run-data view.
447
+ */}
448
+ <Dialog
449
+ visible={configOpen}
450
+ onHide={() => setConfigOpen(false)}
451
+ header="Test Configuration"
452
+ style={{ width: 'min(640px, 90vw)' }}
453
+ modal
454
+ >
455
+ <ConfigList config={meta?.config} />
456
+ </Dialog>
329
457
  </div>
330
458
  );
331
459
  };
@@ -338,7 +466,8 @@ const Header: React.FC<{
338
466
  meta: any; config: any; runId: string;
339
467
  projectId: string; methodId: string;
340
468
  canViewRaw: boolean; onViewRaw: () => void;
341
- }> = ({ meta, config, runId, projectId, methodId, canViewRaw, onViewRaw }) => {
469
+ onShowConfig: () => void;
470
+ }> = ({ meta, config, runId, projectId, methodId, canViewRaw, onViewRaw, onShowConfig }) => {
342
471
  // Sample ID is the field operators look for first; pull it from the
343
472
  // top-level test.json field, falling back to the legacy nested location.
344
473
  const sampleId =
@@ -346,39 +475,203 @@ const Header: React.FC<{
346
475
  (typeof meta?.config?.sample_id === 'string' && meta.config.sample_id) ||
347
476
  '';
348
477
 
349
- // The body of the user-facing config grid should not surface the
350
- // sample_id again it's already in the header.
351
- const configWithoutSampleId = config && typeof config === 'object'
352
- ? Object.fromEntries(Object.entries(config).filter(([k]) => k !== 'sample_id'))
353
- : {};
478
+ // Config detail moved into its own dialog (operator only occasionally
479
+ // needs to consult it). The Info button only renders when there's
480
+ // something to show keeps the header tight on runs whose config
481
+ // is empty.
482
+ const hasConfigDetail = config && typeof config === 'object'
483
+ && Object.entries(config).some(([k]) => k !== 'sample_id');
354
484
 
355
485
  return (
356
486
  <div className="p-card" style={{ padding: '1rem' }}>
357
487
  <div className="flex" style={{ justifyContent: 'space-between', alignItems: 'flex-start', gap: '1rem' }}>
358
488
  <div>
359
- <h2 style={{ margin: 0 }}>{sampleId || runId}</h2>
489
+ <h2 style={{ margin: 0, display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
490
+ {sampleId || runId}
491
+ {hasConfigDetail && (
492
+ <Button
493
+ icon="pi pi-info-circle"
494
+ type="button"
495
+ rounded
496
+ text
497
+ onClick={onShowConfig}
498
+ tooltip="Show test configuration"
499
+ tooltipOptions={{ position: 'top' }}
500
+ style={{ width: '2rem', height: '2rem', padding: 0 }}
501
+ aria-label="Show test configuration"
502
+ />
503
+ )}
504
+ </h2>
360
505
  <div style={{ color: 'var(--text-secondary-color)', fontSize: '0.85em' }}>
361
506
  project: {projectId} · method: {methodId} · run: {runId}
362
507
  {meta?.start_time && <> · started: {new Date(meta.start_time).toLocaleString()}</>}
363
508
  </div>
364
509
  </div>
365
510
  {canViewRaw && (
366
- <Button icon="pi pi-chart-line" label="View Raw Data" onClick={onViewRaw} outlined />
511
+ <Button icon="pi pi-table" label="View Raw Data" onClick={onViewRaw} outlined />
367
512
  )}
368
513
  </div>
369
- {Object.keys(configWithoutSampleId).length > 0 && (
370
- <div style={{ marginTop: '0.75rem', display: 'grid',
371
- gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
372
- gap: '0.25rem 1rem', fontSize: '0.9em' }}>
373
- {Object.entries(configWithoutSampleId).map(([k, v]) => (
374
- <div key={k}><strong>{k}:</strong> {formatCell(v, 'string')}</div>
375
- ))}
376
- </div>
377
- )}
378
514
  </div>
379
515
  );
380
516
  };
381
517
 
518
+ // -------------------------------------------------------------------------
519
+ // ConfigList — flat key/value listing for the Test Configuration dialog.
520
+ // Same data the inline grid used to show, just inside its own modal.
521
+ // -------------------------------------------------------------------------
522
+ const ConfigList: React.FC<{ config: any }> = ({ config }) => {
523
+ const entries = config && typeof config === 'object'
524
+ ? Object.entries(config).filter(([k]) => k !== 'sample_id')
525
+ : [];
526
+ if (entries.length === 0) {
527
+ return (
528
+ <div style={{ color: 'var(--text-secondary-color)' }}>
529
+ No configuration recorded for this run.
530
+ </div>
531
+ );
532
+ }
533
+ return (
534
+ <div style={{ display: 'grid',
535
+ gridTemplateColumns: 'auto 1fr',
536
+ gap: '0.5rem 1rem',
537
+ fontSize: '0.95em' }}>
538
+ {entries.map(([k, v]) => (
539
+ <React.Fragment key={k}>
540
+ <div style={{ color: 'var(--text-secondary-color)' }}>{k}</div>
541
+ <div>{formatCell(v, 'string')}</div>
542
+ </React.Fragment>
543
+ ))}
544
+ </div>
545
+ );
546
+ };
547
+
548
+ // -------------------------------------------------------------------------
549
+ // DataBlobTable — tabular display of a columnar JSON blob
550
+ // (`{ col_name: number[] }`). Used by the View Raw Data dialog for both
551
+ // the Raw Data and Filtered Data tabs. Schema's `raw_data.columns` is
552
+ // consulted for column ordering and unit labels; columns present in the
553
+ // blob but not in the schema are still rendered (degrades gracefully if
554
+ // the blob carries extra channels). Rendered with virtual scrolling so
555
+ // 5000-sample captures don't blow up the DOM.
556
+ // -------------------------------------------------------------------------
557
+ const DataBlobTable: React.FC<{
558
+ blob: any | null;
559
+ loading: boolean;
560
+ error: string | null;
561
+ rawData: RawDataShape;
562
+ emptyMessage?: string;
563
+ }> = ({ blob, loading, error, rawData, emptyMessage }) => {
564
+ if (loading) {
565
+ return (
566
+ <div style={{ padding: '1rem', color: 'var(--text-secondary-color)' }}>
567
+ Loading…
568
+ </div>
569
+ );
570
+ }
571
+ if (error) {
572
+ return (
573
+ <div style={{ padding: '1rem', color: 'var(--text-secondary-color)' }}>
574
+ {emptyMessage ?? error}
575
+ </div>
576
+ );
577
+ }
578
+ if (!blob || typeof blob !== 'object') {
579
+ return (
580
+ <div style={{ padding: '1rem', color: 'var(--text-secondary-color)' }}>
581
+ No data.
582
+ </div>
583
+ );
584
+ }
585
+
586
+ // Schema-declared columns first, in declaration order; then any
587
+ // extras present in the blob that the schema didn't mention.
588
+ // Filter to columns that are actually array-valued in the blob —
589
+ // scalar fields (e.g., a top-level "checksum") shouldn't get a
590
+ // table column.
591
+ const schemaCols = Object.keys(rawData.columns ?? {});
592
+ const blobArrayCols = Object.keys(blob).filter(k => Array.isArray(blob[k]));
593
+ const orderedCols: string[] = [];
594
+ for (const c of schemaCols) {
595
+ if (Array.isArray(blob[c])) orderedCols.push(c);
596
+ }
597
+ for (const c of blobArrayCols) {
598
+ if (!orderedCols.includes(c)) orderedCols.push(c);
599
+ }
600
+
601
+ if (orderedCols.length === 0) {
602
+ return (
603
+ <div style={{ padding: '1rem', color: 'var(--text-secondary-color)' }}>
604
+ {emptyMessage ?? 'No columnar data in this blob.'}
605
+ </div>
606
+ );
607
+ }
608
+
609
+ // Row count is the shortest array — keeps the table rectangular
610
+ // even when columns disagree (rare, but the post-processing
611
+ // pipeline could produce truncated columns).
612
+ const nRows = orderedCols.reduce(
613
+ (min, c) => Math.min(min, blob[c].length),
614
+ Number.POSITIVE_INFINITY,
615
+ );
616
+ const finiteRows = Number.isFinite(nRows) ? (nRows as number) : 0;
617
+
618
+ // Materialise rows on-demand. With virtual scrolling, only the
619
+ // visible window is in the DOM, but PrimeReact's virtualScroller
620
+ // wants an array — building it lazily with `Array.from` is
621
+ // O(n_visible) per render.
622
+ const rows = Array.from({ length: finiteRows }, (_, i) => {
623
+ const row: Record<string, any> = { __i: i };
624
+ for (const c of orderedCols) row[c] = blob[c][i];
625
+ return row;
626
+ });
627
+
628
+ const headerFor = (col: string) => {
629
+ const u = rawData.units?.[col];
630
+ return u ? `${col} [${u}]` : col;
631
+ };
632
+
633
+ return (
634
+ <DataTable
635
+ value={rows}
636
+ scrollable
637
+ scrollHeight="60vh"
638
+ virtualScrollerOptions={{ itemSize: 32 }}
639
+ emptyMessage={emptyMessage ?? 'No data.'}
640
+ size="small"
641
+ stripedRows
642
+ >
643
+ <Column
644
+ field="__i"
645
+ header="#"
646
+ style={{ width: '5rem', textAlign: 'right' }}
647
+ bodyStyle={{ fontVariantNumeric: 'tabular-nums', textAlign: 'right' }}
648
+ />
649
+ {orderedCols.map(c => (
650
+ <Column
651
+ key={c}
652
+ field={c}
653
+ header={headerFor(c)}
654
+ style={{ minWidth: '8rem' }}
655
+ bodyStyle={{ fontVariantNumeric: 'tabular-nums', textAlign: 'right' }}
656
+ body={(row) => formatNumeric(row[c])}
657
+ />
658
+ ))}
659
+ </DataTable>
660
+ );
661
+ };
662
+
663
+ const formatNumeric = (v: any): string => {
664
+ if (v === null || v === undefined) return '';
665
+ if (typeof v === 'number') {
666
+ if (!Number.isFinite(v)) return String(v);
667
+ // 6 sig figs strikes a balance: enough precision for most
668
+ // engineering signals, narrow enough that columns don't grow
669
+ // wildly for nice round numbers like 0.001.
670
+ return Number.parseFloat(v.toPrecision(6)).toString();
671
+ }
672
+ return String(v);
673
+ };
674
+
382
675
  const ResultsGrid: React.FC<{ schema: TestFieldDef[]; values: any }> = ({ schema, values }) => {
383
676
  if (!values || Object.keys(values).length === 0) {
384
677
  return <div style={{ color: 'var(--text-secondary-color)' }}>No results yet.</div>;