@adcops/autocore-react 3.3.75 → 3.3.79
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/Indicator.d.ts +29 -52
- package/dist/components/Indicator.d.ts.map +1 -1
- package/dist/components/Indicator.js +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/package.json +1 -1
- package/src/components/Indicator.tsx +177 -162
- 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 +393 -0
- package/src/components/tis/ResultHistoryTable.tsx +126 -188
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Automated Design Corp. All Rights Reserved.
|
|
3
|
+
*
|
|
4
|
+
* <NetworkPanel> — operator-facing network configuration view.
|
|
5
|
+
*
|
|
6
|
+
* 1. Status row: WiFi radio toggle + active connection summary.
|
|
7
|
+
* 2. WiFi: scan button, table of nearby SSIDs with Connect action.
|
|
8
|
+
* 3. Wired (read-only): one row per ethernet interface with its
|
|
9
|
+
* runtime IP / gateway / DNS, sourced from the netplan-rendered
|
|
10
|
+
* NetworkManager state.
|
|
11
|
+
*
|
|
12
|
+
* Pair with <StagedChangeBanner> at the top of the page so the
|
|
13
|
+
* countdown is visible regardless of which tab is foregrounded.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import React, { useEffect, useState } from 'react';
|
|
17
|
+
import { Button } from 'primereact/button';
|
|
18
|
+
import { DataTable } from 'primereact/datatable';
|
|
19
|
+
import { Column } from 'primereact/column';
|
|
20
|
+
import { Dialog } from 'primereact/dialog';
|
|
21
|
+
import { Password } from 'primereact/password';
|
|
22
|
+
import { InputSwitch } from 'primereact/inputswitch';
|
|
23
|
+
import { ConfirmDialog, confirmDialog } from 'primereact/confirmdialog';
|
|
24
|
+
import { useNetwork, type WifiAp, type NetworkInterface } from './NetworkProvider';
|
|
25
|
+
|
|
26
|
+
const signalBars = (signal: number): string => {
|
|
27
|
+
if (signal >= 75) return '▰▰▰▰';
|
|
28
|
+
if (signal >= 50) return '▰▰▰▱';
|
|
29
|
+
if (signal >= 25) return '▰▰▱▱';
|
|
30
|
+
if (signal > 0) return '▰▱▱▱';
|
|
31
|
+
return '▱▱▱▱';
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const isSecured = (security: string): boolean => {
|
|
35
|
+
const s = (security ?? '').trim();
|
|
36
|
+
return s.length > 0 && s !== '--';
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export interface NetworkPanelProps {
|
|
40
|
+
/** Optional CSS class on the outer container. */
|
|
41
|
+
className?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const NetworkPanel: React.FC<NetworkPanelProps> = ({ className }) => {
|
|
45
|
+
const net = useNetwork();
|
|
46
|
+
const [connectTarget, setConnectTarget] = useState<WifiAp | null>(null);
|
|
47
|
+
const [password, setPassword] = useState('');
|
|
48
|
+
const [submitting, setSubmitting] = useState(false);
|
|
49
|
+
|
|
50
|
+
// Kick off an initial scan when the panel mounts so the WiFi table
|
|
51
|
+
// isn't empty on first paint. Skipped when a scan is already in
|
|
52
|
+
// flight to avoid stomping a fresh result.
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (net.aps.length === 0 && !net.scanning) {
|
|
55
|
+
void net.scanWifi();
|
|
56
|
+
}
|
|
57
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
58
|
+
}, []);
|
|
59
|
+
|
|
60
|
+
const wired: NetworkInterface[] = net.status.interfaces.filter(i => i.type === 'ethernet');
|
|
61
|
+
const wifiDevices: NetworkInterface[] = net.status.interfaces.filter(i => i.type === 'wifi');
|
|
62
|
+
const activeWifi = wifiDevices.find(d => d.state === 'connected') ?? wifiDevices[0];
|
|
63
|
+
|
|
64
|
+
const openConnect = (ap: WifiAp) => {
|
|
65
|
+
setConnectTarget(ap);
|
|
66
|
+
setPassword('');
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const submitConnect = async () => {
|
|
70
|
+
if (!connectTarget) return;
|
|
71
|
+
setSubmitting(true);
|
|
72
|
+
try {
|
|
73
|
+
const pw = isSecured(connectTarget.security) ? password : undefined;
|
|
74
|
+
const result = await net.wifiConnect(connectTarget.ssid, pw);
|
|
75
|
+
if (result) {
|
|
76
|
+
setConnectTarget(null);
|
|
77
|
+
setPassword('');
|
|
78
|
+
}
|
|
79
|
+
} finally {
|
|
80
|
+
setSubmitting(false);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const confirmForget = (conn: { name: string; uuid: string }) => {
|
|
85
|
+
confirmDialog({
|
|
86
|
+
header: 'Forget network',
|
|
87
|
+
icon: 'pi pi-exclamation-triangle',
|
|
88
|
+
acceptLabel: 'Forget',
|
|
89
|
+
rejectLabel: 'Cancel',
|
|
90
|
+
acceptClassName: 'p-button-danger',
|
|
91
|
+
message: (
|
|
92
|
+
<div>
|
|
93
|
+
<p style={{ marginTop: 0 }}>
|
|
94
|
+
Remove saved profile <code>{conn.name}</code>?
|
|
95
|
+
</p>
|
|
96
|
+
<p style={{ marginBottom: 0, fontSize: '0.875rem', color: '#9ca3af' }}>
|
|
97
|
+
The password is deleted. You'll need to re-enter it next
|
|
98
|
+
time you connect to this network.
|
|
99
|
+
</p>
|
|
100
|
+
</div>
|
|
101
|
+
),
|
|
102
|
+
accept: () => { void net.forgetConnection(conn.uuid); },
|
|
103
|
+
});
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<div className={className} style={{ width: '100%', maxWidth: '100%', boxSizing: 'border-box' }}>
|
|
108
|
+
<ConfirmDialog />
|
|
109
|
+
|
|
110
|
+
{/* ----------------------------------------------------- Status */}
|
|
111
|
+
<div style={{
|
|
112
|
+
display: 'flex',
|
|
113
|
+
justifyContent: 'space-between',
|
|
114
|
+
alignItems: 'center',
|
|
115
|
+
marginBottom: '1rem',
|
|
116
|
+
gap: '0.5rem',
|
|
117
|
+
flexWrap: 'wrap',
|
|
118
|
+
}}>
|
|
119
|
+
<div>
|
|
120
|
+
<h3 style={{ margin: 0 }}>Network</h3>
|
|
121
|
+
{activeWifi ? (
|
|
122
|
+
<div style={{ fontSize: '0.875rem', color: '#9ca3af', marginTop: '0.25rem' }}>
|
|
123
|
+
{activeWifi.state === 'connected'
|
|
124
|
+
? <>Connected to <code>{activeWifi.connection || '(unnamed)'}</code> on <code>{activeWifi.device}</code></>
|
|
125
|
+
: <>WiFi: {activeWifi.state}</>}
|
|
126
|
+
{activeWifi.ip4.addresses.length > 0 && (
|
|
127
|
+
<> · {activeWifi.ip4.addresses[0]}</>
|
|
128
|
+
)}
|
|
129
|
+
</div>
|
|
130
|
+
) : (
|
|
131
|
+
<div style={{ fontSize: '0.875rem', color: '#9ca3af', marginTop: '0.25rem' }}>
|
|
132
|
+
No WiFi device detected.
|
|
133
|
+
</div>
|
|
134
|
+
)}
|
|
135
|
+
</div>
|
|
136
|
+
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
|
137
|
+
<label style={{ display: 'inline-flex', alignItems: 'center', gap: '0.4rem' }}>
|
|
138
|
+
<span style={{ fontSize: '0.875rem' }}>WiFi radio</span>
|
|
139
|
+
<InputSwitch
|
|
140
|
+
checked={!!net.status.wifi_radio}
|
|
141
|
+
onChange={(e) => { void net.setRadio(!!e.value); }}
|
|
142
|
+
disabled={net.status.wifi_radio === null}
|
|
143
|
+
/>
|
|
144
|
+
</label>
|
|
145
|
+
<Button
|
|
146
|
+
icon="pi pi-refresh"
|
|
147
|
+
label="Refresh"
|
|
148
|
+
size="small"
|
|
149
|
+
onClick={() => { void net.refreshStatus(); }}
|
|
150
|
+
/>
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
{net.lastError && (
|
|
155
|
+
<div style={{
|
|
156
|
+
background: '#7f1d1d',
|
|
157
|
+
color: 'white',
|
|
158
|
+
padding: '0.5rem 0.75rem',
|
|
159
|
+
borderRadius: 4,
|
|
160
|
+
marginBottom: '1rem',
|
|
161
|
+
fontSize: '0.875rem',
|
|
162
|
+
}}>
|
|
163
|
+
{net.lastError}
|
|
164
|
+
</div>
|
|
165
|
+
)}
|
|
166
|
+
|
|
167
|
+
{/* ----------------------------------------------------- WiFi */}
|
|
168
|
+
<div style={{
|
|
169
|
+
marginBottom: '1.5rem',
|
|
170
|
+
padding: '0.75rem 1rem',
|
|
171
|
+
border: '1px solid #2a2a2a',
|
|
172
|
+
borderRadius: 4,
|
|
173
|
+
background: '#161616',
|
|
174
|
+
}}>
|
|
175
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem' }}>
|
|
176
|
+
<strong>WiFi Networks</strong>
|
|
177
|
+
<Button
|
|
178
|
+
icon={net.scanning ? 'pi pi-spin pi-spinner' : 'pi pi-search'}
|
|
179
|
+
label={net.scanning ? 'Scanning…' : 'Scan'}
|
|
180
|
+
size="small"
|
|
181
|
+
outlined
|
|
182
|
+
disabled={net.scanning || net.status.wifi_radio === false}
|
|
183
|
+
onClick={() => { void net.scanWifi(); }}
|
|
184
|
+
/>
|
|
185
|
+
</div>
|
|
186
|
+
<DataTable
|
|
187
|
+
value={net.aps}
|
|
188
|
+
emptyMessage={net.status.wifi_radio === false
|
|
189
|
+
? 'WiFi radio is off.'
|
|
190
|
+
: 'No networks found yet. Click Scan to refresh.'}
|
|
191
|
+
scrollable
|
|
192
|
+
scrollHeight="20rem"
|
|
193
|
+
tableStyle={{ minWidth: 0 }}
|
|
194
|
+
style={{ width: '100%' }}
|
|
195
|
+
>
|
|
196
|
+
<Column header="SSID" body={(ap: WifiAp) => (
|
|
197
|
+
<span>
|
|
198
|
+
{ap.in_use && <i className="pi pi-check" style={{ marginRight: '0.4rem', color: '#22c55e' }} />}
|
|
199
|
+
{ap.ssid}
|
|
200
|
+
</span>
|
|
201
|
+
)} style={{ minWidth: '10rem' }} />
|
|
202
|
+
<Column header="Signal" body={(ap: WifiAp) => (
|
|
203
|
+
<span style={{ fontFamily: 'monospace' }}>
|
|
204
|
+
{signalBars(ap.signal)} <span style={{ color: '#9ca3af' }}>{ap.signal}%</span>
|
|
205
|
+
</span>
|
|
206
|
+
)} style={{ width: '10rem' }}
|
|
207
|
+
sortable sortField="signal" />
|
|
208
|
+
<Column header="Security" body={(ap: WifiAp) => (
|
|
209
|
+
<span>{isSecured(ap.security) ? ap.security : 'Open'}</span>
|
|
210
|
+
)} style={{ width: '8rem' }} />
|
|
211
|
+
<Column
|
|
212
|
+
header="Action"
|
|
213
|
+
style={{ width: '9rem' }}
|
|
214
|
+
body={(ap: WifiAp) => (
|
|
215
|
+
<Button
|
|
216
|
+
icon="pi pi-link"
|
|
217
|
+
label={ap.in_use ? 'Reconnect' : 'Connect'}
|
|
218
|
+
size="small"
|
|
219
|
+
outlined
|
|
220
|
+
disabled={!!net.staged}
|
|
221
|
+
onClick={() => openConnect(ap)}
|
|
222
|
+
/>
|
|
223
|
+
)}
|
|
224
|
+
/>
|
|
225
|
+
</DataTable>
|
|
226
|
+
</div>
|
|
227
|
+
|
|
228
|
+
{/* ----------------------------------------------------- Saved profiles */}
|
|
229
|
+
{net.status.connections.length > 0 && (
|
|
230
|
+
<div style={{
|
|
231
|
+
marginBottom: '1.5rem',
|
|
232
|
+
padding: '0.75rem 1rem',
|
|
233
|
+
border: '1px solid #2a2a2a',
|
|
234
|
+
borderRadius: 4,
|
|
235
|
+
background: '#161616',
|
|
236
|
+
}}>
|
|
237
|
+
<strong style={{ display: 'block', marginBottom: '0.75rem' }}>Saved Connections</strong>
|
|
238
|
+
<DataTable
|
|
239
|
+
value={net.status.connections}
|
|
240
|
+
emptyMessage="No saved profiles."
|
|
241
|
+
tableStyle={{ minWidth: 0 }}
|
|
242
|
+
style={{ width: '100%' }}
|
|
243
|
+
>
|
|
244
|
+
<Column field="name" header="Name" style={{ minWidth: '10rem' }} />
|
|
245
|
+
<Column field="type" header="Type" style={{ width: '8rem' }} />
|
|
246
|
+
<Column field="device" header="Device" style={{ minWidth: '8rem' }} />
|
|
247
|
+
<Column header="Active" style={{ width: '6rem' }}
|
|
248
|
+
body={(c) => c.active ? <i className="pi pi-check" style={{ color: '#22c55e' }} /> : null} />
|
|
249
|
+
<Column
|
|
250
|
+
header="Action"
|
|
251
|
+
style={{ width: '8rem' }}
|
|
252
|
+
body={(c) => c.type === 'wifi' ? (
|
|
253
|
+
<Button
|
|
254
|
+
icon="pi pi-trash"
|
|
255
|
+
label="Forget"
|
|
256
|
+
size="small"
|
|
257
|
+
severity="danger"
|
|
258
|
+
outlined
|
|
259
|
+
onClick={() => confirmForget(c)}
|
|
260
|
+
/>
|
|
261
|
+
) : null}
|
|
262
|
+
/>
|
|
263
|
+
</DataTable>
|
|
264
|
+
</div>
|
|
265
|
+
)}
|
|
266
|
+
|
|
267
|
+
{/* ----------------------------------------------------- Wired (read-only) */}
|
|
268
|
+
<div style={{
|
|
269
|
+
padding: '0.75rem 1rem',
|
|
270
|
+
border: '1px solid #2a2a2a',
|
|
271
|
+
borderRadius: 4,
|
|
272
|
+
background: '#161616',
|
|
273
|
+
}}>
|
|
274
|
+
<strong style={{ display: 'block', marginBottom: '0.25rem' }}>Wired Interfaces</strong>
|
|
275
|
+
<div style={{ fontSize: '0.75rem', color: '#6b7280', marginBottom: '0.75rem' }}>
|
|
276
|
+
Wired configuration is managed by netplan and is read-only here.
|
|
277
|
+
</div>
|
|
278
|
+
<DataTable
|
|
279
|
+
value={wired}
|
|
280
|
+
emptyMessage="No wired interfaces."
|
|
281
|
+
tableStyle={{ minWidth: 0 }}
|
|
282
|
+
style={{ width: '100%' }}
|
|
283
|
+
>
|
|
284
|
+
<Column field="device" header="Device" style={{ minWidth: '8rem' }} />
|
|
285
|
+
<Column field="state" header="State" style={{ width: '8rem' }} />
|
|
286
|
+
<Column header="IP Address" body={(d: NetworkInterface) =>
|
|
287
|
+
d.ip4.addresses.length > 0 ? d.ip4.addresses.join(', ') : <span style={{ color: '#6b7280' }}>—</span>
|
|
288
|
+
} style={{ minWidth: '10rem' }} />
|
|
289
|
+
<Column header="Gateway" body={(d: NetworkInterface) =>
|
|
290
|
+
d.ip4.gateway || <span style={{ color: '#6b7280' }}>—</span>
|
|
291
|
+
} style={{ minWidth: '8rem' }} />
|
|
292
|
+
<Column header="DNS" body={(d: NetworkInterface) =>
|
|
293
|
+
d.ip4.dns.length > 0 ? d.ip4.dns.join(', ') : <span style={{ color: '#6b7280' }}>—</span>
|
|
294
|
+
} style={{ minWidth: '8rem' }} />
|
|
295
|
+
</DataTable>
|
|
296
|
+
</div>
|
|
297
|
+
|
|
298
|
+
{/* ----------------------------------------------------- Connect dialog */}
|
|
299
|
+
<Dialog
|
|
300
|
+
header={connectTarget ? `Connect to ${connectTarget.ssid}` : 'Connect'}
|
|
301
|
+
visible={!!connectTarget}
|
|
302
|
+
style={{ width: '24rem' }}
|
|
303
|
+
onHide={() => { if (!submitting) setConnectTarget(null); }}
|
|
304
|
+
modal
|
|
305
|
+
draggable={false}
|
|
306
|
+
>
|
|
307
|
+
{connectTarget && (
|
|
308
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
|
309
|
+
<div style={{ fontSize: '0.875rem', color: '#9ca3af' }}>
|
|
310
|
+
<div>Signal: {connectTarget.signal}%</div>
|
|
311
|
+
<div>Security: {isSecured(connectTarget.security) ? connectTarget.security : 'Open'}</div>
|
|
312
|
+
</div>
|
|
313
|
+
{isSecured(connectTarget.security) ? (
|
|
314
|
+
<>
|
|
315
|
+
<label htmlFor="nw-pw" style={{ fontSize: '0.875rem' }}>Password</label>
|
|
316
|
+
<Password
|
|
317
|
+
inputId="nw-pw"
|
|
318
|
+
value={password}
|
|
319
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
320
|
+
feedback={false}
|
|
321
|
+
toggleMask
|
|
322
|
+
autoFocus
|
|
323
|
+
/>
|
|
324
|
+
</>
|
|
325
|
+
) : (
|
|
326
|
+
<div style={{ fontSize: '0.875rem' }}>
|
|
327
|
+
This is an open network. No password required.
|
|
328
|
+
</div>
|
|
329
|
+
)}
|
|
330
|
+
<div style={{
|
|
331
|
+
background: '#78350f',
|
|
332
|
+
color: 'white',
|
|
333
|
+
padding: '0.5rem 0.75rem',
|
|
334
|
+
borderRadius: 4,
|
|
335
|
+
fontSize: '0.875rem',
|
|
336
|
+
}}>
|
|
337
|
+
After connecting, you have 60 seconds to confirm the
|
|
338
|
+
change. If you don't, the server will auto-revert
|
|
339
|
+
to the previous network — that way a bad password
|
|
340
|
+
from a remote session can't lock you out.
|
|
341
|
+
</div>
|
|
342
|
+
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.4rem' }}>
|
|
343
|
+
<Button
|
|
344
|
+
label="Cancel"
|
|
345
|
+
outlined
|
|
346
|
+
onClick={() => setConnectTarget(null)}
|
|
347
|
+
disabled={submitting}
|
|
348
|
+
/>
|
|
349
|
+
<Button
|
|
350
|
+
label={submitting ? 'Connecting…' : 'Connect'}
|
|
351
|
+
icon={submitting ? 'pi pi-spin pi-spinner' : 'pi pi-link'}
|
|
352
|
+
onClick={() => { void submitConnect(); }}
|
|
353
|
+
disabled={submitting || (isSecured(connectTarget.security) && password.length === 0)}
|
|
354
|
+
/>
|
|
355
|
+
</div>
|
|
356
|
+
</div>
|
|
357
|
+
)}
|
|
358
|
+
</Dialog>
|
|
359
|
+
</div>
|
|
360
|
+
);
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
export default NetworkPanel;
|