@adcops/autocore-react 3.3.83 → 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.
- 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 +463 -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
|
@@ -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
|
-
{
|
|
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
|
}
|