@chrysb/alphaclaw 0.7.2-beta.0 → 0.7.2-beta.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.
Files changed (39) hide show
  1. package/lib/public/css/theme.css +12 -1
  2. package/lib/public/js/app.js +10 -2
  3. package/lib/public/js/components/cron-tab/cron-job-detail.js +18 -2
  4. package/lib/public/js/components/cron-tab/cron-job-list.js +43 -0
  5. package/lib/public/js/components/cron-tab/cron-job-trends-panel.js +319 -0
  6. package/lib/public/js/components/cron-tab/cron-job-usage.js +22 -8
  7. package/lib/public/js/components/cron-tab/cron-overview.js +17 -13
  8. package/lib/public/js/components/cron-tab/cron-prompt-editor.js +1 -1
  9. package/lib/public/js/components/cron-tab/cron-run-history-panel.js +66 -30
  10. package/lib/public/js/components/cron-tab/cron-runs-trend-card.js +109 -53
  11. package/lib/public/js/components/cron-tab/index.js +6 -0
  12. package/lib/public/js/components/cron-tab/use-cron-tab.js +51 -0
  13. package/lib/public/js/components/nodes-tab/connected-nodes/index.js +85 -0
  14. package/lib/public/js/components/nodes-tab/connected-nodes/user-connected-nodes.js +25 -0
  15. package/lib/public/js/components/nodes-tab/exec-allowlist/index.js +89 -0
  16. package/lib/public/js/components/nodes-tab/exec-allowlist/use-exec-allowlist.js +78 -0
  17. package/lib/public/js/components/nodes-tab/exec-config/index.js +118 -0
  18. package/lib/public/js/components/nodes-tab/exec-config/use-exec-config.js +79 -0
  19. package/lib/public/js/components/nodes-tab/index.js +46 -0
  20. package/lib/public/js/components/nodes-tab/setup-wizard/index.js +243 -0
  21. package/lib/public/js/components/nodes-tab/setup-wizard/use-setup-wizard.js +159 -0
  22. package/lib/public/js/components/nodes-tab/use-nodes-tab.js +22 -0
  23. package/lib/public/js/components/routes/index.js +1 -0
  24. package/lib/public/js/components/routes/nodes-route.js +11 -0
  25. package/lib/public/js/components/usage-tab/constants.js +8 -0
  26. package/lib/public/js/components/usage-tab/formatters.js +13 -0
  27. package/lib/public/js/components/usage-tab/index.js +2 -0
  28. package/lib/public/js/components/usage-tab/overview-section.js +22 -4
  29. package/lib/public/js/components/usage-tab/use-usage-tab.js +61 -16
  30. package/lib/public/js/lib/api.js +61 -0
  31. package/lib/public/js/lib/app-navigation.js +2 -0
  32. package/lib/public/js/lib/format.js +50 -0
  33. package/lib/server/constants.js +1 -0
  34. package/lib/server/cron-service.js +230 -1
  35. package/lib/server/db/usage/summary.js +101 -1
  36. package/lib/server/init/register-server-routes.js +8 -0
  37. package/lib/server/routes/cron.js +11 -0
  38. package/lib/server/routes/nodes.js +286 -0
  39. package/package.json +2 -2
