@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.
- package/lib/public/css/theme.css +12 -1
- package/lib/public/js/app.js +10 -2
- package/lib/public/js/components/cron-tab/cron-job-detail.js +18 -2
- package/lib/public/js/components/cron-tab/cron-job-list.js +43 -0
- package/lib/public/js/components/cron-tab/cron-job-trends-panel.js +319 -0
- package/lib/public/js/components/cron-tab/cron-job-usage.js +22 -8
- package/lib/public/js/components/cron-tab/cron-overview.js +17 -13
- package/lib/public/js/components/cron-tab/cron-prompt-editor.js +1 -1
- package/lib/public/js/components/cron-tab/cron-run-history-panel.js +66 -30
- package/lib/public/js/components/cron-tab/cron-runs-trend-card.js +109 -53
- package/lib/public/js/components/cron-tab/index.js +6 -0
- package/lib/public/js/components/cron-tab/use-cron-tab.js +51 -0
- package/lib/public/js/components/nodes-tab/connected-nodes/index.js +85 -0
- package/lib/public/js/components/nodes-tab/connected-nodes/user-connected-nodes.js +25 -0
- package/lib/public/js/components/nodes-tab/exec-allowlist/index.js +89 -0
- package/lib/public/js/components/nodes-tab/exec-allowlist/use-exec-allowlist.js +78 -0
- package/lib/public/js/components/nodes-tab/exec-config/index.js +118 -0
- package/lib/public/js/components/nodes-tab/exec-config/use-exec-config.js +79 -0
- package/lib/public/js/components/nodes-tab/index.js +46 -0
- package/lib/public/js/components/nodes-tab/setup-wizard/index.js +243 -0
- package/lib/public/js/components/nodes-tab/setup-wizard/use-setup-wizard.js +159 -0
- package/lib/public/js/components/nodes-tab/use-nodes-tab.js +22 -0
- package/lib/public/js/components/routes/index.js +1 -0
- package/lib/public/js/components/routes/nodes-route.js +11 -0
- package/lib/public/js/components/usage-tab/constants.js +8 -0
- package/lib/public/js/components/usage-tab/formatters.js +13 -0
- package/lib/public/js/components/usage-tab/index.js +2 -0
- package/lib/public/js/components/usage-tab/overview-section.js +22 -4
- package/lib/public/js/components/usage-tab/use-usage-tab.js +61 -16
- package/lib/public/js/lib/api.js +61 -0
- package/lib/public/js/lib/app-navigation.js +2 -0
- package/lib/public/js/lib/format.js +50 -0
- package/lib/server/constants.js +1 -0
- package/lib/server/cron-service.js +230 -1
- package/lib/server/db/usage/summary.js +101 -1
- package/lib/server/init/register-server-routes.js +8 -0
- package/lib/server/routes/cron.js +11 -0
- package/lib/server/routes/nodes.js +286 -0
- 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
|
+
};
|