@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.
- 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 +8 -0
- 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.map +1 -1
- package/dist/components/tis/TestRawDataView.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 +39 -0
- package/dist/components/tis/useRawCycleData.d.ts.map +1 -0
- package/dist/components/tis/useRawCycleData.js +1 -0
- 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 +256 -84
- package/src/components/tis/TestRawDataView.tsx +15 -97
- package/src/components/tis/TestSetupForm.tsx +60 -6
- 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
|
-
{
|
|
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></>
|