@@ -0,0 +1,25 @@
1
+ import { usePolling } from "../../../hooks/usePolling.js";
2
+ import { fetchNodesStatus } from "../../../lib/api.js";
3
+
4
+ const kNodesPollIntervalMs = 3000;
5
+
6
+ export const useConnectedNodes = ({ enabled = true } = {}) => {
7
+ const poll = usePolling(
8
+ async () => {
9
+ const result = await fetchNodesStatus();
10
+ const nodes = Array.isArray(result?.nodes) ? result.nodes : [];
11
+ const pending = Array.isArray(result?.pending) ? result.pending : [];
12
+ return { nodes, pending };
13
+ },
14
+ kNodesPollIntervalMs,
15
+ { enabled },
16
+ );
17
+
18
+ return {
19
+ nodes: Array.isArray(poll.data?.nodes) ? poll.data.nodes : [],
20
+ pending: Array.isArray(poll.data?.pending) ? poll.data.pending : [],
21
+ loading: poll.data === null && !poll.error,
22
+ error: poll.error ? String(poll.error.message || "Could not load nodes") : "",
23
+ refresh: poll.refresh,
24
+ };
25
+ };
@@ -0,0 +1,89 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import htm from "https://esm.sh/htm";
3
+ import { ActionButton } from "../../action-button.js";
4
+ import { useExecAllowlist } from "./use-exec-allowlist.js";
5
+
6
+ const html = htm.bind(h);
7
+
8
+ export const NodeExecAllowlistCard = () => {
9
+ const state = useExecAllowlist();
10
+
11
+ return html`
12
+ <div class="bg-surface border border-border rounded-xl p-4 space-y-3">
13
+ <div class="flex items-center justify-between gap-2">
14
+ <div class="space-y-1">
15
+ <h3 class="font-semibold text-sm">Gateway Exec Allowlist</h3>
16
+ <p class="text-xs text-gray-500">
17
+ Patterns here are used when <code>tools.exec.security</code> is set to
18
+ <code>allowlist</code>.
19
+ </p>
20
+ </div>
21
+ <${ActionButton}
22
+ onClick=${state.refresh}
23
+ idleLabel="Reload"
24
+ tone="secondary"
25
+ size="sm"
26
+ disabled=${state.loading}
27
+ />
28
+ </div>
29
+
30
+ ${state.error ? html`<div class="text-xs text-red-400">${state.error}</div>` : null}
31
+
32
+ <div class="flex items-center gap-2">
33
+ <input
34
+ type="text"
35
+ value=${state.patternInput}
36
+ oninput=${(event) => state.setPatternInput(event.target.value)}
37
+ placeholder="/usr/bin/sw_vers"
38
+ 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"
39
+ disabled=${state.loading || state.saving}
40
+ />
41
+ <${ActionButton}
42
+ onClick=${state.addPattern}
43
+ loading=${state.saving}
44
+ idleLabel="Add Pattern"
45
+ loadingLabel="Adding..."
46
+ tone="primary"
47
+ size="sm"
48
+ disabled=${!String(state.patternInput || "").trim()}
49
+ />
50
+ </div>
51
+
52
+ <div class="text-[11px] text-gray-500">
53
+ Supports wildcard patterns like <code>*</code>, <code>**</code>, and
54
+ exact executable paths.
55
+ </div>
56
+
57
+ ${state.loading
58
+ ? html`<div class="text-xs text-gray-500">Loading allowlist...</div>`
59
+ : !state.allowlist.length
60
+ ? html`<div class="text-xs text-gray-500">No allowlist patterns configured.</div>`
61
+ : html`
62
+ <div class="space-y-2">
63
+ ${state.allowlist.map(
64
+ (entry) => html`
65
+ <div class="ac-surface-inset rounded-lg px-3 py-2 flex items-center justify-between gap-2">
66
+ <div class="min-w-0">
67
+ <div class="text-xs font-mono text-gray-200 truncate">
68
+ ${entry?.pattern || ""}
69
+ </div>
70
+ <div class="text-[11px] text-gray-500 font-mono truncate">
71
+ ${entry?.id || ""}
72
+ </div>
73
+ </div>
74
+ <${ActionButton}
75
+ onClick=${() => state.removePattern(entry?.id)}
76
+ loading=${state.removingId === String(entry?.id || "")}
77
+ idleLabel="Remove"
78
+ loadingLabel="Removing..."
79
+ tone="danger"
80
+ size="sm"
81
+ />
82
+ </div>
83
+ `,
84
+ )}
85
+ </div>
86
+ `}
87
+ </div>
88
+ `;
89
+ };
@@ -0,0 +1,78 @@
1
+ import { useCallback, useEffect, useState } from "https://esm.sh/preact/hooks";
2
+ import {
3
+ addNodeExecAllowlistPattern,
4
+ fetchNodeExecApprovals,
5
+ removeNodeExecAllowlistPattern,
6
+ } from "../../../lib/api.js";
7
+ import { showToast } from "../../toast.js";
8
+
9
+ export const useExecAllowlist = () => {
10
+ const [allowlist, setAllowlist] = useState([]);
11
+ const [loading, setLoading] = useState(true);
12
+ const [error, setError] = useState("");
13
+ const [patternInput, setPatternInput] = useState("");
14
+ const [saving, setSaving] = useState(false);
15
+ const [removingId, setRemovingId] = useState("");
16
+
17
+ const refresh = useCallback(async () => {
18
+ setLoading(true);
19
+ setError("");
20
+ try {
21
+ const result = await fetchNodeExecApprovals();
22
+ const nextAllowlist = Array.isArray(result?.allowlist) ? result.allowlist : [];
23
+ setAllowlist(nextAllowlist);
24
+ } catch (err) {
25
+ setError(err.message || "Could not load allowlist");
26
+ } finally {
27
+ setLoading(false);
28
+ }
29
+ }, []);
30
+
31
+ useEffect(() => {
32
+ refresh();
33
+ }, [refresh]);
34
+
35
+ const addPattern = useCallback(async () => {
36
+ const nextPattern = String(patternInput || "").trim();
37
+ if (!nextPattern || saving) return;
38
+ setSaving(true);
39
+ try {
40
+ await addNodeExecAllowlistPattern(nextPattern);
41
+ setPatternInput("");
42
+ showToast("Allowlist pattern added", "success");
43
+ await refresh();
44
+ } catch (err) {
45
+ showToast(err.message || "Could not add allowlist pattern", "error");
46
+ } finally {
47
+ setSaving(false);
48
+ }
49
+ }, [patternInput, refresh, saving]);
50
+
51
+ const removePattern = useCallback(async (entryId) => {
52
+ const id = String(entryId || "").trim();
53
+ if (!id || removingId) return;
54
+ setRemovingId(id);
55
+ try {
56
+ await removeNodeExecAllowlistPattern(id);
57
+ showToast("Allowlist pattern removed", "success");
58
+ await refresh();
59
+ } catch (err) {
60
+ showToast(err.message || "Could not remove allowlist pattern", "error");
61
+ } finally {
62
+ setRemovingId("");
63
+ }
64
+ }, [refresh, removingId]);
65
+
66
+ return {
67
+ allowlist,
68
+ loading,
69
+ error,
70
+ patternInput,
71
+ saving,
72
+ removingId,
73
+ setPatternInput,
74
+ refresh,
75
+ addPattern,
76
+ removePattern,
77
+ };
78
+ };
@@ -0,0 +1,118 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import htm from "https://esm.sh/htm";
3
+ import { ActionButton } from "../../action-button.js";
4
+ import { useExecConfig } from "./use-exec-config.js";
5
+
6
+ const html = htm.bind(h);
7
+
8
+ export const NodeExecConfigCard = ({
9
+ nodes = [],
10
+ onRestartRequired = () => {},
11
+ }) => {
12
+ const state = useExecConfig({ onRestartRequired });
13
+
14
+ const availableNodeOptions = nodes
15
+ .filter((node) => String(node?.nodeId || "").trim())
16
+ .map((node) => ({
17
+ value: String(node.nodeId).trim(),
18
+ label: String(node?.displayName || node.nodeId).trim(),
19
+ }));
20
+
21
+ return html`
22
+ <div class="bg-surface border border-border rounded-xl p-4 space-y-3">
23
+ <div class="flex items-center justify-between gap-2">
24
+ <div class="space-y-1">
25
+ <h3 class="font-semibold text-sm">Exec Routing</h3>
26
+ <p class="text-xs text-gray-500">
27
+ Set where command execution runs and how strict approval policy should be.
28
+ </p>
29
+ </div>
30
+ <${ActionButton}
31
+ onClick=${state.refresh}
32
+ idleLabel="Reload"
33
+ tone="secondary"
34
+ size="sm"
35
+ disabled=${state.loading}
36
+ />
37
+ </div>
38
+
39
+ ${state.error ? html`<div class="text-xs text-red-400">${state.error}</div>` : null}
40
+
41
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
42
+ <label class="space-y-1">
43
+ <div class="text-xs text-gray-500">Host</div>
44
+ <select
45
+ value=${state.config.host}
46
+ disabled=${state.loading || state.saving}
47
+ oninput=${(event) => state.updateField("host", event.target.value)}
48
+ class="w-full bg-black/30 border border-border rounded-lg px-2.5 py-2 text-xs font-mono focus:border-gray-500 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
49
+ >
50
+ <option value="gateway">gateway</option>
51
+ <option value="node">node</option>
52
+ </select>
53
+ </label>
54
+
55
+ <label class="space-y-1">
56
+ <div class="text-xs text-gray-500">Security</div>
57
+ <select
58
+ value=${state.config.security}
59
+ disabled=${state.loading || state.saving}
60
+ oninput=${(event) => state.updateField("security", event.target.value)}
61
+ class="w-full bg-black/30 border border-border rounded-lg px-2.5 py-2 text-xs font-mono focus:border-gray-500 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
62
+ >
63
+ <option value="deny">deny</option>
64
+ <option value="allowlist">allowlist</option>
65
+ <option value="full">full</option>
66
+ </select>
67
+ </label>
68
+
69
+ <label class="space-y-1">
70
+ <div class="text-xs text-gray-500">Ask</div>
71
+ <select
72
+ value=${state.config.ask}
73
+ disabled=${state.loading || state.saving}
74
+ oninput=${(event) => state.updateField("ask", event.target.value)}
75
+ class="w-full bg-black/30 border border-border rounded-lg px-2.5 py-2 text-xs font-mono focus:border-gray-500 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
76
+ >
77
+ <option value="off">off</option>
78
+ <option value="on-miss">on-miss</option>
79
+ <option value="always">always</option>
80
+ </select>
81
+ </label>
82
+
83
+ <label class="space-y-1">
84
+ <div class="text-xs text-gray-500">Node target</div>
85
+ <select
86
+ value=${state.config.node}
87
+ disabled=${state.loading || state.saving || state.config.host !== "node"}
88
+ oninput=${(event) => state.updateField("node", event.target.value)}
89
+ class="w-full bg-black/30 border border-border rounded-lg px-2.5 py-2 text-xs font-mono focus:border-gray-500 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
90
+ >
91
+ <option value="">${availableNodeOptions.length ? "Select node..." : "No nodes available"}</option>
92
+ ${availableNodeOptions.map(
93
+ (option) => html`
94
+ <option value=${option.value}>${option.label}</option>
95
+ `,
96
+ )}
97
+ </select>
98
+ </label>
99
+ </div>
100
+
101
+ <div class="rounded-lg border border-yellow-500/40 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-300">
102
+ Save applies config immediately, but gateway restart may still be required by OpenClaw.
103
+ </div>
104
+
105
+ <div class="flex justify-end">
106
+ <${ActionButton}
107
+ onClick=${state.save}
108
+ loading=${state.saving}
109
+ idleLabel="Save Exec Config"
110
+ loadingLabel="Saving..."
111
+ tone="primary"
112
+ size="sm"
113
+ disabled=${state.loading || (state.config.host === "node" && !state.config.node)}
114
+ />
115
+ </div>
116
+ </div>
117
+ `;
118
+ };
@@ -0,0 +1,79 @@
1
+ import { useCallback, useEffect, useState } from "https://esm.sh/preact/hooks";
2
+ import { fetchNodeExecConfig, saveNodeExecConfig } from "../../../lib/api.js";
3
+ import { showToast } from "../../toast.js";
4
+
5
+ const kDefaultExecConfig = {
6
+ host: "gateway",
7
+ security: "allowlist",
8
+ ask: "on-miss",
9
+ node: "",
10
+ };
11
+
12
+ export const useExecConfig = ({ onRestartRequired = () => {} } = {}) => {
13
+ const [config, setConfig] = useState(kDefaultExecConfig);
14
+ const [loading, setLoading] = useState(true);
15
+ const [saving, setSaving] = useState(false);
16
+ const [error, setError] = useState("");
17
+
18
+ const refresh = useCallback(async () => {
19
+ setLoading(true);
20
+ setError("");
21
+ try {
22
+ const result = await fetchNodeExecConfig();
23
+ const nextConfig = {
24
+ ...kDefaultExecConfig,
25
+ ...(result?.config || {}),
26
+ };
27
+ setConfig(nextConfig);
28
+ } catch (err) {
29
+ setError(err.message || "Could not load exec settings");
30
+ } finally {
31
+ setLoading(false);
32
+ }
33
+ }, []);
34
+
35
+ useEffect(() => {
36
+ refresh();
37
+ }, [refresh]);
38
+
39
+ const updateField = useCallback((field, value) => {
40
+ setConfig((prev) => {
41
+ const next = { ...prev, [field]: value };
42
+ if (field === "host" && value !== "node") {
43
+ next.node = "";
44
+ }
45
+ return next;
46
+ });
47
+ }, []);
48
+
49
+ const save = useCallback(async () => {
50
+ if (saving) return false;
51
+ setSaving(true);
52
+ setError("");
53
+ try {
54
+ const result = await saveNodeExecConfig(config);
55
+ if (result?.restartRequired) {
56
+ onRestartRequired(true);
57
+ }
58
+ showToast("Node exec config saved", "success");
59
+ return true;
60
+ } catch (err) {
61
+ const message = err.message || "Could not save exec settings";
62
+ setError(message);
63
+ showToast(message, "error");
64
+ return false;
65
+ } finally {
66
+ setSaving(false);
67
+ }
68
+ }, [config, onRestartRequired, saving]);
69
+
70
+ return {
71
+ config,
72
+ loading,
73
+ saving,
74
+ error,
75
+ refresh,
76
+ updateField,
77
+ save,
78
+ };
79
+ };
@@ -0,0 +1,46 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import htm from "https://esm.sh/htm";
3
+ import { PageHeader } from "../page-header.js";
4
+ import { ActionButton } from "../action-button.js";
5
+ import { useNodesTab } from "./use-nodes-tab.js";
6
+ import { ConnectedNodesCard } from "./connected-nodes/index.js";
7
+ import { NodesSetupWizard } from "./setup-wizard/index.js";
8
+
9
+ const html = htm.bind(h);
10
+
11
+ export const NodesTab = ({ onRestartRequired = () => {} }) => {
12
+ const { state, actions } = useNodesTab();
13
+
14
+ return html`
15
+ <div class="space-y-4">
16
+ <${PageHeader}
17
+ title="Nodes"
18
+ actions=${html`
19
+ <${ActionButton}
20
+ onClick=${actions.openWizard}
21
+ idleLabel="Connect Node"
22
+ tone="primary"
23
+ size="sm"
24
+ />
25
+ `}
26
+ />
27
+
28
+ <${ConnectedNodesCard}
29
+ nodes=${state.nodes}
30
+ pending=${state.pending}
31
+ loading=${state.loadingNodes}
32
+ error=${state.nodesError}
33
+ onRefresh=${actions.refreshNodes}
34
+ />
35
+
36
+ <${NodesSetupWizard}
37
+ visible=${state.wizardVisible}
38
+ nodes=${state.nodes}
39
+ pending=${state.pending}
40
+ refreshNodes=${actions.refreshNodes}
41
+ onRestartRequired=${onRestartRequired}
42
+ onClose=${actions.closeWizard}
43
+ />
44
+ </div>
45
+ `;
46
+ };
@@ -0,0 +1,243 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import htm from "https://esm.sh/htm";
3
+ import { ModalShell } from "../../modal-shell.js";
4
+ import { ActionButton } from "../../action-button.js";
5
+ import { CloseIcon } from "../../icons.js";
6
+ import { copyTextToClipboard } from "../../../lib/clipboard.js";
7
+ import { showToast } from "../../toast.js";
8
+ import { useSetupWizard } from "./use-setup-wizard.js";
9
+
10
+ const html = htm.bind(h);
11
+
12
+ const kWizardSteps = [
13
+ "Install OpenClaw CLI",
14
+ "Connect Node",
15
+ "Approve Node",
16
+ "Set Gateway Routing",
17
+ ];
18
+
19
+ const renderCommandBlock = ({ command = "", onCopy = () => {} }) => html`
20
+ <div class="rounded-lg border border-border bg-black/30 p-3">
21
+ <pre class="pt-1 pl-2 text-[11px] leading-5 whitespace-pre-wrap break-all font-mono text-gray-300">${command}</pre>
22
+ <div class="pt-3">
23
+ <button
24
+ type="button"
25
+ onclick=${onCopy}
26
+ class="text-xs px-2 py-1 rounded-lg ac-btn-ghost"
27
+ >
28
+ Copy
29
+ </button>
30
+ </div>
31
+ </div>
32
+ `;
33
+
34
+ const copyAndToast = async (value, label = "text") => {
35
+ const copied = await copyTextToClipboard(value);
36
+ if (copied) {
37
+ showToast("Copied to clipboard", "success");
38
+ return;
39
+ }
40
+ showToast(`Could not copy ${label}`, "error");
41
+ };
42
+
43
+ export const NodesSetupWizard = ({
44
+ visible = false,
45
+ nodes = [],
46
+ pending = [],
47
+ refreshNodes = async () => {},
48
+ onRestartRequired = () => {},
49
+ onClose = () => {},
50
+ }) => {
51
+ const state = useSetupWizard({
52
+ visible,
53
+ nodes,
54
+ pending,
55
+ refreshNodes,
56
+ onRestartRequired,
57
+ onClose,
58
+ });
59
+ const isFinalStep = state.step === kWizardSteps.length - 1;
60
+
61
+ return html`
62
+ <${ModalShell}
63
+ visible=${visible}
64
+ onClose=${onClose}
65
+ closeOnOverlayClick=${false}
66
+ closeOnEscape=${false}
67
+ panelClassName="relative bg-modal border border-border rounded-xl p-6 max-w-2xl w-full space-y-4"
68
+ >
69
+ <button
70
+ type="button"
71
+ onclick=${onClose}
72
+ class="absolute top-6 right-6 h-8 w-8 inline-flex items-center justify-center rounded-lg ac-btn-secondary"
73
+ aria-label="Close modal"
74
+ >
75
+ <${CloseIcon} className="w-3.5 h-3.5 text-gray-300" />
76
+ </button>
77
+
78
+ <div class="text-xs text-gray-500">Node Setup Wizard</div>
79
+ <div class="flex items-center gap-1">
80
+ ${kWizardSteps.map(
81
+ (_label, idx) => html`
82
+ <div
83
+ class=${`h-1 flex-1 rounded-full transition-colors ${idx <= state.step ? "bg-accent" : "bg-border"}`}
84
+ style=${idx <= state.step ? "background: var(--accent)" : ""}
85
+ ></div>
86
+ `,
87
+ )}
88
+ </div>
89
+ <h3 class="font-semibold text-base">
90
+ Step ${state.step + 1} of ${kWizardSteps.length}: ${kWizardSteps[state.step]}
91
+ </h3>
92
+
93
+ ${state.step === 0
94
+ ? html`
95
+ <div class="text-xs text-gray-500">
96
+ Install OpenClaw on the machine you want to connect as a node.
97
+ </div>
98
+ ${renderCommandBlock({
99
+ command: "npm install -g openclaw",
100
+ onCopy: () => copyAndToast("npm install -g openclaw", "command"),
101
+ })}
102
+ <div class="text-xs text-gray-500">Requires Node.js 22+.</div>
103
+ `
104
+ : null}
105
+
106
+ ${state.step === 1
107
+ ? html`
108
+ <div class="space-y-2">
109
+ <div class="text-xs text-gray-500">
110
+ Run this on the device you want to connect.
111
+ </div>
112
+ <label class="space-y-1 block">
113
+ <div class="text-xs text-gray-500">Display name</div>
114
+ <input
115
+ type="text"
116
+ value=${state.displayName}
117
+ oninput=${(event) => state.setDisplayName(event.target.value)}
118
+ class="w-full bg-black/30 border border-border rounded-lg px-2.5 py-2 text-xs font-mono focus:border-gray-500 focus:outline-none"
119
+ />
120
+ </label>
121
+ ${state.loadingConnectInfo
122
+ ? html`<div class="text-xs text-gray-500">Loading command...</div>`
123
+ : renderCommandBlock({
124
+ command: state.connectCommand || "Could not build connect command.",
125
+ onCopy: () =>
126
+ copyAndToast(state.connectCommand || "", "command"),
127
+ })}
128
+ </div>
129
+ `
130
+ : null}
131
+
132
+ ${state.step === 2
133
+ ? html`
134
+ <div class="space-y-2">
135
+ <div class="text-xs text-gray-500">
136
+ Select the node to approve after you run the connect command.
137
+ </div>
138
+ <div class="flex items-center gap-2">
139
+ <select
140
+ value=${state.selectedNodeId}
141
+ oninput=${(event) => state.setSelectedNodeId(event.target.value)}
142
+ 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"
143
+ >
144
+ <option value="">
145
+ ${state.selectableNodes.length
146
+ ? "Select node..."
147
+ : "No nodes found yet"}
148
+ </option>
149
+ ${state.selectableNodes.map(
150
+ (entry) => html`
151
+ <option value=${entry.nodeId}>
152
+ ${entry.displayName} (${entry.nodeId.slice(0, 12)}...)
153
+ </option>
154
+ `,
155
+ )}
156
+ </select>
157
+ <${ActionButton}
158
+ onClick=${state.refreshNodeList}
159
+ idleLabel="Refresh"
160
+ tone="secondary"
161
+ size="sm"
162
+ />
163
+ </div>
164
+ <${ActionButton}
165
+ onClick=${state.approveSelectedNode}
166
+ loading=${state.approvingNodeId === state.selectedNodeId}
167
+ idleLabel="Approve Selected Node"
168
+ loadingLabel="Approving..."
169
+ tone="primary"
170
+ size="sm"
171
+ disabled=${!state.selectedNodeId}
172
+ />
173
+ </div>
174
+ `
175
+ : null}
176
+
177
+ ${state.step === 3
178
+ ? html`
179
+ <div class="space-y-2">
180
+ <div class="text-xs text-gray-500">
181
+ This step only updates gateway routing
182
+ (<code>tools.exec.host=node</code>, target node, and gateway
183
+ ask/security defaults).
184
+ </div>
185
+ <div class="rounded-lg border border-yellow-500/40 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-200">
186
+ Node-host permissions are local to the connected machine and are
187
+ not managed from this wizard yet.
188
+ </div>
189
+ <${ActionButton}
190
+ onClick=${async () => {
191
+ const ok = await state.applyGatewayNodeRouting();
192
+ if (ok) {
193
+ await refreshNodes();
194
+ state.completeWizard();
195
+ }
196
+ }}
197
+ loading=${state.configuring}
198
+ idleLabel="Apply Gateway Routing + Finish"
199
+ loadingLabel="Applying..."
200
+ tone="primary"
201
+ size="sm"
202
+ disabled=${!state.selectedNodeId}
203
+ />
204
+ </div>
205
+ `
206
+ : null}
207
+
208
+ <div class="grid grid-cols-2 gap-2 pt-2">
209
+ ${state.step === 0
210
+ ? html`<div></div>`
211
+ : html`
212
+ <${ActionButton}
213
+ onClick=${() => state.setStep(Math.max(0, state.step - 1))}
214
+ idleLabel="Back"
215
+ tone="secondary"
216
+ size="md"
217
+ className="w-full justify-center"
218
+ />
219
+ `}
220
+ ${isFinalStep
221
+ ? html`
222
+ <${ActionButton}
223
+ onClick=${onClose}
224
+ idleLabel="Close"
225
+ tone="secondary"
226
+ size="md"
227
+ className="w-full justify-center"
228
+ />
229
+ `
230
+ : html`
231
+ <${ActionButton}
232
+ onClick=${() => state.setStep(Math.min(kWizardSteps.length - 1, state.step + 1))}
233
+ idleLabel="Next"
234
+ tone="primary"
235
+ size="md"
236
+ className="w-full justify-center"
237
+ disabled=${state.step === 2 && !state.selectedNodeId}
238
+ />
239
+ `}
240
+ </div>
241
+ </${ModalShell}>
242
+ `;
243
+ };