@adcops/autocore-react 3.3.73 → 3.3.77
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/assets/HomeMotor.d.ts +4 -0
- package/dist/assets/HomeMotor.d.ts.map +1 -0
- package/dist/assets/HomeMotor.js +1 -0
- package/dist/assets/svg/home_motor.svg +57 -0
- package/dist/components/Indicator.d.ts +29 -52
- package/dist/components/Indicator.d.ts.map +1 -1
- package/dist/components/Indicator.js +1 -1
- package/dist/components/ValueInput.d.ts +1 -1
- package/dist/components/ValueInput.d.ts.map +1 -1
- package/dist/components/ams/AmsProvider.d.ts +7 -0
- package/dist/components/ams/AmsProvider.d.ts.map +1 -1
- package/dist/components/ams/AssetDetailView.d.ts.map +1 -1
- package/dist/components/ams/AssetDetailView.js +1 -1
- package/dist/components/ams/AssetRegistryTable.d.ts.map +1 -1
- package/dist/components/ams/AssetRegistryTable.js +1 -1
- package/dist/components/ams/CalibrationEntryDialog.d.ts.map +1 -1
- package/dist/components/ams/CalibrationEntryDialog.js +1 -1
- package/dist/components/ams/MissingAssetsBanner.d.ts +11 -0
- package/dist/components/ams/MissingAssetsBanner.d.ts.map +1 -0
- package/dist/components/ams/MissingAssetsBanner.js +1 -0
- package/dist/components/ams/PlaceholderHealthPanel.d.ts +3 -0
- package/dist/components/ams/PlaceholderHealthPanel.d.ts.map +1 -0
- package/dist/components/ams/PlaceholderHealthPanel.js +1 -0
- package/dist/components/ams/index.d.ts +2 -0
- package/dist/components/ams/index.d.ts.map +1 -1
- package/dist/components/ams/index.js +1 -1
- package/dist/components/index.d.ts +8 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +1 -1
- package/dist/components/network/NetworkPanel.d.ts +8 -0
- package/dist/components/network/NetworkPanel.d.ts.map +1 -0
- package/dist/components/network/NetworkPanel.js +1 -0
- package/dist/components/network/NetworkProvider.d.ts +72 -0
- package/dist/components/network/NetworkProvider.d.ts.map +1 -0
- package/dist/components/network/NetworkProvider.js +1 -0
- package/dist/components/network/StagedChangeBanner.d.ts +8 -0
- package/dist/components/network/StagedChangeBanner.d.ts.map +1 -0
- package/dist/components/network/StagedChangeBanner.js +1 -0
- package/dist/components/network/index.d.ts +7 -0
- package/dist/components/network/index.d.ts.map +1 -0
- package/dist/components/network/index.js +1 -0
- package/dist/components/tis/ProjectManager.d.ts +7 -0
- package/dist/components/tis/ProjectManager.d.ts.map +1 -0
- package/dist/components/tis/ProjectManager.js +1 -0
- package/dist/components/tis/ResultHistoryTable.d.ts.map +1 -1
- package/dist/components/tis/ResultHistoryTable.js +1 -1
- 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 +7 -0
- package/dist/components/tis/TestSetupForm.d.ts.map +1 -1
- package/dist/components/tis/TestSetupForm.js +1 -1
- package/package.json +5 -1
- package/src/assets/HomeMotor.tsx +37 -0
- package/src/assets/svg/home_motor.svg +57 -0
- package/src/components/Indicator.tsx +166 -162
- package/src/components/ValueInput.tsx +2 -2
- package/src/components/ams/AmsProvider.tsx +7 -0
- package/src/components/ams/AssetDetailView.tsx +287 -4
- package/src/components/ams/AssetRegistryTable.tsx +325 -21
- package/src/components/ams/CalibrationEntryDialog.tsx +163 -30
- package/src/components/ams/MissingAssetsBanner.tsx +124 -0
- package/src/components/ams/PlaceholderHealthPanel.tsx +188 -0
- package/src/components/ams/index.ts +2 -0
- package/src/components/index.ts +26 -0
- package/src/components/network/NetworkPanel.tsx +363 -0
- package/src/components/network/NetworkProvider.tsx +349 -0
- package/src/components/network/StagedChangeBanner.tsx +101 -0
- package/src/components/network/index.ts +17 -0
- package/src/components/tis/ProjectManager.tsx +392 -0
- package/src/components/tis/ResultHistoryTable.tsx +125 -74
- package/src/components/tis/TestDataView.tsx +160 -14
- package/src/components/tis/TestRawDataView.tsx +118 -8
- package/src/components/tis/TestSetupForm.tsx +42 -1
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Automated Design Corp. All Rights Reserved.
|
|
3
|
+
*
|
|
4
|
+
* <NetworkProvider> — context for the network management panel.
|
|
5
|
+
*
|
|
6
|
+
* Owns:
|
|
7
|
+
* 1. A copy of the server-side `nw.status` snapshot (devices, saved
|
|
8
|
+
* connections, radio state) — refreshed on demand and on any
|
|
9
|
+
* `nw.*` mutation broadcast.
|
|
10
|
+
* 2. The latest pending stage (if any), so <StagedChangeBanner> can
|
|
11
|
+
* render its countdown without re-polling.
|
|
12
|
+
* 3. Action methods (scan / connect / confirm / cancel / forget /
|
|
13
|
+
* set_radio) that wrap `invoke` and refresh status afterwards.
|
|
14
|
+
*
|
|
15
|
+
* Drop once at the top of the HMI (or scope it to the network tab —
|
|
16
|
+
* the provider's WS subscriptions are cheap, so either works).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import React, {
|
|
20
|
+
createContext,
|
|
21
|
+
useCallback,
|
|
22
|
+
useContext,
|
|
23
|
+
useEffect,
|
|
24
|
+
useMemo,
|
|
25
|
+
useState,
|
|
26
|
+
type ReactNode,
|
|
27
|
+
} from 'react';
|
|
28
|
+
import { EventEmitterContext } from '../../core/EventEmitterContext';
|
|
29
|
+
import { MessageType } from '../../hub/CommandMessage';
|
|
30
|
+
|
|
31
|
+
// -------------------------------------------------------------------------
|
|
32
|
+
// Types
|
|
33
|
+
// -------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
export interface NetworkDeviceIp4 {
|
|
36
|
+
addresses: string[];
|
|
37
|
+
gateway: string;
|
|
38
|
+
dns: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface NetworkInterface {
|
|
42
|
+
device: string;
|
|
43
|
+
type: string;
|
|
44
|
+
state: string;
|
|
45
|
+
connection: string;
|
|
46
|
+
ip4: NetworkDeviceIp4;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface NetworkConnection {
|
|
50
|
+
name: string;
|
|
51
|
+
uuid: string;
|
|
52
|
+
type: string;
|
|
53
|
+
device: string;
|
|
54
|
+
active: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface WifiAp {
|
|
58
|
+
ssid: string;
|
|
59
|
+
bssid: string;
|
|
60
|
+
signal: number;
|
|
61
|
+
security: string;
|
|
62
|
+
in_use: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface StagedChange {
|
|
66
|
+
staged_id: string;
|
|
67
|
+
device: string;
|
|
68
|
+
ssid: string;
|
|
69
|
+
prev_connection_uuid: string;
|
|
70
|
+
new_connection_uuid: string;
|
|
71
|
+
new_profile_created: boolean;
|
|
72
|
+
revert_in_seconds: number;
|
|
73
|
+
/**
|
|
74
|
+
* Absolute deadline (ms since epoch) computed when the staged
|
|
75
|
+
* change was received. The banner uses this to render a
|
|
76
|
+
* monotonically-decreasing countdown that doesn't depend on
|
|
77
|
+
* `revert_in_seconds` staying fresh in React state.
|
|
78
|
+
*/
|
|
79
|
+
deadline_ms: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface NetworkStatus {
|
|
83
|
+
interfaces: NetworkInterface[];
|
|
84
|
+
connections: NetworkConnection[];
|
|
85
|
+
wifi_radio: boolean | null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface NetworkContextValue {
|
|
89
|
+
status: NetworkStatus;
|
|
90
|
+
statusLoaded: boolean;
|
|
91
|
+
refreshStatus: () => Promise<void>;
|
|
92
|
+
|
|
93
|
+
aps: WifiAp[];
|
|
94
|
+
scanning: boolean;
|
|
95
|
+
scanWifi: (device?: string) => Promise<void>;
|
|
96
|
+
|
|
97
|
+
/** Latest pending stage, or null when none in flight. */
|
|
98
|
+
staged: StagedChange | null;
|
|
99
|
+
|
|
100
|
+
/** Connect to an SSID; returns the staged change record on success. */
|
|
101
|
+
wifiConnect: (ssid: string, password?: string, device?: string) => Promise<StagedChange | null>;
|
|
102
|
+
confirmConnection: (stagedId: string) => Promise<boolean>;
|
|
103
|
+
cancelStaged: (stagedId: string) => Promise<boolean>;
|
|
104
|
+
forgetConnection: (uuid: string) => Promise<boolean>;
|
|
105
|
+
setRadio: (enabled: boolean) => Promise<boolean>;
|
|
106
|
+
|
|
107
|
+
/** Most recent error from any network action, or empty string. */
|
|
108
|
+
lastError: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const EMPTY_STATUS: NetworkStatus = {
|
|
112
|
+
interfaces: [],
|
|
113
|
+
connections: [],
|
|
114
|
+
wifi_radio: null,
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const NetworkContext = createContext<NetworkContextValue>({
|
|
118
|
+
status: EMPTY_STATUS,
|
|
119
|
+
statusLoaded: false,
|
|
120
|
+
refreshStatus: async () => {},
|
|
121
|
+
aps: [],
|
|
122
|
+
scanning: false,
|
|
123
|
+
scanWifi: async () => {},
|
|
124
|
+
staged: null,
|
|
125
|
+
wifiConnect: async () => null,
|
|
126
|
+
confirmConnection: async () => false,
|
|
127
|
+
cancelStaged: async () => false,
|
|
128
|
+
forgetConnection: async () => false,
|
|
129
|
+
setRadio: async () => false,
|
|
130
|
+
lastError: '',
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// -------------------------------------------------------------------------
|
|
134
|
+
// Provider
|
|
135
|
+
// -------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
export interface NetworkProviderProps {
|
|
138
|
+
children: ReactNode;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export const NetworkProvider: React.FC<NetworkProviderProps> = ({ children }) => {
|
|
142
|
+
const { invoke, subscribe, unsubscribe } = useContext(EventEmitterContext);
|
|
143
|
+
|
|
144
|
+
const [status, setStatus] = useState<NetworkStatus>(EMPTY_STATUS);
|
|
145
|
+
const [statusLoaded, setStatusLoaded] = useState(false);
|
|
146
|
+
const [aps, setAps] = useState<WifiAp[]>([]);
|
|
147
|
+
const [scanning, setScanning] = useState(false);
|
|
148
|
+
const [staged, setStaged] = useState<StagedChange | null>(null);
|
|
149
|
+
const [lastError, setLastError] = useState('');
|
|
150
|
+
|
|
151
|
+
// -----------------------------------------------------------------
|
|
152
|
+
// Status fetch — merges nw.status + nw.list_interfaces so consumers
|
|
153
|
+
// get IP4 info alongside the connection list.
|
|
154
|
+
// -----------------------------------------------------------------
|
|
155
|
+
const refreshStatus = useCallback(async () => {
|
|
156
|
+
try {
|
|
157
|
+
const [ifResp, statResp] = await Promise.all([
|
|
158
|
+
invoke('nw.list_interfaces' as any, MessageType.Request, {} as any),
|
|
159
|
+
invoke('nw.status' as any, MessageType.Request, {} as any),
|
|
160
|
+
]) as any[];
|
|
161
|
+
const interfaces = (ifResp?.success ? (ifResp.data?.interfaces ?? []) : []) as NetworkInterface[];
|
|
162
|
+
const connections = (statResp?.success ? (statResp.data?.connections ?? []) : []) as NetworkConnection[];
|
|
163
|
+
const wifi_radio = statResp?.success
|
|
164
|
+
? (typeof statResp.data?.wifi_radio === 'boolean' ? statResp.data.wifi_radio : null)
|
|
165
|
+
: null;
|
|
166
|
+
setStatus({ interfaces, connections, wifi_radio });
|
|
167
|
+
setStatusLoaded(true);
|
|
168
|
+
if (!ifResp?.success) setLastError(ifResp?.error_message ?? '');
|
|
169
|
+
else if (!statResp?.success) setLastError(statResp?.error_message ?? '');
|
|
170
|
+
else setLastError('');
|
|
171
|
+
} catch (e) {
|
|
172
|
+
setLastError(e instanceof Error ? e.message : String(e));
|
|
173
|
+
}
|
|
174
|
+
}, [invoke]);
|
|
175
|
+
|
|
176
|
+
useEffect(() => { void refreshStatus(); }, [refreshStatus]);
|
|
177
|
+
|
|
178
|
+
// -----------------------------------------------------------------
|
|
179
|
+
// Subscriptions: refresh status on any nw.* mutation. Stage and
|
|
180
|
+
// revert broadcasts also drive the in-memory `staged` record so
|
|
181
|
+
// the banner survives across refresh-induced remounts.
|
|
182
|
+
// -----------------------------------------------------------------
|
|
183
|
+
useEffect(() => {
|
|
184
|
+
const onStaged = (payload: any) => {
|
|
185
|
+
if (!payload || typeof payload !== 'object') return;
|
|
186
|
+
const secs = Number(payload.revert_in_seconds ?? 0);
|
|
187
|
+
setStaged({
|
|
188
|
+
staged_id: String(payload.staged_id ?? ''),
|
|
189
|
+
device: String(payload.device ?? ''),
|
|
190
|
+
ssid: String(payload.ssid ?? ''),
|
|
191
|
+
prev_connection_uuid: String(payload.prev_connection_uuid ?? ''),
|
|
192
|
+
new_connection_uuid: String(payload.new_connection_uuid ?? ''),
|
|
193
|
+
new_profile_created: !!payload.new_profile_created,
|
|
194
|
+
revert_in_seconds: secs,
|
|
195
|
+
deadline_ms: Date.now() + secs * 1000,
|
|
196
|
+
});
|
|
197
|
+
};
|
|
198
|
+
const onConfirmed = (payload: any) => {
|
|
199
|
+
const id = String(payload?.staged_id ?? '');
|
|
200
|
+
setStaged(prev => prev && prev.staged_id === id ? null : prev);
|
|
201
|
+
void refreshStatus();
|
|
202
|
+
};
|
|
203
|
+
const onReverted = (payload: any) => {
|
|
204
|
+
const id = String(payload?.staged_id ?? '');
|
|
205
|
+
setStaged(prev => prev && prev.staged_id === id ? null : prev);
|
|
206
|
+
void refreshStatus();
|
|
207
|
+
};
|
|
208
|
+
const onDeleted = () => { void refreshStatus(); };
|
|
209
|
+
|
|
210
|
+
const subs = [
|
|
211
|
+
subscribe('nw.staged_change', onStaged),
|
|
212
|
+
subscribe('nw.confirmed', onConfirmed),
|
|
213
|
+
subscribe('nw.revert_fired', onReverted),
|
|
214
|
+
subscribe('nw.connection_deleted', onDeleted),
|
|
215
|
+
];
|
|
216
|
+
return () => { subs.forEach(unsubscribe); };
|
|
217
|
+
}, [subscribe, unsubscribe, refreshStatus]);
|
|
218
|
+
|
|
219
|
+
// -----------------------------------------------------------------
|
|
220
|
+
// Actions
|
|
221
|
+
// -----------------------------------------------------------------
|
|
222
|
+
const scanWifi = useCallback(async (device?: string) => {
|
|
223
|
+
setScanning(true);
|
|
224
|
+
setLastError('');
|
|
225
|
+
try {
|
|
226
|
+
const payload: any = {};
|
|
227
|
+
if (device) payload.device = device;
|
|
228
|
+
const resp: any = await invoke('nw.scan_wifi' as any, MessageType.Request, payload);
|
|
229
|
+
if (resp?.success) {
|
|
230
|
+
setAps((resp.data?.access_points ?? []) as WifiAp[]);
|
|
231
|
+
} else {
|
|
232
|
+
setLastError(resp?.error_message ?? 'scan_wifi failed');
|
|
233
|
+
}
|
|
234
|
+
} catch (e) {
|
|
235
|
+
setLastError(e instanceof Error ? e.message : String(e));
|
|
236
|
+
} finally {
|
|
237
|
+
setScanning(false);
|
|
238
|
+
}
|
|
239
|
+
}, [invoke]);
|
|
240
|
+
|
|
241
|
+
const wifiConnect = useCallback(async (
|
|
242
|
+
ssid: string, password?: string, device?: string,
|
|
243
|
+
): Promise<StagedChange | null> => {
|
|
244
|
+
setLastError('');
|
|
245
|
+
try {
|
|
246
|
+
const payload: any = { ssid };
|
|
247
|
+
if (password) payload.password = password;
|
|
248
|
+
if (device) payload.device = device;
|
|
249
|
+
const resp: any = await invoke('nw.wifi_connect' as any, MessageType.Request, payload);
|
|
250
|
+
if (!resp?.success) {
|
|
251
|
+
setLastError(resp?.error_message ?? 'wifi_connect failed');
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
// The broadcast handler above will also set `staged`, but we
|
|
255
|
+
// build a record here too in case the broadcast arrives after
|
|
256
|
+
// the resolver wakes (we'd otherwise return before the banner
|
|
257
|
+
// had a chance to render).
|
|
258
|
+
const secs = Number(resp.data?.revert_in_seconds ?? 0);
|
|
259
|
+
const change: StagedChange = {
|
|
260
|
+
staged_id: String(resp.data?.staged_id ?? ''),
|
|
261
|
+
device: String(resp.data?.device ?? ''),
|
|
262
|
+
ssid: String(resp.data?.ssid ?? ssid),
|
|
263
|
+
prev_connection_uuid: String(resp.data?.prev_connection_uuid ?? ''),
|
|
264
|
+
new_connection_uuid: String(resp.data?.new_connection_uuid ?? ''),
|
|
265
|
+
new_profile_created: !!resp.data?.new_profile_created,
|
|
266
|
+
revert_in_seconds: secs,
|
|
267
|
+
deadline_ms: Date.now() + secs * 1000,
|
|
268
|
+
};
|
|
269
|
+
setStaged(change);
|
|
270
|
+
return change;
|
|
271
|
+
} catch (e) {
|
|
272
|
+
setLastError(e instanceof Error ? e.message : String(e));
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
}, [invoke]);
|
|
276
|
+
|
|
277
|
+
const confirmConnection = useCallback(async (stagedId: string): Promise<boolean> => {
|
|
278
|
+
try {
|
|
279
|
+
const resp: any = await invoke('nw.confirm_connection' as any, MessageType.Request, { staged_id: stagedId } as any);
|
|
280
|
+
if (resp?.success) {
|
|
281
|
+
setStaged(prev => prev && prev.staged_id === stagedId ? null : prev);
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
setLastError(resp?.error_message ?? 'confirm_connection failed');
|
|
285
|
+
return false;
|
|
286
|
+
} catch (e) {
|
|
287
|
+
setLastError(e instanceof Error ? e.message : String(e));
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
}, [invoke]);
|
|
291
|
+
|
|
292
|
+
const cancelStaged = useCallback(async (stagedId: string): Promise<boolean> => {
|
|
293
|
+
try {
|
|
294
|
+
const resp: any = await invoke('nw.cancel_staged' as any, MessageType.Request, { staged_id: stagedId } as any);
|
|
295
|
+
return !!resp?.success;
|
|
296
|
+
} catch (e) {
|
|
297
|
+
setLastError(e instanceof Error ? e.message : String(e));
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
}, [invoke]);
|
|
301
|
+
|
|
302
|
+
const forgetConnection = useCallback(async (uuid: string): Promise<boolean> => {
|
|
303
|
+
try {
|
|
304
|
+
const resp: any = await invoke('nw.forget_connection' as any, MessageType.Request, { uuid } as any);
|
|
305
|
+
if (resp?.success) {
|
|
306
|
+
void refreshStatus();
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
setLastError(resp?.error_message ?? 'forget_connection failed');
|
|
310
|
+
return false;
|
|
311
|
+
} catch (e) {
|
|
312
|
+
setLastError(e instanceof Error ? e.message : String(e));
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
}, [invoke, refreshStatus]);
|
|
316
|
+
|
|
317
|
+
const setRadio = useCallback(async (enabled: boolean): Promise<boolean> => {
|
|
318
|
+
try {
|
|
319
|
+
const resp: any = await invoke('nw.set_radio' as any, MessageType.Request, { enabled } as any);
|
|
320
|
+
if (resp?.success) {
|
|
321
|
+
setStatus(s => ({ ...s, wifi_radio: enabled }));
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
setLastError(resp?.error_message ?? 'set_radio failed');
|
|
325
|
+
return false;
|
|
326
|
+
} catch (e) {
|
|
327
|
+
setLastError(e instanceof Error ? e.message : String(e));
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
}, [invoke]);
|
|
331
|
+
|
|
332
|
+
const value = useMemo<NetworkContextValue>(() => ({
|
|
333
|
+
status, statusLoaded, refreshStatus,
|
|
334
|
+
aps, scanning, scanWifi,
|
|
335
|
+
staged,
|
|
336
|
+
wifiConnect, confirmConnection, cancelStaged, forgetConnection, setRadio,
|
|
337
|
+
lastError,
|
|
338
|
+
}), [status, statusLoaded, refreshStatus, aps, scanning, scanWifi,
|
|
339
|
+
staged, wifiConnect, confirmConnection, cancelStaged, forgetConnection, setRadio,
|
|
340
|
+
lastError]);
|
|
341
|
+
|
|
342
|
+
return <NetworkContext.Provider value={value}>{children}</NetworkContext.Provider>;
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
// -------------------------------------------------------------------------
|
|
346
|
+
// Hook
|
|
347
|
+
// -------------------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
export const useNetwork = () => useContext(NetworkContext);
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Automated Design Corp. All Rights Reserved.
|
|
3
|
+
*
|
|
4
|
+
* <StagedChangeBanner> — sticky banner that appears whenever a WiFi
|
|
5
|
+
* connection has been staged via `nw.wifi_connect` and is awaiting
|
|
6
|
+
* confirmation. Shows a live countdown to the auto-revert deadline
|
|
7
|
+
* and offers Confirm / Cancel buttons.
|
|
8
|
+
*
|
|
9
|
+
* Drives itself off `useNetwork().staged`; renders nothing when no
|
|
10
|
+
* stage is in flight.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import React, { useEffect, useState } from 'react';
|
|
14
|
+
import { Button } from 'primereact/button';
|
|
15
|
+
import { useNetwork } from './NetworkProvider';
|
|
16
|
+
|
|
17
|
+
export interface StagedChangeBannerProps {
|
|
18
|
+
/** Override the banner placement. Defaults to a fixed top bar. */
|
|
19
|
+
style?: React.CSSProperties;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const StagedChangeBanner: React.FC<StagedChangeBannerProps> = ({ style }) => {
|
|
23
|
+
const { staged, confirmConnection, cancelStaged } = useNetwork();
|
|
24
|
+
const [now, setNow] = useState<number>(() => Date.now());
|
|
25
|
+
|
|
26
|
+
// Tick once per second while a stage is active. The countdown reads
|
|
27
|
+
// from `staged.deadline_ms - now` so the display stays monotonic
|
|
28
|
+
// even if the broadcast arrives a beat late.
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (!staged) return;
|
|
31
|
+
const id = window.setInterval(() => setNow(Date.now()), 250);
|
|
32
|
+
return () => window.clearInterval(id);
|
|
33
|
+
}, [staged]);
|
|
34
|
+
|
|
35
|
+
if (!staged) return null;
|
|
36
|
+
|
|
37
|
+
const remainingSec = Math.max(0, Math.ceil((staged.deadline_ms - now) / 1000));
|
|
38
|
+
const total = Math.max(1, staged.revert_in_seconds);
|
|
39
|
+
const pctRemaining = Math.max(0, Math.min(100, (remainingSec / total) * 100));
|
|
40
|
+
|
|
41
|
+
const containerStyle: React.CSSProperties = {
|
|
42
|
+
position: 'sticky',
|
|
43
|
+
top: 0,
|
|
44
|
+
zIndex: 1000,
|
|
45
|
+
padding: '0.75rem 1rem',
|
|
46
|
+
background: remainingSec <= 10 ? '#7f1d1d' : '#78350f',
|
|
47
|
+
color: 'white',
|
|
48
|
+
borderBottom: '1px solid #1f2937',
|
|
49
|
+
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.3)',
|
|
50
|
+
...style,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div style={containerStyle} role="alertdialog" aria-live="polite">
|
|
55
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap' }}>
|
|
56
|
+
<div style={{ minWidth: 0 }}>
|
|
57
|
+
<strong>WiFi change pending:</strong>{' '}
|
|
58
|
+
connected to <code>{staged.ssid}</code> on <code>{staged.device}</code>
|
|
59
|
+
</div>
|
|
60
|
+
<div style={{ fontVariantNumeric: 'tabular-nums', fontSize: '1.1rem' }}>
|
|
61
|
+
Reverting in <strong>{remainingSec}s</strong>
|
|
62
|
+
</div>
|
|
63
|
+
<div style={{ display: 'flex', gap: '0.4rem' }}>
|
|
64
|
+
<Button
|
|
65
|
+
label="Confirm"
|
|
66
|
+
icon="pi pi-check"
|
|
67
|
+
size="small"
|
|
68
|
+
severity="success"
|
|
69
|
+
onClick={() => { void confirmConnection(staged.staged_id); }}
|
|
70
|
+
/>
|
|
71
|
+
<Button
|
|
72
|
+
label="Cancel & Revert"
|
|
73
|
+
icon="pi pi-undo"
|
|
74
|
+
size="small"
|
|
75
|
+
severity="danger"
|
|
76
|
+
outlined
|
|
77
|
+
onClick={() => { void cancelStaged(staged.staged_id); }}
|
|
78
|
+
/>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
{/* Slim progress strip beneath the row — visual reinforcement
|
|
82
|
+
of the countdown. Width shrinks toward zero as time runs out. */}
|
|
83
|
+
<div style={{
|
|
84
|
+
marginTop: '0.5rem',
|
|
85
|
+
height: '0.25rem',
|
|
86
|
+
background: 'rgba(255, 255, 255, 0.2)',
|
|
87
|
+
borderRadius: 0,
|
|
88
|
+
overflow: 'hidden',
|
|
89
|
+
}}>
|
|
90
|
+
<div style={{
|
|
91
|
+
width: `${pctRemaining}%`,
|
|
92
|
+
height: '100%',
|
|
93
|
+
background: 'white',
|
|
94
|
+
transition: 'width 0.25s linear',
|
|
95
|
+
}} />
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export default StagedChangeBanner;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export { NetworkProvider, useNetwork } from './NetworkProvider';
|
|
2
|
+
export type {
|
|
3
|
+
NetworkProviderProps,
|
|
4
|
+
NetworkContextValue,
|
|
5
|
+
NetworkStatus,
|
|
6
|
+
NetworkInterface,
|
|
7
|
+
NetworkConnection,
|
|
8
|
+
NetworkDeviceIp4,
|
|
9
|
+
WifiAp,
|
|
10
|
+
StagedChange,
|
|
11
|
+
} from './NetworkProvider';
|
|
12
|
+
|
|
13
|
+
export { NetworkPanel } from './NetworkPanel';
|
|
14
|
+
export type { NetworkPanelProps } from './NetworkPanel';
|
|
15
|
+
|
|
16
|
+
export { StagedChangeBanner } from './StagedChangeBanner';
|
|
17
|
+
export type { StagedChangeBannerProps } from './StagedChangeBanner';
|