@chrysb/alphaclaw 0.7.2-beta.5 → 0.7.2-beta.6
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/lib/public/js/components/file-viewer/use-file-viewer.js +0 -1
- package/lib/public/js/components/nodes-tab/connected-nodes/index.js +76 -3
- package/lib/public/js/components/nodes-tab/index.js +1 -1
- package/lib/public/js/components/nodes-tab/setup-wizard/index.js +33 -114
- package/lib/public/js/components/nodes-tab/setup-wizard/use-setup-wizard.js +53 -52
- package/lib/public/js/components/nodes-tab/use-nodes-tab.js +4 -1
- package/lib/public/js/lib/api.js +31 -1
- package/lib/server/routes/nodes.js +92 -7
- package/package.json +1 -1
|
@@ -3,15 +3,17 @@ import htm from "https://esm.sh/htm";
|
|
|
3
3
|
import { useCallback, useEffect, useRef, useState } from "https://esm.sh/preact/hooks";
|
|
4
4
|
import { ActionButton } from "../../action-button.js";
|
|
5
5
|
import { Badge } from "../../badge.js";
|
|
6
|
+
import { ConfirmDialog } from "../../confirm-dialog.js";
|
|
6
7
|
import { ComputerLineIcon, FileCopyLineIcon } from "../../icons.js";
|
|
7
8
|
import { LoadingSpinner } from "../../loading-spinner.js";
|
|
8
9
|
import { copyTextToClipboard } from "../../../lib/clipboard.js";
|
|
9
|
-
import { fetchNodeBrowserStatusForNode } from "../../../lib/api.js";
|
|
10
|
+
import { fetchNodeBrowserStatusForNode, removeNode } from "../../../lib/api.js";
|
|
11
|
+
import { OverflowMenu, OverflowMenuItem } from "../../overflow-menu.js";
|
|
10
12
|
import { readUiSettings, updateUiSettings } from "../../../lib/ui-settings.js";
|
|
11
13
|
import { showToast } from "../../toast.js";
|
|
12
14
|
|
|
13
15
|
const html = htm.bind(h);
|
|
14
|
-
const kBrowserCheckTimeoutMs =
|
|
16
|
+
const kBrowserCheckTimeoutMs = 15000;
|
|
15
17
|
const kBrowserPollIntervalMs = 10000;
|
|
16
18
|
const kBrowserAttachStateByNodeKey = "nodesBrowserAttachStateByNode";
|
|
17
19
|
|
|
@@ -108,6 +110,7 @@ export const ConnectedNodesCard = ({
|
|
|
108
110
|
loading = false,
|
|
109
111
|
error = "",
|
|
110
112
|
connectInfo = null,
|
|
113
|
+
onRefreshNodes = async () => {},
|
|
111
114
|
}) => {
|
|
112
115
|
const [browserStatusByNodeId, setBrowserStatusByNodeId] = useState({});
|
|
113
116
|
const [browserErrorByNodeId, setBrowserErrorByNodeId] = useState({});
|
|
@@ -115,6 +118,9 @@ export const ConnectedNodesCard = ({
|
|
|
115
118
|
const [browserAttachStateByNodeId, setBrowserAttachStateByNodeId] = useState(() =>
|
|
116
119
|
readBrowserAttachStateByNode(),
|
|
117
120
|
);
|
|
121
|
+
const [menuOpenNodeId, setMenuOpenNodeId] = useState("");
|
|
122
|
+
const [removeDialogNode, setRemoveDialogNode] = useState(null);
|
|
123
|
+
const [removingNodeId, setRemovingNodeId] = useState("");
|
|
118
124
|
const browserPollCursorRef = useRef(0);
|
|
119
125
|
const browserCheckInFlightNodeIdRef = useRef("");
|
|
120
126
|
|
|
@@ -200,6 +206,33 @@ export const ConnectedNodesCard = ({
|
|
|
200
206
|
});
|
|
201
207
|
}, [setBrowserAttachStateForNode]);
|
|
202
208
|
|
|
209
|
+
const handleOpenNodeMenu = useCallback((nodeId) => {
|
|
210
|
+
const normalizedNodeId = String(nodeId || "").trim();
|
|
211
|
+
if (!normalizedNodeId) return;
|
|
212
|
+
setMenuOpenNodeId((currentNodeId) =>
|
|
213
|
+
currentNodeId === normalizedNodeId ? "" : normalizedNodeId,
|
|
214
|
+
);
|
|
215
|
+
}, []);
|
|
216
|
+
|
|
217
|
+
const handleRemoveNode = useCallback(async () => {
|
|
218
|
+
const nodeId = String(removeDialogNode?.nodeId || "").trim();
|
|
219
|
+
if (!nodeId || removingNodeId) return;
|
|
220
|
+
setRemovingNodeId(nodeId);
|
|
221
|
+
try {
|
|
222
|
+
await removeNode(nodeId);
|
|
223
|
+
// Removing a device should also clear local browser-attach state for that node.
|
|
224
|
+
handleDetachNodeBrowser(nodeId);
|
|
225
|
+
showToast("Device removed", "success");
|
|
226
|
+
setRemoveDialogNode(null);
|
|
227
|
+
setMenuOpenNodeId("");
|
|
228
|
+
await onRefreshNodes();
|
|
229
|
+
} catch (removeError) {
|
|
230
|
+
showToast(removeError.message || "Could not remove node", "error");
|
|
231
|
+
} finally {
|
|
232
|
+
setRemovingNodeId("");
|
|
233
|
+
}
|
|
234
|
+
}, [handleDetachNodeBrowser, onRefreshNodes, removeDialogNode, removingNodeId]);
|
|
235
|
+
|
|
203
236
|
useEffect(() => {
|
|
204
237
|
if (checkingBrowserNodeId) return;
|
|
205
238
|
const pollableNodeIds = nodes
|
|
@@ -310,7 +343,30 @@ export const ConnectedNodesCard = ({
|
|
|
310
343
|
${node?.nodeId || ""}
|
|
311
344
|
</div>
|
|
312
345
|
</div>
|
|
313
|
-
|
|
346
|
+
<div class="flex items-center gap-1.5">
|
|
347
|
+
${renderNodeStatusBadge(node)}
|
|
348
|
+
${node?.paired
|
|
349
|
+
? html`
|
|
350
|
+
<${OverflowMenu}
|
|
351
|
+
open=${menuOpenNodeId === nodeId}
|
|
352
|
+
ariaLabel="Open node actions"
|
|
353
|
+
title="Open node actions"
|
|
354
|
+
onClose=${() => setMenuOpenNodeId("")}
|
|
355
|
+
onToggle=${() => handleOpenNodeMenu(nodeId)}
|
|
356
|
+
>
|
|
357
|
+
<${OverflowMenuItem}
|
|
358
|
+
className="text-red-300 hover:text-red-200"
|
|
359
|
+
onClick=${() => {
|
|
360
|
+
setMenuOpenNodeId("");
|
|
361
|
+
setRemoveDialogNode(node);
|
|
362
|
+
}}
|
|
363
|
+
>
|
|
364
|
+
Remove device
|
|
365
|
+
</${OverflowMenuItem}>
|
|
366
|
+
</${OverflowMenu}>
|
|
367
|
+
`
|
|
368
|
+
: null}
|
|
369
|
+
</div>
|
|
314
370
|
</div>
|
|
315
371
|
<div class="flex flex-wrap gap-2 text-[11px] text-gray-500">
|
|
316
372
|
<span>platform: <code>${node?.platform || "unknown"}</code></span>
|
|
@@ -459,5 +515,22 @@ export const ConnectedNodesCard = ({
|
|
|
459
515
|
</div>
|
|
460
516
|
`}
|
|
461
517
|
</div>
|
|
518
|
+
<${ConfirmDialog}
|
|
519
|
+
visible=${!!removeDialogNode}
|
|
520
|
+
title="Remove device?"
|
|
521
|
+
message=${removeDialogNode?.connected
|
|
522
|
+
? "This device is currently connected. Removing it will disconnect and remove the paired device from this gateway (equivalent to running openclaw devices remove for this device id). The device can reconnect and pair again later."
|
|
523
|
+
: "This removes the paired device from this gateway (equivalent to running openclaw devices remove for this device id). The device can reconnect and pair again later."}
|
|
524
|
+
confirmLabel="Remove device"
|
|
525
|
+
confirmLoadingLabel="Removing..."
|
|
526
|
+
confirmTone="warning"
|
|
527
|
+
confirmLoading=${Boolean(removingNodeId)}
|
|
528
|
+
confirmDisabled=${Boolean(removingNodeId)}
|
|
529
|
+
onCancel=${() => {
|
|
530
|
+
if (removingNodeId) return;
|
|
531
|
+
setRemoveDialogNode(null);
|
|
532
|
+
}}
|
|
533
|
+
onConfirm=${handleRemoveNode}
|
|
534
|
+
/>
|
|
462
535
|
`;
|
|
463
536
|
};
|
|
@@ -40,6 +40,7 @@ export const NodesTab = ({ onRestartRequired = () => {} }) => {
|
|
|
40
40
|
loading=${state.loadingNodes}
|
|
41
41
|
error=${state.nodesError}
|
|
42
42
|
connectInfo=${state.connectInfo}
|
|
43
|
+
onRefreshNodes=${actions.refreshNodes}
|
|
43
44
|
/>
|
|
44
45
|
|
|
45
46
|
<${BrowserAttachCard} />
|
|
@@ -47,7 +48,6 @@ export const NodesTab = ({ onRestartRequired = () => {} }) => {
|
|
|
47
48
|
<${NodesSetupWizard}
|
|
48
49
|
visible=${state.wizardVisible}
|
|
49
50
|
nodes=${state.nodes}
|
|
50
|
-
pending=${state.pending}
|
|
51
51
|
refreshNodes=${actions.refreshNodes}
|
|
52
52
|
onRestartRequired=${onRestartRequired}
|
|
53
53
|
onClose=${actions.closeWizard}
|
|
@@ -3,6 +3,7 @@ import htm from "https://esm.sh/htm";
|
|
|
3
3
|
import { ModalShell } from "../../modal-shell.js";
|
|
4
4
|
import { ActionButton } from "../../action-button.js";
|
|
5
5
|
import { CloseIcon, FileCopyLineIcon } from "../../icons.js";
|
|
6
|
+
import { DevicePairings } from "../../device-pairings.js";
|
|
6
7
|
import { copyTextToClipboard } from "../../../lib/clipboard.js";
|
|
7
8
|
import { showToast } from "../../toast.js";
|
|
8
9
|
import { useSetupWizard } from "./use-setup-wizard.js";
|
|
@@ -12,8 +13,6 @@ const html = htm.bind(h);
|
|
|
12
13
|
const kWizardSteps = [
|
|
13
14
|
"Install OpenClaw CLI",
|
|
14
15
|
"Connect Node",
|
|
15
|
-
"Approve Node",
|
|
16
|
-
"Set Gateway Routing",
|
|
17
16
|
];
|
|
18
17
|
|
|
19
18
|
const renderCommandBlock = ({ command = "", onCopy = () => {} }) => html`
|
|
@@ -44,7 +43,6 @@ const copyAndToast = async (value, label = "text") => {
|
|
|
44
43
|
export const NodesSetupWizard = ({
|
|
45
44
|
visible = false,
|
|
46
45
|
nodes = [],
|
|
47
|
-
pending = [],
|
|
48
46
|
refreshNodes = async () => {},
|
|
49
47
|
onRestartRequired = () => {},
|
|
50
48
|
onClose = () => {},
|
|
@@ -52,13 +50,11 @@ export const NodesSetupWizard = ({
|
|
|
52
50
|
const state = useSetupWizard({
|
|
53
51
|
visible,
|
|
54
52
|
nodes,
|
|
55
|
-
pending,
|
|
56
53
|
refreshNodes,
|
|
57
54
|
onRestartRequired,
|
|
58
55
|
onClose,
|
|
59
56
|
});
|
|
60
57
|
const isFinalStep = state.step === kWizardSteps.length - 1;
|
|
61
|
-
const canApproveSelectedNode = state.selectedNode?.paired === false;
|
|
62
58
|
|
|
63
59
|
return html`
|
|
64
60
|
<${ModalShell}
|
|
@@ -127,101 +123,26 @@ export const NodesSetupWizard = ({
|
|
|
127
123
|
onCopy: () =>
|
|
128
124
|
copyAndToast(state.connectCommand || "", "command"),
|
|
129
125
|
})}
|
|
130
|
-
|
|
131
|
-
${state.pendingSelectableNodes.length
|
|
132
|
-
? `${state.pendingSelectableNodes.length} pending node${state.pendingSelectableNodes.length === 1
|
|
133
|
-
? ""
|
|
134
|
-
: "s"} detected. Continue to Step 3 to approve.`
|
|
135
|
-
: "Pairing requests will show up here. Checks every 3s."}
|
|
136
|
-
</div>
|
|
137
|
-
</div>
|
|
138
|
-
`
|
|
139
|
-
: null}
|
|
140
|
-
|
|
141
|
-
${state.step === 2
|
|
142
|
-
? html`
|
|
143
|
-
<div class="space-y-2">
|
|
144
|
-
<div class="text-xs text-gray-500">
|
|
145
|
-
Select the node to approve after you run the connect command.
|
|
146
|
-
</div>
|
|
147
|
-
<div class="text-xs text-gray-500">
|
|
148
|
-
This list refreshes automatically every
|
|
149
|
-
${" "}
|
|
150
|
-
<code>${Math.round(state.nodeDiscoveryPollIntervalMs / 1000)}s</code>
|
|
151
|
-
${" "}
|
|
152
|
-
while this step is open.
|
|
153
|
-
</div>
|
|
154
|
-
<div class="flex items-center gap-2">
|
|
155
|
-
<select
|
|
156
|
-
value=${state.selectedNodeId}
|
|
157
|
-
oninput=${(event) => state.setSelectedNodeId(event.target.value)}
|
|
158
|
-
class="flex-1 min-w-0 bg-black/30 border border-border rounded-lg px-2.5 py-2 text-xs font-mono focus:border-gray-500 focus:outline-none"
|
|
159
|
-
>
|
|
160
|
-
<option value="">
|
|
161
|
-
${state.selectableNodes.length
|
|
162
|
-
? "Select node..."
|
|
163
|
-
: "No nodes found yet"}
|
|
164
|
-
</option>
|
|
165
|
-
${state.selectableNodes.map(
|
|
166
|
-
(entry) => html`
|
|
167
|
-
<option value=${entry.nodeId}>
|
|
168
|
-
${entry.displayName} (${entry.nodeId.slice(0, 12)}...)${entry.paired ===
|
|
169
|
-
false
|
|
170
|
-
? " · pending approval"
|
|
171
|
-
: " · already paired"}
|
|
172
|
-
</option>
|
|
173
|
-
`,
|
|
174
|
-
)}
|
|
175
|
-
</select>
|
|
176
|
-
<${ActionButton}
|
|
177
|
-
onClick=${state.refreshNodeList}
|
|
178
|
-
idleLabel="Refresh"
|
|
179
|
-
tone="secondary"
|
|
180
|
-
size="sm"
|
|
181
|
-
/>
|
|
182
|
-
</div>
|
|
183
|
-
${state.selectedNodeId && !canApproveSelectedNode
|
|
126
|
+
${state.devicePending.length
|
|
184
127
|
? html`
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
node to continue.
|
|
191
|
-
</div>
|
|
128
|
+
<${DevicePairings}
|
|
129
|
+
pending=${state.devicePending}
|
|
130
|
+
onApprove=${state.handleDeviceApprove}
|
|
131
|
+
onReject=${state.handleDeviceReject}
|
|
132
|
+
/>
|
|
192
133
|
`
|
|
193
|
-
:
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
</div>
|
|
206
|
-
<div class="rounded-lg border border-yellow-500/40 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-200">
|
|
207
|
-
Node-host permissions are local to the connected machine and are
|
|
208
|
-
not managed from this wizard yet.
|
|
209
|
-
</div>
|
|
210
|
-
<${ActionButton}
|
|
211
|
-
onClick=${async () => {
|
|
212
|
-
const ok = await state.applyGatewayNodeRouting();
|
|
213
|
-
if (ok) {
|
|
214
|
-
await refreshNodes();
|
|
215
|
-
state.completeWizard();
|
|
216
|
-
}
|
|
217
|
-
}}
|
|
218
|
-
loading=${state.configuring}
|
|
219
|
-
idleLabel="Apply Gateway Routing + Finish"
|
|
220
|
-
loadingLabel="Applying..."
|
|
221
|
-
tone="primary"
|
|
222
|
-
size="sm"
|
|
223
|
-
disabled=${!state.selectedNodeId}
|
|
224
|
-
/>
|
|
134
|
+
: state.selectedPairedNode && !state.selectedPairedNode.connected
|
|
135
|
+
? html`
|
|
136
|
+
<div class="rounded-lg border border-yellow-500/40 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-200">
|
|
137
|
+
Node is paired but currently disconnected. Run the node
|
|
138
|
+
command again on your device, then Finish will enable.
|
|
139
|
+
</div>
|
|
140
|
+
`
|
|
141
|
+
: html`
|
|
142
|
+
<div class="rounded-lg border border-border bg-black/20 px-3 py-2 text-xs text-gray-400">
|
|
143
|
+
Pairing request will show up here. Checks every 3s.
|
|
144
|
+
</div>
|
|
145
|
+
`}
|
|
225
146
|
</div>
|
|
226
147
|
`
|
|
227
148
|
: null}
|
|
@@ -241,31 +162,29 @@ export const NodesSetupWizard = ({
|
|
|
241
162
|
${isFinalStep
|
|
242
163
|
? html`
|
|
243
164
|
<${ActionButton}
|
|
244
|
-
onClick=${
|
|
245
|
-
|
|
246
|
-
|
|
165
|
+
onClick=${async () => {
|
|
166
|
+
const ok = await state.applyGatewayNodeRouting();
|
|
167
|
+
if (!ok) return;
|
|
168
|
+
await refreshNodes();
|
|
169
|
+
state.completeWizard();
|
|
170
|
+
}}
|
|
171
|
+
loading=${state.configuring}
|
|
172
|
+
idleLabel="Finish"
|
|
173
|
+
loadingLabel="Finishing..."
|
|
174
|
+
tone="primary"
|
|
247
175
|
size="md"
|
|
248
176
|
className="w-full justify-center"
|
|
177
|
+
disabled=${!state.canFinish}
|
|
249
178
|
/>
|
|
250
179
|
`
|
|
251
180
|
: html`
|
|
252
181
|
<${ActionButton}
|
|
253
|
-
onClick=${
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
if (!approved) return;
|
|
257
|
-
}
|
|
258
|
-
state.setStep(Math.min(kWizardSteps.length - 1, state.step + 1));
|
|
259
|
-
}}
|
|
260
|
-
loading=${state.step === 2 &&
|
|
261
|
-
state.approvingNodeId === state.selectedNodeId}
|
|
262
|
-
idleLabel=${state.step === 2 ? "Approve" : "Next"}
|
|
263
|
-
loadingLabel="Approving..."
|
|
182
|
+
onClick=${() =>
|
|
183
|
+
state.setStep(Math.min(kWizardSteps.length - 1, state.step + 1))}
|
|
184
|
+
idleLabel="Next"
|
|
264
185
|
tone="primary"
|
|
265
186
|
size="md"
|
|
266
187
|
className="w-full justify-center"
|
|
267
|
-
disabled=${state.step === 2 &&
|
|
268
|
-
(!state.selectedNodeId || !canApproveSelectedNode)}
|
|
269
188
|
/>
|
|
270
189
|
`}
|
|
271
190
|
</div>
|
|
@@ -6,9 +6,11 @@ import {
|
|
|
6
6
|
useState,
|
|
7
7
|
} from "https://esm.sh/preact/hooks";
|
|
8
8
|
import {
|
|
9
|
-
|
|
9
|
+
approveDevice,
|
|
10
|
+
fetchDevicePairings,
|
|
10
11
|
fetchNodeConnectInfo,
|
|
11
|
-
|
|
12
|
+
rejectDevice,
|
|
13
|
+
routeExecToNode,
|
|
12
14
|
} from "../../../lib/api.js";
|
|
13
15
|
import { showToast } from "../../toast.js";
|
|
14
16
|
|
|
@@ -17,7 +19,6 @@ const kNodeDiscoveryPollIntervalMs = 3000;
|
|
|
17
19
|
export const useSetupWizard = ({
|
|
18
20
|
visible = false,
|
|
19
21
|
nodes = [],
|
|
20
|
-
pending = [],
|
|
21
22
|
refreshNodes = async () => {},
|
|
22
23
|
onRestartRequired = () => {},
|
|
23
24
|
onClose = () => {},
|
|
@@ -27,15 +28,14 @@ export const useSetupWizard = ({
|
|
|
27
28
|
const [loadingConnectInfo, setLoadingConnectInfo] = useState(false);
|
|
28
29
|
const [displayName, setDisplayName] = useState("My Mac Node");
|
|
29
30
|
const [selectedNodeId, setSelectedNodeId] = useState("");
|
|
30
|
-
const [approvingNodeId, setApprovingNodeId] = useState("");
|
|
31
31
|
const [configuring, setConfiguring] = useState(false);
|
|
32
|
+
const [devicePending, setDevicePending] = useState([]);
|
|
32
33
|
const refreshInFlightRef = useRef(false);
|
|
33
34
|
|
|
34
35
|
useEffect(() => {
|
|
35
36
|
if (!visible) return;
|
|
36
37
|
setStep(0);
|
|
37
38
|
setSelectedNodeId("");
|
|
38
|
-
setApprovingNodeId("");
|
|
39
39
|
setConfiguring(false);
|
|
40
40
|
}, [visible]);
|
|
41
41
|
|
|
@@ -54,38 +54,29 @@ export const useSetupWizard = ({
|
|
|
54
54
|
});
|
|
55
55
|
}, [visible]);
|
|
56
56
|
|
|
57
|
-
const
|
|
58
|
-
const all = [
|
|
59
|
-
...pending.map((entry) => ({ ...entry, pendingApproval: true })),
|
|
60
|
-
...nodes.map((entry) => ({ ...entry, pendingApproval: false })),
|
|
61
|
-
];
|
|
57
|
+
const pairedNodes = useMemo(() => {
|
|
62
58
|
const seen = new Set();
|
|
63
59
|
const unique = [];
|
|
64
|
-
for (const entry of
|
|
65
|
-
const nodeId = String(entry?.nodeId ||
|
|
60
|
+
for (const entry of nodes) {
|
|
61
|
+
const nodeId = String(entry?.nodeId || "").trim();
|
|
66
62
|
if (!nodeId || seen.has(nodeId)) continue;
|
|
63
|
+
if (entry?.paired === false) continue;
|
|
67
64
|
seen.add(nodeId);
|
|
68
65
|
unique.push({
|
|
69
66
|
nodeId,
|
|
70
67
|
displayName: String(entry?.displayName || entry?.name || nodeId),
|
|
71
|
-
paired: entry?.pendingApproval ? false : entry?.paired !== false,
|
|
72
68
|
connected: entry?.connected === true,
|
|
73
69
|
});
|
|
74
70
|
}
|
|
75
71
|
return unique;
|
|
76
|
-
}, [nodes
|
|
72
|
+
}, [nodes]);
|
|
77
73
|
|
|
78
|
-
const
|
|
79
|
-
() => selectableNodes.filter((entry) => entry.paired === false),
|
|
80
|
-
[selectableNodes],
|
|
81
|
-
);
|
|
82
|
-
|
|
83
|
-
const selectedNode = useMemo(
|
|
74
|
+
const selectedPairedNode = useMemo(
|
|
84
75
|
() =>
|
|
85
|
-
|
|
76
|
+
pairedNodes.find(
|
|
86
77
|
(entry) => entry.nodeId === String(selectedNodeId || "").trim(),
|
|
87
78
|
) || null,
|
|
88
|
-
[
|
|
79
|
+
[pairedNodes, selectedNodeId],
|
|
89
80
|
);
|
|
90
81
|
|
|
91
82
|
const connectCommand = useMemo(() => {
|
|
@@ -114,13 +105,18 @@ export const useSetupWizard = ({
|
|
|
114
105
|
refreshInFlightRef.current = true;
|
|
115
106
|
try {
|
|
116
107
|
await refreshNodes();
|
|
108
|
+
const deviceData = await fetchDevicePairings();
|
|
109
|
+
const pendingList = Array.isArray(deviceData?.pending)
|
|
110
|
+
? deviceData.pending
|
|
111
|
+
: [];
|
|
112
|
+
setDevicePending(pendingList);
|
|
117
113
|
} finally {
|
|
118
114
|
refreshInFlightRef.current = false;
|
|
119
115
|
}
|
|
120
116
|
}, [refreshNodes]);
|
|
121
117
|
|
|
122
118
|
useEffect(() => {
|
|
123
|
-
if (!visible ||
|
|
119
|
+
if (!visible || step !== 1) return;
|
|
124
120
|
let active = true;
|
|
125
121
|
const poll = async () => {
|
|
126
122
|
if (!active) return;
|
|
@@ -137,45 +133,49 @@ export const useSetupWizard = ({
|
|
|
137
133
|
}, [refreshNodeList, step, visible]);
|
|
138
134
|
|
|
139
135
|
useEffect(() => {
|
|
140
|
-
if (!visible || step !==
|
|
141
|
-
const hasSelected =
|
|
136
|
+
if (!visible || step !== 1) return;
|
|
137
|
+
const hasSelected = pairedNodes.some(
|
|
142
138
|
(entry) => entry.nodeId === String(selectedNodeId || "").trim(),
|
|
143
139
|
);
|
|
144
|
-
|
|
140
|
+
const normalizedDisplayName = String(displayName || "").trim().toLowerCase();
|
|
145
141
|
const preferredNode =
|
|
146
|
-
|
|
142
|
+
pairedNodes.find(
|
|
143
|
+
(entry) =>
|
|
144
|
+
String(entry?.displayName || "")
|
|
145
|
+
.trim()
|
|
146
|
+
.toLowerCase() === normalizedDisplayName,
|
|
147
|
+
) || pairedNodes[0];
|
|
147
148
|
if (!preferredNode) return;
|
|
149
|
+
if (hasSelected && String(selectedNodeId || "").trim() === preferredNode.nodeId) return;
|
|
148
150
|
setSelectedNodeId(preferredNode.nodeId);
|
|
149
|
-
}, [
|
|
151
|
+
}, [displayName, pairedNodes, selectedNodeId, step, visible]);
|
|
150
152
|
|
|
151
|
-
const
|
|
152
|
-
const nodeId = String(selectedNodeId || "").trim();
|
|
153
|
-
if (!nodeId || approvingNodeId) return false;
|
|
154
|
-
setApprovingNodeId(nodeId);
|
|
153
|
+
const handleDeviceApprove = useCallback(async (requestId) => {
|
|
155
154
|
try {
|
|
156
|
-
await
|
|
157
|
-
showToast("
|
|
158
|
-
await
|
|
159
|
-
return true;
|
|
155
|
+
await approveDevice(requestId);
|
|
156
|
+
showToast("Pairing approved", "success");
|
|
157
|
+
await refreshNodeList();
|
|
160
158
|
} catch (err) {
|
|
161
|
-
showToast(err.message || "Could not approve
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
159
|
+
showToast(err.message || "Could not approve pairing", "error");
|
|
160
|
+
}
|
|
161
|
+
}, [refreshNodeList]);
|
|
162
|
+
|
|
163
|
+
const handleDeviceReject = useCallback(async (requestId) => {
|
|
164
|
+
try {
|
|
165
|
+
await rejectDevice(requestId);
|
|
166
|
+
showToast("Pairing rejected", "info");
|
|
167
|
+
await refreshNodeList();
|
|
168
|
+
} catch (err) {
|
|
169
|
+
showToast(err.message || "Could not reject pairing", "error");
|
|
165
170
|
}
|
|
166
|
-
}, [
|
|
171
|
+
}, [refreshNodeList]);
|
|
167
172
|
|
|
168
173
|
const applyGatewayNodeRouting = useCallback(async () => {
|
|
169
174
|
const nodeId = String(selectedNodeId || "").trim();
|
|
170
175
|
if (!nodeId || configuring) return false;
|
|
171
176
|
setConfiguring(true);
|
|
172
177
|
try {
|
|
173
|
-
await
|
|
174
|
-
host: "node",
|
|
175
|
-
security: "allowlist",
|
|
176
|
-
ask: "on-miss",
|
|
177
|
-
node: nodeId,
|
|
178
|
-
});
|
|
178
|
+
await routeExecToNode(nodeId);
|
|
179
179
|
onRestartRequired(true);
|
|
180
180
|
showToast("Gateway routing now points to the selected node", "success");
|
|
181
181
|
return true;
|
|
@@ -200,15 +200,16 @@ export const useSetupWizard = ({
|
|
|
200
200
|
setDisplayName,
|
|
201
201
|
selectedNodeId,
|
|
202
202
|
setSelectedNodeId,
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
approvingNodeId,
|
|
203
|
+
pairedNodes,
|
|
204
|
+
selectedPairedNode,
|
|
205
|
+
devicePending,
|
|
207
206
|
configuring,
|
|
207
|
+
canFinish: Boolean(selectedPairedNode?.connected),
|
|
208
208
|
connectCommand,
|
|
209
209
|
refreshNodeList,
|
|
210
210
|
nodeDiscoveryPollIntervalMs: kNodeDiscoveryPollIntervalMs,
|
|
211
|
-
|
|
211
|
+
handleDeviceApprove,
|
|
212
|
+
handleDeviceReject,
|
|
212
213
|
applyGatewayNodeRouting,
|
|
213
214
|
completeWizard,
|
|
214
215
|
};
|
|
@@ -8,6 +8,9 @@ export const useNodesTab = () => {
|
|
|
8
8
|
const [wizardVisible, setWizardVisible] = useState(false);
|
|
9
9
|
const [connectInfo, setConnectInfo] = useState(null);
|
|
10
10
|
const [refreshingNodes, setRefreshingNodes] = useState(false);
|
|
11
|
+
const pairedNodes = Array.isArray(connectedNodesState.nodes)
|
|
12
|
+
? connectedNodesState.nodes.filter((entry) => entry?.paired !== false)
|
|
13
|
+
: [];
|
|
11
14
|
|
|
12
15
|
useEffect(() => {
|
|
13
16
|
fetchNodeConnectInfo()
|
|
@@ -32,7 +35,7 @@ export const useNodesTab = () => {
|
|
|
32
35
|
return {
|
|
33
36
|
state: {
|
|
34
37
|
wizardVisible,
|
|
35
|
-
nodes:
|
|
38
|
+
nodes: pairedNodes,
|
|
36
39
|
pending: connectedNodesState.pending,
|
|
37
40
|
loadingNodes: connectedNodesState.loading,
|
|
38
41
|
refreshingNodes,
|
package/lib/public/js/lib/api.js
CHANGED
|
@@ -679,6 +679,34 @@ export const approveNode = async (nodeId) => {
|
|
|
679
679
|
return parseJsonOrThrow(res, "Could not approve node");
|
|
680
680
|
};
|
|
681
681
|
|
|
682
|
+
export const removeNode = async (nodeId) => {
|
|
683
|
+
const safeNodeId = encodeURIComponent(String(nodeId || ""));
|
|
684
|
+
const res = await authFetch(`/api/nodes/${safeNodeId}`, {
|
|
685
|
+
method: "DELETE",
|
|
686
|
+
});
|
|
687
|
+
return parseJsonOrThrow(res, "Could not remove node");
|
|
688
|
+
};
|
|
689
|
+
|
|
690
|
+
export const routeExecToNode = async (nodeId) => {
|
|
691
|
+
const safeNodeId = encodeURIComponent(String(nodeId || ""));
|
|
692
|
+
const controller = new AbortController();
|
|
693
|
+
const timeoutId = setTimeout(() => controller.abort(), 20000);
|
|
694
|
+
try {
|
|
695
|
+
const res = await authFetch(`/api/nodes/${safeNodeId}/route`, {
|
|
696
|
+
method: "POST",
|
|
697
|
+
signal: controller.signal,
|
|
698
|
+
});
|
|
699
|
+
return parseJsonOrThrow(res, "Could not route execution to node");
|
|
700
|
+
} catch (error) {
|
|
701
|
+
if (String(error?.name || "") === "AbortError") {
|
|
702
|
+
throw new Error("Routing timed out. Gateway may be restarting or unavailable.");
|
|
703
|
+
}
|
|
704
|
+
throw error;
|
|
705
|
+
} finally {
|
|
706
|
+
clearTimeout(timeoutId);
|
|
707
|
+
}
|
|
708
|
+
};
|
|
709
|
+
|
|
682
710
|
export const fetchNodeConnectInfo = async () => {
|
|
683
711
|
const res = await authFetch("/api/nodes/connect-info");
|
|
684
712
|
return parseJsonOrThrow(res, "Could not load connect info");
|
|
@@ -1132,10 +1160,12 @@ export const fetchFileContent = async (filePath) => {
|
|
|
1132
1160
|
};
|
|
1133
1161
|
|
|
1134
1162
|
export const saveFileContent = async (filePath, content) => {
|
|
1163
|
+
const normalizedPath = String(filePath || "");
|
|
1164
|
+
const normalizedContent = typeof content === "string" ? content : String(content ?? "");
|
|
1135
1165
|
const res = await authFetch("/api/browse/write", {
|
|
1136
1166
|
method: "PUT",
|
|
1137
1167
|
headers: { "Content-Type": "application/json" },
|
|
1138
|
-
body: JSON.stringify({ path:
|
|
1168
|
+
body: JSON.stringify({ path: normalizedPath, content: normalizedContent }),
|
|
1139
1169
|
});
|
|
1140
1170
|
return parseJsonOrThrow(res, "Could not save file");
|
|
1141
1171
|
};
|
|
@@ -7,8 +7,9 @@ const kAllowedExecHosts = new Set(["gateway", "node"]);
|
|
|
7
7
|
const kAllowedExecSecurity = new Set(["deny", "allowlist", "full"]);
|
|
8
8
|
const kAllowedExecAsk = new Set(["off", "on-miss", "always"]);
|
|
9
9
|
const kSafeNodeIdPattern = /^[\w\-:.]+$/;
|
|
10
|
-
const kNodeBrowserInvokeTimeoutMs =
|
|
11
|
-
const kNodeBrowserCliTimeoutMs =
|
|
10
|
+
const kNodeBrowserInvokeTimeoutMs = 15000;
|
|
11
|
+
const kNodeBrowserCliTimeoutMs = 18000;
|
|
12
|
+
const kNodeRouteCliTimeoutMs = 12000;
|
|
12
13
|
|
|
13
14
|
const quoteCliArg = (value) => quoteShellArg(value, { strategy: "single" });
|
|
14
15
|
|
|
@@ -34,6 +35,31 @@ const parseNodesStatus = (stdout) => {
|
|
|
34
35
|
return { nodes, pending };
|
|
35
36
|
};
|
|
36
37
|
|
|
38
|
+
const parseNodesPending = (stdout) => {
|
|
39
|
+
const parsed = parseJsonObjectFromNoisyOutput(stdout) || {};
|
|
40
|
+
const list = Array.isArray(parsed.pending)
|
|
41
|
+
? parsed.pending
|
|
42
|
+
: Array.isArray(parsed.requests)
|
|
43
|
+
? parsed.requests
|
|
44
|
+
: Array.isArray(parsed.nodes)
|
|
45
|
+
? parsed.nodes
|
|
46
|
+
: [];
|
|
47
|
+
return list
|
|
48
|
+
.map((entry) => {
|
|
49
|
+
if (!entry || typeof entry !== "object") return null;
|
|
50
|
+
const requestId = String(entry.requestId || entry.id || "").trim();
|
|
51
|
+
const nodeId = String(entry.nodeId || requestId).trim();
|
|
52
|
+
if (!nodeId) return null;
|
|
53
|
+
return {
|
|
54
|
+
...entry,
|
|
55
|
+
id: requestId || nodeId,
|
|
56
|
+
nodeId,
|
|
57
|
+
paired: false,
|
|
58
|
+
};
|
|
59
|
+
})
|
|
60
|
+
.filter(Boolean);
|
|
61
|
+
};
|
|
62
|
+
|
|
37
63
|
const parseNodeBrowserStatus = (stdout) => {
|
|
38
64
|
const parsed = parseJsonObjectFromNoisyOutput(stdout) || {};
|
|
39
65
|
const payload =
|
|
@@ -146,15 +172,29 @@ const registerNodeRoutes = ({
|
|
|
146
172
|
fsModule,
|
|
147
173
|
}) => {
|
|
148
174
|
app.get("/api/nodes", async (_req, res) => {
|
|
149
|
-
const
|
|
150
|
-
if (!
|
|
175
|
+
const statusResult = await clawCmd("nodes status --json", { quiet: true });
|
|
176
|
+
if (!statusResult.ok) {
|
|
151
177
|
return res.status(500).json({
|
|
152
178
|
ok: false,
|
|
153
|
-
error:
|
|
179
|
+
error: statusResult.stderr || "Could not load nodes status",
|
|
154
180
|
});
|
|
155
181
|
}
|
|
156
|
-
const status = parseNodesStatus(
|
|
157
|
-
|
|
182
|
+
const status = parseNodesStatus(statusResult.stdout);
|
|
183
|
+
const pendingResult = await clawCmd("nodes pending --json", { quiet: true });
|
|
184
|
+
const pending = pendingResult.ok
|
|
185
|
+
? parseNodesPending(pendingResult.stdout)
|
|
186
|
+
: status.pending;
|
|
187
|
+
const pendingById = new Map();
|
|
188
|
+
for (const entry of pending) {
|
|
189
|
+
const nodeId = String(entry?.nodeId || entry?.id || "").trim();
|
|
190
|
+
if (!nodeId || pendingById.has(nodeId)) continue;
|
|
191
|
+
pendingById.set(nodeId, entry);
|
|
192
|
+
}
|
|
193
|
+
return res.json({
|
|
194
|
+
ok: true,
|
|
195
|
+
nodes: status.nodes,
|
|
196
|
+
pending: Array.from(pendingById.values()),
|
|
197
|
+
});
|
|
158
198
|
});
|
|
159
199
|
|
|
160
200
|
app.post("/api/nodes/:id/approve", async (req, res) => {
|
|
@@ -172,6 +212,51 @@ const registerNodeRoutes = ({
|
|
|
172
212
|
return res.json({ ok: true });
|
|
173
213
|
});
|
|
174
214
|
|
|
215
|
+
app.post("/api/nodes/:id/route", async (req, res) => {
|
|
216
|
+
const nodeId = String(req.params.id || "").trim();
|
|
217
|
+
if (!nodeId || !kSafeNodeIdPattern.test(nodeId)) {
|
|
218
|
+
return res.status(400).json({ ok: false, error: "Invalid node id" });
|
|
219
|
+
}
|
|
220
|
+
const commands = [
|
|
221
|
+
"config set tools.exec.host 'node'",
|
|
222
|
+
"config set tools.exec.security 'allowlist'",
|
|
223
|
+
"config set tools.exec.ask 'on-miss'",
|
|
224
|
+
`config set tools.exec.node ${quoteCliArg(nodeId)}`,
|
|
225
|
+
];
|
|
226
|
+
for (const command of commands) {
|
|
227
|
+
const result = await clawCmd(command, {
|
|
228
|
+
quiet: true,
|
|
229
|
+
timeoutMs: kNodeRouteCliTimeoutMs,
|
|
230
|
+
});
|
|
231
|
+
if (!result.ok) {
|
|
232
|
+
return res.status(500).json({
|
|
233
|
+
ok: false,
|
|
234
|
+
error:
|
|
235
|
+
result.stderr ||
|
|
236
|
+
`Could not apply node routing (${command})`,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return res.json({ ok: true, restartRequired: true, nodeId });
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
app.delete("/api/nodes/:id", async (req, res) => {
|
|
244
|
+
const nodeId = String(req.params.id || "").trim();
|
|
245
|
+
if (!nodeId || !kSafeNodeIdPattern.test(nodeId)) {
|
|
246
|
+
return res.status(400).json({ ok: false, error: "Invalid node id" });
|
|
247
|
+
}
|
|
248
|
+
const result = await clawCmd(`devices remove ${quoteCliArg(nodeId)}`, {
|
|
249
|
+
quiet: true,
|
|
250
|
+
});
|
|
251
|
+
if (!result.ok) {
|
|
252
|
+
return res.status(500).json({
|
|
253
|
+
ok: false,
|
|
254
|
+
error: result.stderr || "Could not remove node",
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
return res.json({ ok: true, nodeId });
|
|
258
|
+
});
|
|
259
|
+
|
|
175
260
|
app.get("/api/nodes/connect-info", async (req, res) => {
|
|
176
261
|
const baseUrl = resolveSetupUiBaseUrl(req);
|
|
177
262
|
const parsed = parseBaseUrlParts(baseUrl);
|