@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
@@ -0,0 +1,463 @@
1
+ /*
2
+ * <AssetEditDialog> — modal for editing an existing asset's role,
3
+ * nameplate (custom fields), and per-axis sub_locations matrix.
4
+ *
5
+ * The server's `ams.update_asset` treats `asset_id`, `asset_type`,
6
+ * `serial`, and `install_date` as immutable; this dialog mirrors that
7
+ * by rendering those four as read-only context up top. Status stays
8
+ * out of the form because the Retire button on <AssetDetailView>
9
+ * already owns that transition — keeping the dialog focused on
10
+ * "fix the values".
11
+ *
12
+ * Sibling <AssetRegistryTable>'s Add dialog covers creation. The two
13
+ * forms have similar shape but enough divergent rules (which fields
14
+ * are editable; role becomes a dropdown vs. read-only; etc.) that
15
+ * sharing one component would be more confusing than two focused
16
+ * dialogs. If we ever add a third mode the right answer is to
17
+ * extract a shared <AssetFieldsForm> sub-component then.
18
+ */
19
+
20
+ import React, { useContext, useEffect, useMemo, useState } from 'react';
21
+ import { Button } from 'primereact/button';
22
+ import { Dialog } from 'primereact/dialog';
23
+ import { Dropdown } from 'primereact/dropdown';
24
+ import { InputText } from 'primereact/inputtext';
25
+ import { EventEmitterContext } from '../../core/EventEmitterContext';
26
+ import { MessageType } from '../../hub/CommandMessage';
27
+ import { useAms, type AmsRole } from './AmsProvider';
28
+
29
+ // Sentinel: keeps the Role dropdown consistent with the Add dialog's
30
+ // "Other..." escape hatch so operators can move an asset to a custom
31
+ // role that isn't declared in project.json.
32
+ const ROLE_OTHER = '__other__';
33
+
34
+ interface SchemaField {
35
+ name: string;
36
+ type: string;
37
+ units?: string;
38
+ label?: string;
39
+ description?: string;
40
+ required?: boolean;
41
+ }
42
+
43
+ interface SubLocationsSchema {
44
+ label?: string;
45
+ key_label?: string;
46
+ keys: string[];
47
+ fields: SchemaField[];
48
+ calibration_fields?: SchemaField[];
49
+ }
50
+
51
+ export interface AssetEditDialogProps {
52
+ visible: boolean;
53
+ /** Full asset record as returned by `ams.read_asset`. The dialog
54
+ * pre-seeds every editable input from this; on submit it pins
55
+ * asset_id from here onto the update_asset call. */
56
+ asset: any | null;
57
+ onHide: () => void;
58
+ /** Fires after a successful update so the parent can refresh. */
59
+ onSaved?: () => void;
60
+ }
61
+
62
+ function fieldsFor(schemas: any, assetType: string): SchemaField[] {
63
+ const arr = schemas?.[assetType]?.fields;
64
+ return Array.isArray(arr) ? arr as SchemaField[] : [];
65
+ }
66
+
67
+ function subLocationsFor(schemas: any, assetType: string): SubLocationsSchema | null {
68
+ const sl = schemas?.[assetType]?.sub_locations;
69
+ if (!sl || !Array.isArray(sl.keys) || !Array.isArray(sl.fields)) return null;
70
+ return sl as SubLocationsSchema;
71
+ }
72
+
73
+ /** Coerce a per-cell string back to the field's declared type.
74
+ * Mirrors the Add dialog's helper so empty strings drop out, numbers
75
+ * come back as JSON numbers, bools as bools. */
76
+ function coerceField(field: SchemaField, raw: string): any {
77
+ if (raw === undefined || raw === '') return undefined;
78
+ switch (field.type) {
79
+ case 'f32': case 'f64':
80
+ case 'i8': case 'i16': case 'i32': case 'i64':
81
+ case 'u8': case 'u16': case 'u32': case 'u64': {
82
+ const n = Number(raw);
83
+ return Number.isFinite(n) ? n : undefined;
84
+ }
85
+ case 'bool':
86
+ return raw === 'true' || raw === '1';
87
+ default:
88
+ return raw;
89
+ }
90
+ }
91
+
92
+ /** Default a field's input from whatever's in the asset's custom blob.
93
+ * Numbers / bools serialize back to strings for the text inputs to
94
+ * consume; missing values become "". */
95
+ function seedFromCustom(custom: Record<string, any> | undefined, name: string): string {
96
+ if (!custom) return '';
97
+ const v = custom[name];
98
+ if (v === undefined || v === null) return '';
99
+ return String(v);
100
+ }
101
+
102
+ export const AssetEditDialog: React.FC<AssetEditDialogProps> = ({
103
+ visible, asset, onHide, onSaved,
104
+ }) => {
105
+ const { schemas, roles } = useAms();
106
+ const { invoke } = useContext(EventEmitterContext);
107
+
108
+ const assetType = asset?.asset_type ?? '';
109
+ const fields = useMemo(() => fieldsFor(schemas, assetType), [schemas, assetType]);
110
+ const subLocationsSchema = useMemo(
111
+ () => subLocationsFor(schemas, assetType), [schemas, assetType],
112
+ );
113
+ const rolesForType: AmsRole[] = useMemo(
114
+ () => (assetType ? roles[assetType] ?? [] : []),
115
+ [assetType, roles],
116
+ );
117
+ const typeHasRoles = rolesForType.length > 0;
118
+
119
+ const roleDropdownOptions = useMemo(() => {
120
+ const opts = rolesForType.map(r => ({
121
+ label: r.label ?? r.location, value: r.location,
122
+ }));
123
+ opts.push({ label: 'Other (advanced — type a custom role)', value: ROLE_OTHER });
124
+ return opts;
125
+ }, [rolesForType]);
126
+
127
+ // Form state — kept local to the dialog so editing one asset
128
+ // doesn't leak into the next open. Seeded inside the `visible`
129
+ // effect below.
130
+ const [roleSelection, setRoleSelection] = useState<string>('');
131
+ const [location, setLocation] = useState<string>('');
132
+ const [customFields, setCustomFields] =
133
+ useState<Record<string, string>>({});
134
+ const [subLocationFields, setSubLocationFields] =
135
+ useState<Record<string, Record<string, string>>>({});
136
+ const [submitting, setSubmitting] = useState(false);
137
+ const [error, setError] = useState<string | null>(null);
138
+
139
+ // Seed every input from the asset when the dialog opens. Re-seeds
140
+ // on every open so editing one asset, cancelling, then opening
141
+ // another doesn't carry the previous values forward.
142
+ useEffect(() => {
143
+ if (!visible || !asset) return;
144
+ setError(null);
145
+ setSubmitting(false);
146
+
147
+ // Role: pick the dropdown option when the asset's location
148
+ // matches a declared role; otherwise route into ROLE_OTHER so
149
+ // the operator can keep the current custom string.
150
+ const loc = typeof asset.location === 'string' ? asset.location : '';
151
+ const declaredHit = rolesForType.find(r => r.location === loc);
152
+ if (loc === '') {
153
+ setRoleSelection('');
154
+ setLocation('');
155
+ } else if (declaredHit) {
156
+ setRoleSelection(loc);
157
+ setLocation(loc);
158
+ } else {
159
+ setRoleSelection(ROLE_OTHER);
160
+ setLocation(loc);
161
+ }
162
+
163
+ // Nameplate: stringify each declared field's current value.
164
+ const seededCustom: Record<string, string> = {};
165
+ for (const f of fields) {
166
+ seededCustom[f.name] = seedFromCustom(asset.custom, f.name);
167
+ }
168
+ setCustomFields(seededCustom);
169
+
170
+ // Per-axis matrix: same treatment, walking each declared key
171
+ // and each declared field.
172
+ const seededSub: Record<string, Record<string, string>> = {};
173
+ if (subLocationsSchema) {
174
+ const subSrc = (asset.sub_locations && typeof asset.sub_locations === 'object')
175
+ ? asset.sub_locations as Record<string, any> : {};
176
+ for (const key of subLocationsSchema.keys) {
177
+ const row = (subSrc[key] && typeof subSrc[key] === 'object')
178
+ ? subSrc[key] as Record<string, any> : {};
179
+ const seededRow: Record<string, string> = {};
180
+ for (const f of subLocationsSchema.fields) {
181
+ const v = row[f.name];
182
+ seededRow[f.name] = (v === undefined || v === null) ? '' : String(v);
183
+ }
184
+ seededSub[key] = seededRow;
185
+ }
186
+ }
187
+ setSubLocationFields(seededSub);
188
+ }, [visible, asset, fields, subLocationsSchema, rolesForType]);
189
+
190
+ const onRoleChange = (value: string) => {
191
+ if (value === ROLE_OTHER) {
192
+ setRoleSelection(ROLE_OTHER);
193
+ // Keep whatever string is currently in `location` so the
194
+ // operator can tweak it rather than retyping.
195
+ } else {
196
+ setRoleSelection(value);
197
+ setLocation(value);
198
+ }
199
+ };
200
+
201
+ const onSubmit = async () => {
202
+ if (!asset?.asset_id) return;
203
+ setSubmitting(true);
204
+ setError(null);
205
+
206
+ // Build `custom` from the current input map, coercing each
207
+ // value back to its declared JSON type. Empty inputs drop out
208
+ // so the asset.json doesn't carry empty strings that the
209
+ // placeholder resolver would treat as "missing".
210
+ const custom: Record<string, any> = {};
211
+ for (const field of fields) {
212
+ const raw = customFields[field.name];
213
+ const coerced = coerceField(field, raw ?? '');
214
+ if (coerced !== undefined) custom[field.name] = coerced;
215
+ }
216
+
217
+ // Per-axis matrix: only included when the asset_type declares
218
+ // a keyed-fields schema. Empty cells drop out per-axis; the
219
+ // server re-validates with a per-key, per-field problem list
220
+ // and we surface that error inline if anything's wrong.
221
+ let subLocations: Record<string, Record<string, any>> | undefined;
222
+ if (subLocationsSchema) {
223
+ subLocations = {};
224
+ for (const key of subLocationsSchema.keys) {
225
+ const row: Record<string, any> = {};
226
+ for (const field of subLocationsSchema.fields) {
227
+ const raw = subLocationFields[key]?.[field.name] ?? '';
228
+ const coerced = coerceField(field, raw);
229
+ if (coerced !== undefined) row[field.name] = coerced;
230
+ }
231
+ subLocations[key] = row;
232
+ }
233
+ }
234
+
235
+ const payload: any = {
236
+ asset_id: asset.asset_id,
237
+ location,
238
+ custom,
239
+ };
240
+ if (subLocations) payload.sub_locations = subLocations;
241
+
242
+ try {
243
+ const resp: any = await invoke(
244
+ 'ams.update_asset' as any, MessageType.Request, payload,
245
+ );
246
+ if (resp?.success) {
247
+ onSaved?.();
248
+ onHide();
249
+ } else {
250
+ setError(resp?.error_message ?? 'update_asset failed');
251
+ }
252
+ } catch (e: any) {
253
+ setError(String(e?.message ?? e));
254
+ } finally {
255
+ setSubmitting(false);
256
+ }
257
+ };
258
+
259
+ // Gate Save on the same required-field rules the Add dialog uses,
260
+ // applied to the *current* (possibly edited) values. The server
261
+ // re-validates and we surface its error if anything slips through.
262
+ const saveDisabled =
263
+ !asset?.asset_id ||
264
+ submitting ||
265
+ (typeHasRoles && (
266
+ roleSelection === '' ||
267
+ (roleSelection === ROLE_OTHER && !location.trim())
268
+ )) ||
269
+ fields.some(f =>
270
+ f.required && !(customFields[f.name]?.toString().trim()),
271
+ ) ||
272
+ (subLocationsSchema?.keys.some(key =>
273
+ subLocationsSchema.fields.some(field =>
274
+ field.required &&
275
+ !(subLocationFields[key]?.[field.name]?.toString().trim()),
276
+ ),
277
+ ) ?? false);
278
+
279
+ if (!asset) return null;
280
+ const typeLabel = schemas[assetType]?.label ?? assetType;
281
+
282
+ return (
283
+ <Dialog
284
+ header={`Edit Asset — ${asset.asset_id}`}
285
+ visible={visible}
286
+ style={{ width: '32rem' }}
287
+ onHide={() => { if (!submitting) onHide(); }}
288
+ footer={
289
+ <>
290
+ <Button label="Cancel" severity="secondary"
291
+ onClick={onHide} disabled={submitting} />
292
+ <Button label="Save" icon="pi pi-check"
293
+ onClick={onSubmit} loading={submitting}
294
+ disabled={saveDisabled} />
295
+ </>
296
+ }
297
+ >
298
+ {/* Read-only context strip — type, serial, install_date.
299
+ The server treats these as immutable; surfacing them
300
+ here keeps the operator oriented without inviting an
301
+ edit that would silently no-op. */}
302
+ <div style={{ display: 'grid',
303
+ gridTemplateColumns: 'auto 1fr',
304
+ gap: '0.25rem 1rem',
305
+ fontSize: '0.875rem',
306
+ color: 'var(--text-secondary-color)',
307
+ marginBottom: '0.75rem' }}>
308
+ <span>Type</span> <span>{typeLabel}</span>
309
+ <span>Serial</span> <span>{asset.serial || <em>(none)</em>}</span>
310
+ {asset.install_date && (
311
+ <>
312
+ <span>Installed</span>
313
+ <span>{new Date(asset.install_date).toLocaleString()}</span>
314
+ </>
315
+ )}
316
+ </div>
317
+
318
+ <div style={{ display: 'grid',
319
+ gridTemplateColumns: 'auto 1fr',
320
+ gap: '0.5rem 1rem',
321
+ alignItems: 'center' }}>
322
+ {/* Role field. Asset types referenced only by_id_field
323
+ (no by_location asset_ref) come back with an empty
324
+ role list — we hide the row entirely so the operator
325
+ doesn't try to set a role that no test method or
326
+ module will pick up. */}
327
+ {typeHasRoles && (
328
+ <>
329
+ <label>Role *</label>
330
+ <Dropdown
331
+ value={roleSelection}
332
+ options={roleDropdownOptions}
333
+ onChange={(e) => onRoleChange(e.value)}
334
+ placeholder="Choose where this asset is mounted"
335
+ />
336
+ {roleSelection === ROLE_OTHER && (
337
+ <>
338
+ <label>Custom role</label>
339
+ <InputText
340
+ value={location}
341
+ placeholder="e.g. tsdr_secondary"
342
+ onChange={(e) => setLocation(e.target.value)}
343
+ />
344
+ </>
345
+ )}
346
+ </>
347
+ )}
348
+
349
+ {/* Schema-declared nameplate fields. */}
350
+ {fields.map(field => {
351
+ const label = field.label ?? field.name;
352
+ const fullLabel = field.units
353
+ ? `${label} [${field.units}]${field.required ? ' *' : ''}`
354
+ : `${label}${field.required ? ' *' : ''}`;
355
+ const isNum = field.type !== 'string' && field.type !== 'bool';
356
+ return (
357
+ <React.Fragment key={field.name}>
358
+ <label title={field.description ?? undefined}>
359
+ {fullLabel}
360
+ </label>
361
+ {field.type === 'bool' ? (
362
+ <Dropdown
363
+ value={customFields[field.name] ?? ''}
364
+ options={[
365
+ { label: 'true', value: 'true' },
366
+ { label: 'false', value: 'false' },
367
+ ]}
368
+ onChange={(e) => setCustomFields(s => ({
369
+ ...s, [field.name]: e.value,
370
+ }))}
371
+ placeholder="—"
372
+ />
373
+ ) : (
374
+ <InputText
375
+ value={customFields[field.name] ?? ''}
376
+ keyfilter={isNum ? 'num' : undefined}
377
+ placeholder={field.description ?? undefined}
378
+ onChange={(e) => setCustomFields(s => ({
379
+ ...s, [field.name]: e.target.value,
380
+ }))}
381
+ />
382
+ )}
383
+ </React.Fragment>
384
+ );
385
+ })}
386
+ </div>
387
+
388
+ {/* Per-axis matrix (multi-axis transducer types etc.) */}
389
+ {subLocationsSchema && (
390
+ <div style={{ marginTop: '1.5rem' }}>
391
+ <h4 style={{ margin: '0 0 0.5rem 0' }}>
392
+ {subLocationsSchema.label ?? 'Sub-locations'}
393
+ </h4>
394
+ <div style={{ overflowX: 'auto' }}>
395
+ <table style={{ width: '100%', borderCollapse: 'collapse',
396
+ fontSize: '0.875rem' }}>
397
+ <thead>
398
+ <tr>
399
+ <th style={{ textAlign: 'left', padding: '0.25rem 0.5rem',
400
+ borderBottom: '1px solid var(--surface-border)' }}>
401
+ {subLocationsSchema.key_label ?? 'Key'}
402
+ </th>
403
+ {subLocationsSchema.fields.map(f => (
404
+ <th key={f.name}
405
+ title={f.description ?? undefined}
406
+ style={{ textAlign: 'left',
407
+ padding: '0.25rem 0.5rem',
408
+ borderBottom: '1px solid var(--surface-border)' }}>
409
+ {(f.label ?? f.name)}
410
+ {f.units ? ` [${f.units}]` : ''}
411
+ {f.required ? ' *' : ''}
412
+ </th>
413
+ ))}
414
+ </tr>
415
+ </thead>
416
+ <tbody>
417
+ {subLocationsSchema.keys.map(key => (
418
+ <tr key={key}>
419
+ <td style={{ padding: '0.25rem 0.5rem', fontWeight: 600 }}>
420
+ {key}
421
+ </td>
422
+ {subLocationsSchema.fields.map(field => {
423
+ const cellValue =
424
+ subLocationFields[key]?.[field.name] ?? '';
425
+ const isNum =
426
+ field.type !== 'string' && field.type !== 'bool';
427
+ return (
428
+ <td key={field.name}
429
+ style={{ padding: '0.25rem 0.5rem' }}>
430
+ <InputText
431
+ value={cellValue}
432
+ keyfilter={isNum ? 'num' : undefined}
433
+ onChange={(e) => {
434
+ const v = e.target.value;
435
+ setSubLocationFields(s => ({
436
+ ...s,
437
+ [key]: {
438
+ ...(s[key] ?? {}),
439
+ [field.name]: v,
440
+ },
441
+ }));
442
+ }}
443
+ style={{ width: '100%' }}
444
+ />
445
+ </td>
446
+ );
447
+ })}
448
+ </tr>
449
+ ))}
450
+ </tbody>
451
+ </table>
452
+ </div>
453
+ </div>
454
+ )}
455
+
456
+ {error && (
457
+ <div style={{ marginTop: '1rem', color: '#ef4444' }}>
458
+ {error}
459
+ </div>
460
+ )}
461
+ </Dialog>
462
+ );
463
+ };
@@ -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
- {activeWifi ? (
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></>