@robotical/raftjs 2.1.0 → 2.1.2
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/devdocs/devbin-backwards-compatibility.md +105 -0
- package/devdocs/pseudocode-to-js-transpiler.md +563 -0
- package/dist/react-native/PseudocodeTranspiler.d.ts +6 -0
- package/dist/react-native/PseudocodeTranspiler.js +115 -0
- package/dist/react-native/PseudocodeTranspiler.js.map +1 -0
- package/dist/react-native/RaftAttributeHandler.d.ts +1 -1
- package/dist/react-native/RaftAttributeHandler.js +108 -32
- package/dist/react-native/RaftAttributeHandler.js.map +1 -1
- package/dist/react-native/RaftChannelBLE.web.d.ts +4 -0
- package/dist/react-native/RaftChannelBLE.web.js +59 -21
- package/dist/react-native/RaftChannelBLE.web.js.map +1 -1
- package/dist/react-native/RaftChannelSimulated.d.ts +1 -0
- package/dist/react-native/RaftChannelSimulated.js +9 -5
- package/dist/react-native/RaftChannelSimulated.js.map +1 -1
- package/dist/react-native/RaftChannelWebSocket.js +16 -1
- package/dist/react-native/RaftChannelWebSocket.js.map +1 -1
- package/dist/react-native/RaftConnector.d.ts +11 -1
- package/dist/react-native/RaftConnector.js +75 -9
- package/dist/react-native/RaftConnector.js.map +1 -1
- package/dist/react-native/RaftCustomAttrHandler.d.ts +2 -2
- package/dist/react-native/RaftCustomAttrHandler.js +32 -44
- package/dist/react-native/RaftCustomAttrHandler.js.map +1 -1
- package/dist/react-native/RaftDeviceInfo.d.ts +18 -0
- package/dist/react-native/RaftDeviceInfo.js +8 -0
- package/dist/react-native/RaftDeviceInfo.js.map +1 -1
- package/dist/react-native/RaftDeviceManager.d.ts +30 -3
- package/dist/react-native/RaftDeviceManager.js +618 -107
- package/dist/react-native/RaftDeviceManager.js.map +1 -1
- package/dist/react-native/RaftDeviceMgrIF.d.ts +11 -2
- package/dist/react-native/RaftDeviceStates.d.ts +27 -3
- package/dist/react-native/RaftDeviceStates.js +31 -6
- package/dist/react-native/RaftDeviceStates.js.map +1 -1
- package/dist/react-native/RaftFileHandler.d.ts +0 -1
- package/dist/react-native/RaftFileHandler.js +61 -23
- package/dist/react-native/RaftFileHandler.js.map +1 -1
- package/dist/react-native/RaftPublish.d.ts +2 -0
- package/dist/react-native/RaftPublish.js +81 -0
- package/dist/react-native/RaftPublish.js.map +1 -0
- package/dist/react-native/RaftStreamHandler.d.ts +11 -0
- package/dist/react-native/RaftStreamHandler.js +66 -0
- package/dist/react-native/RaftStreamHandler.js.map +1 -1
- package/dist/react-native/RaftStruct.d.ts +2 -2
- package/dist/react-native/RaftStruct.js +97 -26
- package/dist/react-native/RaftStruct.js.map +1 -1
- package/dist/react-native/RaftSystemType.d.ts +1 -0
- package/dist/react-native/RaftSystemUtils.d.ts +17 -1
- package/dist/react-native/RaftSystemUtils.js +51 -0
- package/dist/react-native/RaftSystemUtils.js.map +1 -1
- package/dist/react-native/RaftTimezone.d.ts +16 -0
- package/dist/react-native/RaftTimezone.js +153 -0
- package/dist/react-native/RaftTimezone.js.map +1 -0
- package/dist/react-native/RaftTypes.d.ts +27 -1
- package/dist/react-native/RaftTypes.js.map +1 -1
- package/dist/react-native/RaftUpdateManager.js +1 -1
- package/dist/react-native/RaftUpdateManager.js.map +1 -1
- package/dist/react-native/main.d.ts +2 -0
- package/dist/react-native/main.js +6 -1
- package/dist/react-native/main.js.map +1 -1
- package/dist/web/PseudocodeTranspiler.d.ts +6 -0
- package/dist/web/PseudocodeTranspiler.js +115 -0
- package/dist/web/PseudocodeTranspiler.js.map +1 -0
- package/dist/web/RaftAttributeHandler.d.ts +1 -1
- package/dist/web/RaftAttributeHandler.js +108 -32
- package/dist/web/RaftAttributeHandler.js.map +1 -1
- package/dist/web/RaftChannelBLE.web.d.ts +4 -0
- package/dist/web/RaftChannelBLE.web.js +59 -21
- package/dist/web/RaftChannelBLE.web.js.map +1 -1
- package/dist/web/RaftChannelSimulated.d.ts +1 -0
- package/dist/web/RaftChannelSimulated.js +9 -5
- package/dist/web/RaftChannelSimulated.js.map +1 -1
- package/dist/web/RaftChannelWebSocket.js +16 -1
- package/dist/web/RaftChannelWebSocket.js.map +1 -1
- package/dist/web/RaftConnector.d.ts +11 -1
- package/dist/web/RaftConnector.js +75 -9
- package/dist/web/RaftConnector.js.map +1 -1
- package/dist/web/RaftCustomAttrHandler.d.ts +2 -2
- package/dist/web/RaftCustomAttrHandler.js +32 -44
- package/dist/web/RaftCustomAttrHandler.js.map +1 -1
- package/dist/web/RaftDeviceInfo.d.ts +18 -0
- package/dist/web/RaftDeviceInfo.js +8 -0
- package/dist/web/RaftDeviceInfo.js.map +1 -1
- package/dist/web/RaftDeviceManager.d.ts +30 -3
- package/dist/web/RaftDeviceManager.js +618 -107
- package/dist/web/RaftDeviceManager.js.map +1 -1
- package/dist/web/RaftDeviceMgrIF.d.ts +11 -2
- package/dist/web/RaftDeviceStates.d.ts +27 -3
- package/dist/web/RaftDeviceStates.js +31 -6
- package/dist/web/RaftDeviceStates.js.map +1 -1
- package/dist/web/RaftFileHandler.d.ts +0 -1
- package/dist/web/RaftFileHandler.js +61 -23
- package/dist/web/RaftFileHandler.js.map +1 -1
- package/dist/web/RaftPublish.d.ts +2 -0
- package/dist/web/RaftPublish.js +81 -0
- package/dist/web/RaftPublish.js.map +1 -0
- package/dist/web/RaftStreamHandler.d.ts +11 -0
- package/dist/web/RaftStreamHandler.js +66 -0
- package/dist/web/RaftStreamHandler.js.map +1 -1
- package/dist/web/RaftStruct.d.ts +2 -2
- package/dist/web/RaftStruct.js +97 -26
- package/dist/web/RaftStruct.js.map +1 -1
- package/dist/web/RaftSystemType.d.ts +1 -0
- package/dist/web/RaftSystemUtils.d.ts +17 -1
- package/dist/web/RaftSystemUtils.js +51 -0
- package/dist/web/RaftSystemUtils.js.map +1 -1
- package/dist/web/RaftTimezone.d.ts +16 -0
- package/dist/web/RaftTimezone.js +153 -0
- package/dist/web/RaftTimezone.js.map +1 -0
- package/dist/web/RaftTypes.d.ts +27 -1
- package/dist/web/RaftTypes.js.map +1 -1
- package/dist/web/RaftUpdateManager.js +1 -1
- package/dist/web/RaftUpdateManager.js.map +1 -1
- package/dist/web/main.d.ts +2 -0
- package/dist/web/main.js +6 -1
- package/dist/web/main.js.map +1 -1
- package/examples/dashboard/package.json +2 -2
- package/examples/dashboard/src/DeviceActionsForm.tsx +158 -8
- package/examples/dashboard/src/DeviceLineChart.tsx +16 -3
- package/examples/dashboard/src/DevicePanel.tsx +92 -11
- package/examples/dashboard/src/DeviceSelectDialog.tsx +224 -0
- package/examples/dashboard/src/DeviceStatsPanel.tsx +76 -0
- package/examples/dashboard/src/DevicesPanel.tsx +11 -0
- package/examples/dashboard/src/LogConfigPanel.tsx +357 -0
- package/examples/dashboard/src/LogFilesPanel.tsx +200 -0
- package/examples/dashboard/src/LoggingPanel.tsx +264 -0
- package/examples/dashboard/src/Main.tsx +12 -2
- package/examples/dashboard/src/SettingsScreen.tsx +9 -4
- package/examples/dashboard/src/SystemTypeCog/CogStateInfo.ts +10 -3
- package/examples/dashboard/src/SystemTypeCog/SystemTypeCog.ts +37 -3
- package/examples/dashboard/src/SystemTypeGeneric/StateInfoGeneric.ts +10 -2
- package/examples/dashboard/src/SystemTypeGeneric/SystemTypeGeneric.ts +41 -7
- package/examples/dashboard/src/SystemTypeMarty/RICStateInfo.ts +34 -3
- package/examples/dashboard/src/styles.css +766 -1
- package/notes/web-ble-reconnect-retry.md +69 -0
- package/package.json +10 -7
- package/src/PseudocodeTranspiler.test.ts +372 -0
- package/src/PseudocodeTranspiler.ts +127 -0
- package/src/RaftAttributeHandler.ts +152 -76
- package/src/RaftChannelBLE.web.ts +62 -20
- package/src/RaftChannelSimulated.ts +10 -5
- package/src/RaftChannelWebSocket.ts +16 -2
- package/src/RaftConnector.ts +93 -15
- package/src/RaftCustomAttrHandler.ts +35 -45
- package/src/RaftDeviceInfo.ts +27 -0
- package/src/RaftDeviceManager.test.ts +164 -0
- package/src/RaftDeviceManager.ts +705 -127
- package/src/RaftDeviceMgrIF.ts +13 -2
- package/src/RaftDeviceStates.ts +49 -8
- package/src/RaftFileHandler.ts +69 -28
- package/src/RaftPublish.ts +92 -0
- package/src/RaftStreamHandler.ts +84 -1
- package/src/RaftStruct.test.ts +229 -0
- package/src/RaftStruct.ts +101 -37
- package/src/RaftSystemType.ts +1 -0
- package/src/RaftSystemUtils.ts +59 -0
- package/src/RaftTimezone.ts +151 -0
- package/src/RaftTypes.ts +34 -1
- package/src/RaftUpdateManager.ts +1 -1
- package/src/main.ts +2 -0
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
import React, { useEffect, useRef, useState } from 'react';
|
|
2
2
|
import ConnManager from './ConnManager';
|
|
3
|
-
import { DeviceTypeAction } from '../../../src/RaftDeviceInfo';
|
|
3
|
+
import { DeviceTypeAction, ActionMapEntry } from '../../../src/RaftDeviceInfo';
|
|
4
4
|
import DispLEDGrid from './DispLedGrid';
|
|
5
5
|
|
|
6
6
|
const connManager = ConnManager.getInstance();
|
|
7
7
|
|
|
8
|
+
// Generic sample rate options for devices without _conf.rate
|
|
9
|
+
const GENERIC_SAMPLE_RATES = [50, 20, 10, 5, 2, 1, 0.5, 0.2, 0.1, 0.01, 0.001];
|
|
10
|
+
|
|
11
|
+
// Find the closest value in an array to a target
|
|
12
|
+
function findClosest(arr: number[], target: number): number {
|
|
13
|
+
return arr.reduce((prev, curr) =>
|
|
14
|
+
Math.abs(curr - target) < Math.abs(prev - target) ? curr : prev
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
8
18
|
type DeviceActionsTableProps = {
|
|
9
19
|
deviceKey: string;
|
|
10
20
|
};
|
|
@@ -17,18 +27,29 @@ const DeviceActionsForm: React.FC<DeviceActionsTableProps> = ({ deviceKey }: Dev
|
|
|
17
27
|
const deviceManager = connManager.getConnector().getSystemType()?.deviceMgrIF;
|
|
18
28
|
const [deviceActions, setDeviceActions] = useState<DeviceTypeAction[]>([]);
|
|
19
29
|
const [inputValues, setInputValues] = useState<InputValues>({});
|
|
30
|
+
const [actionStatus, setActionStatus] = useState<string>('');
|
|
31
|
+
const [genericRateHz, setGenericRateHz] = useState<number>(10);
|
|
32
|
+
const [isBusDevice, setIsBusDevice] = useState<boolean>(false);
|
|
33
|
+
const [hasConfRate, setHasConfRate] = useState<boolean>(false);
|
|
20
34
|
|
|
21
35
|
useEffect(() => {
|
|
22
36
|
if (!deviceManager) {
|
|
23
37
|
return;
|
|
24
38
|
}
|
|
25
39
|
// Wait a little while inline for the device to be ready
|
|
26
|
-
setTimeout(() => {
|
|
40
|
+
setTimeout(async () => {
|
|
27
41
|
const deviceState = deviceManager.getDeviceState(deviceKey);
|
|
28
42
|
const { deviceTypeInfo } = deviceState;
|
|
29
43
|
const actions: DeviceTypeAction[] = deviceTypeInfo?.actions || [];
|
|
30
44
|
setDeviceActions(actions);
|
|
31
|
-
//
|
|
45
|
+
// Check if this is a bus device (has a valid busName)
|
|
46
|
+
const busName = deviceState?.busName ?? '';
|
|
47
|
+
const isBus = busName !== '' && busName !== '0';
|
|
48
|
+
setIsBusDevice(isBus);
|
|
49
|
+
// Check if device has _conf.rate action
|
|
50
|
+
const confRateAction = actions.find(a => a.n === '_conf.rate');
|
|
51
|
+
setHasConfRate(!!confRateAction);
|
|
52
|
+
// Initialize input values with defaults
|
|
32
53
|
const initialValues: InputValues = actions.reduce((acc, action) => {
|
|
33
54
|
acc[action.n] =
|
|
34
55
|
action.d ??
|
|
@@ -39,6 +60,47 @@ const DeviceActionsForm: React.FC<DeviceActionsTableProps> = ({ deviceKey }: Dev
|
|
|
39
60
|
: 0);
|
|
40
61
|
return acc;
|
|
41
62
|
}, {} as InputValues);
|
|
63
|
+
|
|
64
|
+
// Query current poll config from firmware to initialize rate dropdowns
|
|
65
|
+
if (isBus && deviceState?.deviceAddress) {
|
|
66
|
+
try {
|
|
67
|
+
const cmd = `devman/devconfig?bus=${busName}&addr=${deviceState.deviceAddress}`;
|
|
68
|
+
const resp = await connManager.getConnector().sendRICRESTMsg(cmd, {}) as any;
|
|
69
|
+
if (resp?.rslt === 'ok' && resp.pollIntervalUs > 0) {
|
|
70
|
+
const currentRateHz = 1000000 / resp.pollIntervalUs;
|
|
71
|
+
if (confRateAction?.map) {
|
|
72
|
+
// For _conf.rate: find the map key whose interval and numSamples
|
|
73
|
+
// best match the current config (both i and s needed to disambiguate
|
|
74
|
+
// e.g. 52Hz and 104Hz both use i=50000 but differ in s)
|
|
75
|
+
let bestKey = String(confRateAction.d ?? '');
|
|
76
|
+
let bestDist = Infinity;
|
|
77
|
+
for (const [key, entry] of Object.entries(confRateAction.map)) {
|
|
78
|
+
const mapEntry = entry as ActionMapEntry;
|
|
79
|
+
if (mapEntry.i !== undefined) {
|
|
80
|
+
let dist = Math.abs(mapEntry.i - resp.pollIntervalUs);
|
|
81
|
+
// Add penalty for numSamples mismatch to disambiguate entries with same interval
|
|
82
|
+
if (mapEntry.s !== undefined && resp.numSamples !== undefined) {
|
|
83
|
+
dist += Math.abs(mapEntry.s - resp.numSamples) * 1000000;
|
|
84
|
+
}
|
|
85
|
+
if (dist < bestDist) {
|
|
86
|
+
bestDist = dist;
|
|
87
|
+
bestKey = key;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (bestKey) {
|
|
92
|
+
initialValues['_conf.rate'] = parseFloat(bestKey);
|
|
93
|
+
}
|
|
94
|
+
} else {
|
|
95
|
+
// For generic rate dropdown: find closest option
|
|
96
|
+
setGenericRateHz(findClosest(GENERIC_SAMPLE_RATES, currentRateHz));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
} catch (err) {
|
|
100
|
+
// Ignore query errors — keep defaults
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
42
104
|
setInputValues(initialValues);
|
|
43
105
|
}, 1000);
|
|
44
106
|
}, [deviceKey]);
|
|
@@ -50,15 +112,44 @@ const DeviceActionsForm: React.FC<DeviceActionsTableProps> = ({ deviceKey }: Dev
|
|
|
50
112
|
}));
|
|
51
113
|
};
|
|
52
114
|
|
|
53
|
-
const handleSendAction = (action: DeviceTypeAction, value: number) => {
|
|
115
|
+
const handleSendAction = async (action: DeviceTypeAction, value: number) => {
|
|
54
116
|
// Send action to device
|
|
55
117
|
if (!deviceManager) {
|
|
56
118
|
return;
|
|
57
119
|
}
|
|
58
|
-
|
|
120
|
+
// For _conf.rate actions, use setSampleRate for coordinated polling config
|
|
121
|
+
if (action.n === '_conf.rate' && action.map) {
|
|
122
|
+
setActionStatus('Setting sample rate...');
|
|
123
|
+
const result = await deviceManager.setSampleRate(deviceKey, value);
|
|
124
|
+
if (result.ok) {
|
|
125
|
+
setActionStatus(`Rate: ${result.actualRateHz} Hz, poll: ${result.intervalUs} µs, buf: ${result.numSamples}`);
|
|
126
|
+
} else {
|
|
127
|
+
setActionStatus(`Error: ${result.error}`);
|
|
128
|
+
}
|
|
129
|
+
setTimeout(() => setActionStatus(''), 5000);
|
|
130
|
+
} else {
|
|
131
|
+
deviceManager.sendAction(deviceKey, action, [value]);
|
|
132
|
+
}
|
|
59
133
|
};
|
|
60
134
|
|
|
61
|
-
|
|
135
|
+
const handleGenericRateSend = async () => {
|
|
136
|
+
if (!deviceManager) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
setActionStatus('Setting sample rate...');
|
|
140
|
+
const result = await deviceManager.setSampleRate(deviceKey, genericRateHz);
|
|
141
|
+
if (result.ok) {
|
|
142
|
+
setActionStatus(`Rate: ${result.actualRateHz} Hz, poll: ${result.intervalUs} µs, buf: ${result.numSamples}`);
|
|
143
|
+
} else {
|
|
144
|
+
setActionStatus(`Error: ${result.error}`);
|
|
145
|
+
}
|
|
146
|
+
setTimeout(() => setActionStatus(''), 5000);
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// Show generic rate control for bus devices without _conf.rate
|
|
150
|
+
const showGenericRate = isBusDevice && !hasConfRate;
|
|
151
|
+
|
|
152
|
+
if (deviceActions.length === 0 && !showGenericRate) {
|
|
62
153
|
return <></>;
|
|
63
154
|
}
|
|
64
155
|
|
|
@@ -80,14 +171,52 @@ const DeviceActionsForm: React.FC<DeviceActionsTableProps> = ({ deviceKey }: Dev
|
|
|
80
171
|
<td>{action.n}</td>
|
|
81
172
|
<td colSpan={2}>
|
|
82
173
|
<DispLEDGrid
|
|
83
|
-
rows={action.NY
|
|
84
|
-
cols={action.NX
|
|
174
|
+
rows={action.NY ?? 1}
|
|
175
|
+
cols={action.NX ?? 1}
|
|
85
176
|
deviceKey={deviceKey}
|
|
86
177
|
deviceAction={action}
|
|
87
178
|
/>
|
|
88
179
|
</td>
|
|
89
180
|
</tr>
|
|
90
181
|
);
|
|
182
|
+
} else if (action.map) {
|
|
183
|
+
const mapKeys = Object.keys(action.map).sort((a, b) => parseFloat(a) - parseFloat(b));
|
|
184
|
+
// Use "Rate Hz" label for _conf.rate actions
|
|
185
|
+
const actionLabel = action.n === '_conf.rate' ? 'Rate Hz' : (action.desc ?? action.n);
|
|
186
|
+
return (
|
|
187
|
+
<tr key={action.n}>
|
|
188
|
+
<td>{actionLabel}</td>
|
|
189
|
+
<td>
|
|
190
|
+
<select
|
|
191
|
+
value={inputValues[action.n]}
|
|
192
|
+
onChange={(e) =>
|
|
193
|
+
handleInputChange(
|
|
194
|
+
action.n,
|
|
195
|
+
parseFloat(e.target.value)
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
>
|
|
199
|
+
{mapKeys.map((key) => (
|
|
200
|
+
<option key={key} value={parseFloat(key)}>
|
|
201
|
+
{key}
|
|
202
|
+
</option>
|
|
203
|
+
))}
|
|
204
|
+
</select>
|
|
205
|
+
</td>
|
|
206
|
+
<td>
|
|
207
|
+
<button
|
|
208
|
+
onClick={() =>
|
|
209
|
+
handleSendAction(
|
|
210
|
+
action,
|
|
211
|
+
inputValues[action.n]
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
>
|
|
215
|
+
Send
|
|
216
|
+
</button>
|
|
217
|
+
</td>
|
|
218
|
+
</tr>
|
|
219
|
+
);
|
|
91
220
|
} else {
|
|
92
221
|
return (
|
|
93
222
|
<tr key={action.n}>
|
|
@@ -124,8 +253,29 @@ const DeviceActionsForm: React.FC<DeviceActionsTableProps> = ({ deviceKey }: Dev
|
|
|
124
253
|
);
|
|
125
254
|
}
|
|
126
255
|
})}
|
|
256
|
+
{showGenericRate && (
|
|
257
|
+
<tr key="__generic_rate">
|
|
258
|
+
<td>Rate Hz</td>
|
|
259
|
+
<td>
|
|
260
|
+
<select
|
|
261
|
+
value={genericRateHz}
|
|
262
|
+
onChange={(e) => setGenericRateHz(parseFloat(e.target.value))}
|
|
263
|
+
>
|
|
264
|
+
{GENERIC_SAMPLE_RATES.map((rate) => (
|
|
265
|
+
<option key={rate} value={rate}>
|
|
266
|
+
{rate >= 1 ? `${rate} Hz` : `${rate} Hz`}
|
|
267
|
+
</option>
|
|
268
|
+
))}
|
|
269
|
+
</select>
|
|
270
|
+
</td>
|
|
271
|
+
<td>
|
|
272
|
+
<button onClick={handleGenericRateSend}>Send</button>
|
|
273
|
+
</td>
|
|
274
|
+
</tr>
|
|
275
|
+
)}
|
|
127
276
|
</tbody>
|
|
128
277
|
</table>
|
|
278
|
+
{actionStatus && <div className="action-status">{actionStatus}</div>}
|
|
129
279
|
</div>
|
|
130
280
|
);
|
|
131
281
|
};
|
|
@@ -47,13 +47,15 @@ const DeviceLineChart: React.FC<DeviceLineChartProps> = memo(({ deviceKey, lastU
|
|
|
47
47
|
datasets: []
|
|
48
48
|
});
|
|
49
49
|
|
|
50
|
+
const [chartScales, setChartScales] = useState<{ [key: string]: any }>({});
|
|
51
|
+
|
|
50
52
|
const options = {
|
|
51
53
|
responsive: true,
|
|
52
54
|
maintainAspectRatio: false,
|
|
53
55
|
animation: {
|
|
54
56
|
duration: 1, // default is 1000ms
|
|
55
57
|
},
|
|
56
|
-
scales:
|
|
58
|
+
scales: chartScales
|
|
57
59
|
};
|
|
58
60
|
|
|
59
61
|
const colourMapRef = useRef<{ [key: string]: string }>({
|
|
@@ -100,7 +102,12 @@ const DeviceLineChart: React.FC<DeviceLineChartProps> = memo(({ deviceKey, lastU
|
|
|
100
102
|
|
|
101
103
|
const uniqueAxes = new Map<string, { range: [number, number], units: string }>();
|
|
102
104
|
const datasets = Object.entries(deviceState.deviceAttributes)
|
|
103
|
-
.filter(([attributeName, attributeDetails]) =>
|
|
105
|
+
.filter(([attributeName, attributeDetails]) => {
|
|
106
|
+
if (attributeDetails.visibleSeries === false) return false;
|
|
107
|
+
// Exclude string-valued attributes from chart
|
|
108
|
+
if (attributeDetails.values.length > 0 && typeof attributeDetails.values[attributeDetails.values.length - 1] === 'string') return false;
|
|
109
|
+
return true;
|
|
110
|
+
})
|
|
104
111
|
.map(([attributeName, attributeDetails]) => {
|
|
105
112
|
const data = attributeDetails.values.slice(-maxChartDataPoints);
|
|
106
113
|
let colour = colourMapRef.current[attributeName];
|
|
@@ -140,12 +147,18 @@ const DeviceLineChart: React.FC<DeviceLineChartProps> = memo(({ deviceKey, lastU
|
|
|
140
147
|
ticks: {
|
|
141
148
|
min: axis.range[0],
|
|
142
149
|
max: axis.range[1],
|
|
150
|
+
callback: (value: number) => {
|
|
151
|
+
if (typeof value === 'number' && Math.abs(value) > 0 && Math.abs(value) < 1) {
|
|
152
|
+
return value.toPrecision(2);
|
|
153
|
+
}
|
|
154
|
+
return value;
|
|
155
|
+
},
|
|
143
156
|
},
|
|
144
157
|
};
|
|
145
158
|
});
|
|
146
159
|
|
|
147
160
|
// Update options and chart data
|
|
148
|
-
|
|
161
|
+
setChartScales(scales);
|
|
149
162
|
setChartData({ labels: labels.length ? labels : ['0.000'], datasets });
|
|
150
163
|
}, [lastUpdated]);
|
|
151
164
|
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import React, { useEffect, useRef, useState } from 'react';
|
|
2
2
|
import './styles.css';
|
|
3
|
-
import { DeviceState } from '../../../src/RaftDeviceStates';
|
|
3
|
+
import { DeviceState, DeviceOnlineState } from '../../../src/RaftDeviceStates';
|
|
4
4
|
import DeviceAttrsForm from './DeviceAttrsForm';
|
|
5
5
|
import DeviceActionsForm from './DeviceActionsForm';
|
|
6
6
|
import DeviceLineChart from './DeviceLineChart';
|
|
7
|
+
import DeviceStatsPanel from './DeviceStatsPanel';
|
|
7
8
|
import ConnManager from './ConnManager';
|
|
8
9
|
import SettingsManager from './SettingsManager';
|
|
9
10
|
|
|
@@ -19,10 +20,14 @@ const DevicePanel = ({ deviceKey, lastUpdated }: DevicePanelProps) => {
|
|
|
19
20
|
const deviceState: DeviceState | undefined = deviceManager?.getDeviceState(deviceKey);
|
|
20
21
|
|
|
21
22
|
// Gray out the device panel if the device is offline
|
|
22
|
-
const offlineClass = deviceState?.
|
|
23
|
+
const offlineClass = deviceState?.onlineState === DeviceOnlineState.Online ? '' : 'offline';
|
|
23
24
|
|
|
24
25
|
const [timedChartUpdate, setTimedChartUpdate] = useState<number>(0);
|
|
25
26
|
const [menuOpen, setMenuOpen] = useState<boolean>(false);
|
|
27
|
+
const [showPollRateDialog, setShowPollRateDialog] = useState<boolean>(false);
|
|
28
|
+
const [pollRateInput, setPollRateInput] = useState<string>('');
|
|
29
|
+
const [pollRateStatus, setPollRateStatus] = useState<string>('');
|
|
30
|
+
const [showStats, setShowStats] = useState<boolean>(false);
|
|
26
31
|
const menuRef = useRef<HTMLDivElement>(null);
|
|
27
32
|
|
|
28
33
|
const settingsManager = SettingsManager.getInstance();
|
|
@@ -92,6 +97,34 @@ const DevicePanel = ({ deviceKey, lastUpdated }: DevicePanelProps) => {
|
|
|
92
97
|
setMenuOpen(false);
|
|
93
98
|
};
|
|
94
99
|
|
|
100
|
+
const handleSetPollRateClick = () => {
|
|
101
|
+
setMenuOpen(false);
|
|
102
|
+
setPollRateInput('');
|
|
103
|
+
setPollRateStatus('');
|
|
104
|
+
setShowPollRateDialog(true);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const handlePollRateSubmit = () => {
|
|
108
|
+
const rateHz = parseFloat(pollRateInput);
|
|
109
|
+
if (isNaN(rateHz) || rateHz <= 0) {
|
|
110
|
+
setPollRateStatus('Invalid rate — enter a positive number');
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const intervalUs = Math.round(1000000 / rateHz);
|
|
114
|
+
const busName = deviceState?.busName ?? '0';
|
|
115
|
+
const addr = deviceState?.deviceAddress ?? '0';
|
|
116
|
+
const cmd = `devman/devconfig?bus=${busName}&addr=${addr}&intervalUs=${intervalUs}`;
|
|
117
|
+
setPollRateStatus('Sending...');
|
|
118
|
+
connManager.getConnector().sendRICRESTMsg(cmd, {}).then((response: object) => {
|
|
119
|
+
console.log(`Poll rate set: ${rateHz} Hz (${intervalUs} us)`, response);
|
|
120
|
+
setPollRateStatus(`Set to ${rateHz} Hz (${intervalUs} µs)`);
|
|
121
|
+
setTimeout(() => setShowPollRateDialog(false), 1500);
|
|
122
|
+
}).catch((error: unknown) => {
|
|
123
|
+
console.warn('Error setting poll rate', error);
|
|
124
|
+
setPollRateStatus('Error setting poll rate');
|
|
125
|
+
});
|
|
126
|
+
};
|
|
127
|
+
|
|
95
128
|
const fallbackCopyTextToClipboard = (text: string) => {
|
|
96
129
|
const textArea = document.createElement("textarea");
|
|
97
130
|
textArea.value = text;
|
|
@@ -117,25 +150,30 @@ const DevicePanel = ({ deviceKey, lastUpdated }: DevicePanelProps) => {
|
|
|
117
150
|
};
|
|
118
151
|
|
|
119
152
|
let headerText = `Device ${deviceState?.deviceTypeInfo?.name}`;
|
|
153
|
+
let bracketsAdded = false;
|
|
154
|
+
if ((deviceState?.busName !== undefined) && (deviceState?.busName !== "") && (deviceState?.busName !== "0")) {
|
|
155
|
+
headerText += ` (Bus ${deviceState?.busName}`;
|
|
156
|
+
bracketsAdded = true;
|
|
157
|
+
}
|
|
120
158
|
if ((deviceState?.deviceAddress !== undefined) && (deviceState?.deviceAddress !== "") && (deviceState?.deviceAddress !== "0")) {
|
|
121
159
|
// See if we can identify I2C addresses - should start with two bytes of 0s and then have a byte which is slot and a byte which is address
|
|
122
|
-
const addrInt = parseInt(deviceState?.deviceAddress,
|
|
160
|
+
const addrInt = parseInt(deviceState?.deviceAddress, 16);
|
|
123
161
|
if (addrInt < 65536) {
|
|
124
162
|
const slot = addrInt >> 8;
|
|
125
|
-
const address = ("00" + (addrInt & 0xFF).toString(16)).slice(-2);
|
|
126
|
-
headerText += ` I2C Address 0x${address}`;
|
|
127
163
|
if (slot === 0)
|
|
128
|
-
headerText += `
|
|
164
|
+
headerText += ` Main Bus`;
|
|
129
165
|
else
|
|
130
|
-
headerText += `
|
|
166
|
+
headerText += ` Slot ${slot}`;
|
|
167
|
+
const address = ("00" + (addrInt & 0xFF).toString(16)).slice(-2);
|
|
168
|
+
headerText += ` Addr 0x${address}`;
|
|
131
169
|
} else {
|
|
132
|
-
headerText += `
|
|
170
|
+
headerText += ` Addr ${deviceState?.deviceAddress}`;
|
|
133
171
|
}
|
|
134
172
|
}
|
|
135
|
-
if (
|
|
136
|
-
headerText += `
|
|
173
|
+
if (bracketsAdded) {
|
|
174
|
+
headerText += `)`;
|
|
137
175
|
}
|
|
138
|
-
if (
|
|
176
|
+
if (deviceState?.onlineState !== DeviceOnlineState.Online) {
|
|
139
177
|
headerText += " (Offline)";
|
|
140
178
|
}
|
|
141
179
|
|
|
@@ -147,14 +185,57 @@ const DevicePanel = ({ deviceKey, lastUpdated }: DevicePanelProps) => {
|
|
|
147
185
|
{menuOpen && (
|
|
148
186
|
<div className="dropdown-menu" ref={menuRef}>
|
|
149
187
|
<div className="menu-item always-enabled" onClick={handleCopyToClipboard}>Copy Values to Clipboard</div>
|
|
188
|
+
<div className="menu-item always-enabled" onClick={handleSetPollRateClick}>Set Poll Rate</div>
|
|
189
|
+
<div className="menu-item always-enabled menu-item-toggle">
|
|
190
|
+
<label className="menu-toggle">
|
|
191
|
+
<input
|
|
192
|
+
type="checkbox"
|
|
193
|
+
checked={showStats}
|
|
194
|
+
onChange={(e) => setShowStats(e.target.checked)}
|
|
195
|
+
/>
|
|
196
|
+
<span>Show Stats</span>
|
|
197
|
+
</label>
|
|
198
|
+
</div>
|
|
150
199
|
</div>
|
|
151
200
|
)}
|
|
152
201
|
</div>
|
|
202
|
+
{showPollRateDialog && (
|
|
203
|
+
<div className="poll-rate-dialog-overlay" onClick={() => setShowPollRateDialog(false)}>
|
|
204
|
+
<div className="poll-rate-dialog" onClick={(e) => e.stopPropagation()}>
|
|
205
|
+
<div className="poll-rate-dialog-title">Set Poll Rate</div>
|
|
206
|
+
<div className="poll-rate-dialog-row">
|
|
207
|
+
<input
|
|
208
|
+
className="poll-rate-input"
|
|
209
|
+
type="number"
|
|
210
|
+
min="0.001"
|
|
211
|
+
step="any"
|
|
212
|
+
placeholder="Rate (Hz)"
|
|
213
|
+
value={pollRateInput}
|
|
214
|
+
onChange={(e) => setPollRateInput(e.target.value)}
|
|
215
|
+
onKeyDown={(e) => { if (e.key === 'Enter') handlePollRateSubmit(); if (e.key === 'Escape') setShowPollRateDialog(false); }}
|
|
216
|
+
autoFocus
|
|
217
|
+
/>
|
|
218
|
+
<span className="poll-rate-unit">Hz</span>
|
|
219
|
+
</div>
|
|
220
|
+
{pollRateInput && !isNaN(parseFloat(pollRateInput)) && parseFloat(pollRateInput) > 0 && (
|
|
221
|
+
<div className="poll-rate-preview">{Math.round(1000000 / parseFloat(pollRateInput))} µs interval</div>
|
|
222
|
+
)}
|
|
223
|
+
{pollRateStatus && <div className="poll-rate-status">{pollRateStatus}</div>}
|
|
224
|
+
<div className="poll-rate-dialog-buttons">
|
|
225
|
+
<button className="poll-rate-btn poll-rate-btn-set" onClick={handlePollRateSubmit}>Set</button>
|
|
226
|
+
<button className="poll-rate-btn poll-rate-btn-cancel" onClick={() => setShowPollRateDialog(false)}>Cancel</button>
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
)}
|
|
153
231
|
<div className={`device-block-data`}>
|
|
154
232
|
<div className="device-attrs-and-actions">
|
|
155
233
|
<DeviceAttrsForm deviceKey={deviceKey} lastUpdated={lastUpdated} />
|
|
156
234
|
<DeviceActionsForm deviceKey={deviceKey} />
|
|
157
235
|
</div>
|
|
236
|
+
{showStats && (
|
|
237
|
+
<DeviceStatsPanel deviceKey={deviceKey} lastUpdated={timedChartUpdate} />
|
|
238
|
+
)}
|
|
158
239
|
{showCharts &&
|
|
159
240
|
<DeviceLineChart deviceKey={deviceKey} lastUpdated={timedChartUpdate} />
|
|
160
241
|
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import './styles.css';
|
|
3
|
+
|
|
4
|
+
// Rate presets: label + milliseconds
|
|
5
|
+
const RATE_PRESETS = [
|
|
6
|
+
{ label: 'Max (poll rate)', ms: 0 },
|
|
7
|
+
{ label: '10 Hz', ms: 100 },
|
|
8
|
+
{ label: '1 Hz', ms: 1000 },
|
|
9
|
+
{ label: '0.1 Hz (10s)', ms: 10000 },
|
|
10
|
+
{ label: '1/min', ms: 60000 },
|
|
11
|
+
{ label: '1/10min', ms: 600000 },
|
|
12
|
+
{ label: '1/hour', ms: 3600000 },
|
|
13
|
+
{ label: '1/day', ms: 86400000 },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
// Log-scale slider range
|
|
17
|
+
const LOG_RATE_MIN_MS = 50;
|
|
18
|
+
const LOG_RATE_MAX_MS = 360000000;
|
|
19
|
+
|
|
20
|
+
function msToSliderValue(ms: number): number {
|
|
21
|
+
if (ms <= 0) return 0;
|
|
22
|
+
const minLog = Math.log10(LOG_RATE_MIN_MS);
|
|
23
|
+
const maxLog = Math.log10(LOG_RATE_MAX_MS);
|
|
24
|
+
const val = (Math.log10(Math.max(ms, LOG_RATE_MIN_MS)) - minLog) / (maxLog - minLog);
|
|
25
|
+
return Math.min(1, Math.max(0, val));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function sliderValueToMs(val: number): number {
|
|
29
|
+
if (val <= 0) return 0;
|
|
30
|
+
const minLog = Math.log10(LOG_RATE_MIN_MS);
|
|
31
|
+
const maxLog = Math.log10(LOG_RATE_MAX_MS);
|
|
32
|
+
return Math.round(Math.pow(10, minLog + val * (maxLog - minLog)));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function formatRateMs(ms: number): string {
|
|
36
|
+
if (ms <= 0) return 'Max (every poll)';
|
|
37
|
+
if (ms < 1000) return `${ms} ms (${(1000 / ms).toFixed(1)} Hz)`;
|
|
38
|
+
if (ms < 60000) return `${(ms / 1000).toFixed(1)} s (${(1000 / ms).toFixed(2)} Hz)`;
|
|
39
|
+
if (ms < 3600000) return `${(ms / 60000).toFixed(1)} min`;
|
|
40
|
+
if (ms < 86400000) return `${(ms / 3600000).toFixed(1)} hr`;
|
|
41
|
+
return `${(ms / 86400000).toFixed(1)} days`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface DeviceLogEntry {
|
|
45
|
+
enabled: boolean;
|
|
46
|
+
busName: string;
|
|
47
|
+
addr: string;
|
|
48
|
+
typeName: string;
|
|
49
|
+
rateMs: number;
|
|
50
|
+
pollIntervalMs: number;
|
|
51
|
+
availableAttrs: string[];
|
|
52
|
+
selectedAttrs: string[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface DeviceSelectDialogProps {
|
|
56
|
+
entries: DeviceLogEntry[];
|
|
57
|
+
format: string;
|
|
58
|
+
onSave: (entries: DeviceLogEntry[]) => void;
|
|
59
|
+
onCancel: () => void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export default function DeviceSelectDialog({ entries, format, onSave, onCancel }: DeviceSelectDialogProps) {
|
|
63
|
+
const [localEntries, setLocalEntries] = useState<DeviceLogEntry[]>(
|
|
64
|
+
entries.map(e => ({ ...e, selectedAttrs: [...e.selectedAttrs] }))
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const toggleDevice = (index: number) => {
|
|
68
|
+
setLocalEntries(prev => {
|
|
69
|
+
const next = [...prev];
|
|
70
|
+
next[index] = { ...next[index], enabled: !next[index].enabled };
|
|
71
|
+
return next;
|
|
72
|
+
});
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const setRate = (index: number, rateMs: number) => {
|
|
76
|
+
setLocalEntries(prev => {
|
|
77
|
+
const next = [...prev];
|
|
78
|
+
next[index] = { ...next[index], rateMs };
|
|
79
|
+
return next;
|
|
80
|
+
});
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const selectAll = () => {
|
|
84
|
+
setLocalEntries(prev => prev.map(d => ({ ...d, enabled: true })));
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const selectNone = () => {
|
|
88
|
+
setLocalEntries(prev => prev.map(d => ({ ...d, enabled: false })));
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const toggleAttr = (deviceIndex: number, attrName: string) => {
|
|
92
|
+
setLocalEntries(prev => {
|
|
93
|
+
const next = [...prev];
|
|
94
|
+
const entry = { ...next[deviceIndex] };
|
|
95
|
+
const selected = [...entry.selectedAttrs];
|
|
96
|
+
const idx = selected.indexOf(attrName);
|
|
97
|
+
if (idx >= 0) {
|
|
98
|
+
selected.splice(idx, 1);
|
|
99
|
+
} else {
|
|
100
|
+
selected.push(attrName);
|
|
101
|
+
}
|
|
102
|
+
entry.selectedAttrs = selected;
|
|
103
|
+
next[deviceIndex] = entry;
|
|
104
|
+
return next;
|
|
105
|
+
});
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<div className="dev-select-overlay" onClick={onCancel}>
|
|
110
|
+
<div className="dev-select-dialog" onClick={e => e.stopPropagation()}>
|
|
111
|
+
<h3 className="dev-select-title">Select Devices & Attributes</h3>
|
|
112
|
+
|
|
113
|
+
<div className="log-config-select-buttons">
|
|
114
|
+
<button className="log-config-select-btn" onClick={selectAll}>All</button>
|
|
115
|
+
<button className="log-config-select-btn" onClick={selectNone}>None</button>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
<div className="dev-select-list">
|
|
119
|
+
{localEntries.map((entry, idx) => (
|
|
120
|
+
<div key={`${entry.busName}_${entry.addr}`} className={`log-config-device ${entry.enabled ? '' : 'log-config-device-disabled'}`}>
|
|
121
|
+
<div className="log-config-device-header">
|
|
122
|
+
<label className="log-config-checkbox-label">
|
|
123
|
+
<input
|
|
124
|
+
type="checkbox"
|
|
125
|
+
checked={entry.enabled}
|
|
126
|
+
onChange={() => toggleDevice(idx)}
|
|
127
|
+
/>
|
|
128
|
+
<span className="log-config-device-name">{entry.typeName}</span>
|
|
129
|
+
</label>
|
|
130
|
+
<span className="log-config-device-addr">
|
|
131
|
+
Bus {entry.busName} · 0x{entry.addr}
|
|
132
|
+
</span>
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
{entry.enabled && (
|
|
136
|
+
<div className="log-config-rate-control">
|
|
137
|
+
<div className="log-config-rate-row">
|
|
138
|
+
<label className="log-config-rate-label">Log rate:</label>
|
|
139
|
+
<select
|
|
140
|
+
className="log-config-rate-preset"
|
|
141
|
+
value={RATE_PRESETS.find(p => p.ms === entry.rateMs) ? entry.rateMs : 'custom'}
|
|
142
|
+
onChange={e => {
|
|
143
|
+
const val = e.target.value;
|
|
144
|
+
if (val !== 'custom') setRate(idx, parseInt(val, 10));
|
|
145
|
+
}}
|
|
146
|
+
>
|
|
147
|
+
{RATE_PRESETS.map(p => (
|
|
148
|
+
<option key={p.ms} value={p.ms}>{p.label}</option>
|
|
149
|
+
))}
|
|
150
|
+
{!RATE_PRESETS.find(p => p.ms === entry.rateMs) && (
|
|
151
|
+
<option value="custom">Custom</option>
|
|
152
|
+
)}
|
|
153
|
+
</select>
|
|
154
|
+
</div>
|
|
155
|
+
|
|
156
|
+
<div className="log-config-slider-row">
|
|
157
|
+
<span className="log-config-slider-label">Fast</span>
|
|
158
|
+
<input
|
|
159
|
+
type="range"
|
|
160
|
+
className="log-config-slider"
|
|
161
|
+
min="0"
|
|
162
|
+
max="1"
|
|
163
|
+
step="0.005"
|
|
164
|
+
value={msToSliderValue(entry.rateMs)}
|
|
165
|
+
onChange={e => {
|
|
166
|
+
const ms = sliderValueToMs(parseFloat(e.target.value));
|
|
167
|
+
setRate(idx, ms);
|
|
168
|
+
}}
|
|
169
|
+
/>
|
|
170
|
+
<span className="log-config-slider-label">Slow</span>
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
<div className="log-config-rate-display">
|
|
174
|
+
{formatRateMs(entry.rateMs)}
|
|
175
|
+
{entry.pollIntervalMs > 0 && entry.rateMs === 0 && (
|
|
176
|
+
<span className="log-config-poll-rate"> · poll: {(1000 / entry.pollIntervalMs).toFixed(1)} Hz</span>
|
|
177
|
+
)}
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
{format === 'csv' && entry.availableAttrs.length > 0 && (
|
|
181
|
+
<div className="log-config-attrs">
|
|
182
|
+
<div className="log-config-attrs-label">
|
|
183
|
+
Attributes {entry.selectedAttrs.length === 0 ? '(all)' : `(${entry.selectedAttrs.length}/${entry.availableAttrs.length})`}:
|
|
184
|
+
</div>
|
|
185
|
+
<div className="log-config-attrs-list">
|
|
186
|
+
{entry.availableAttrs.map(attrName => (
|
|
187
|
+
<label key={attrName} className="log-config-attr-checkbox">
|
|
188
|
+
<input
|
|
189
|
+
type="checkbox"
|
|
190
|
+
checked={entry.selectedAttrs.length === 0 || entry.selectedAttrs.includes(attrName)}
|
|
191
|
+
onChange={() => {
|
|
192
|
+
if (entry.selectedAttrs.length === 0) {
|
|
193
|
+
setLocalEntries(prev => {
|
|
194
|
+
const next = [...prev];
|
|
195
|
+
const e = { ...next[idx] };
|
|
196
|
+
e.selectedAttrs = entry.availableAttrs.filter(a => a !== attrName);
|
|
197
|
+
next[idx] = e;
|
|
198
|
+
return next;
|
|
199
|
+
});
|
|
200
|
+
} else {
|
|
201
|
+
toggleAttr(idx, attrName);
|
|
202
|
+
}
|
|
203
|
+
}}
|
|
204
|
+
/>
|
|
205
|
+
{attrName}
|
|
206
|
+
</label>
|
|
207
|
+
))}
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
)}
|
|
211
|
+
</div>
|
|
212
|
+
)}
|
|
213
|
+
</div>
|
|
214
|
+
))}
|
|
215
|
+
</div>
|
|
216
|
+
|
|
217
|
+
<div className="dev-select-buttons">
|
|
218
|
+
<button className="dev-select-btn dev-select-btn-cancel" onClick={onCancel}>Cancel</button>
|
|
219
|
+
<button className="dev-select-btn dev-select-btn-save" onClick={() => onSave(localEntries)}>OK</button>
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
</div>
|
|
223
|
+
);
|
|
224
|
+
}
|