@agenticmail/enterprise 0.5.323 → 0.5.325

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 (43) hide show
  1. package/dist/agent-heartbeat-BBINFNL4.js +510 -0
  2. package/dist/agent-heartbeat-UF2RKKS2.js +510 -0
  3. package/dist/chunk-4DBWU3P5.js +4929 -0
  4. package/dist/chunk-CQYLRIQ3.js +25938 -0
  5. package/dist/chunk-GYB2WHMN.js +5101 -0
  6. package/dist/chunk-KN3T3CTD.js +4929 -0
  7. package/dist/chunk-SVSLIQYN.js +1519 -0
  8. package/dist/chunk-VBTHTPZ6.js +26055 -0
  9. package/dist/chunk-WD72IOF2.js +5101 -0
  10. package/dist/chunk-ZGFDTW4H.js +1519 -0
  11. package/dist/cli-agent-USMKX7WN.js +2473 -0
  12. package/dist/cli-agent-ZIIFI77N.js +2473 -0
  13. package/dist/cli-serve-7JQ4FVUQ.js +260 -0
  14. package/dist/cli-serve-MLR4KAE2.js +260 -0
  15. package/dist/cli.js +3 -3
  16. package/dist/dashboard/app.js +4 -1
  17. package/dist/dashboard/components/icons.js +1 -0
  18. package/dist/dashboard/docs/cluster.html +285 -0
  19. package/dist/dashboard/pages/agent-detail/index.js +25 -3
  20. package/dist/dashboard/pages/agents.js +30 -1
  21. package/dist/dashboard/pages/cluster.js +512 -0
  22. package/dist/index.js +4 -4
  23. package/dist/routes-IGR6PZCA.js +92 -0
  24. package/dist/routes-XYR2RNEC.js +92 -0
  25. package/dist/runtime-EAWOE6JZ.js +45 -0
  26. package/dist/runtime-ZOC337DD.js +45 -0
  27. package/dist/server-7NT4LMSQ.js +28 -0
  28. package/dist/server-B3VJ6MSA.js +28 -0
  29. package/dist/setup-5YRQUOW2.js +20 -0
  30. package/dist/setup-6NUSB4XO.js +20 -0
  31. package/logs/cloudflared-error.log +8 -0
  32. package/logs/enterprise-out.log +3 -0
  33. package/package.json +1 -1
  34. package/src/cli-agent.ts +33 -1
  35. package/src/dashboard/app.js +4 -1
  36. package/src/dashboard/components/icons.js +1 -0
  37. package/src/dashboard/docs/cluster.html +285 -0
  38. package/src/dashboard/pages/agent-detail/index.js +25 -3
  39. package/src/dashboard/pages/agents.js +30 -1
  40. package/src/dashboard/pages/cluster.js +512 -0
  41. package/src/engine/cluster.ts +278 -0
  42. package/src/engine/routes.ts +163 -1
  43. package/src/engine/NOTE.MD +0 -52
@@ -0,0 +1,512 @@
1
+ import { h, useState, useEffect, useRef, Fragment, useApp, engineCall, apiCall } from '../components/utils.js';
2
+ import { I } from '../components/icons.js';
3
+ import { HelpButton } from '../components/settings-help.js';
4
+ import { KnowledgeLink } from '../components/knowledge-link.js';
5
+
6
+ function AddNodeModal({ onClose, onAdded, toast }) {
7
+ var [tab, setTab] = useState('manual'); // manual | ssh | script
8
+ var [form, setForm] = useState({ nodeId: '', name: '', host: '', port: 3101, sshHost: '', sshUser: 'root', sshKey: '', agentIds: '' });
9
+ var [testing, setTesting] = useState(false);
10
+ var [testResult, setTestResult] = useState(null);
11
+ var [saving, setSaving] = useState(false);
12
+ var [scriptGenerated, setScriptGenerated] = useState('');
13
+
14
+ var set = function(k, v) { setForm(function(f) { var n = Object.assign({}, f); n[k] = v; return n; }); };
15
+
16
+ var testConnection = function() {
17
+ if (!form.host) { toast('Host is required', 'error'); return; }
18
+ setTesting(true); setTestResult(null);
19
+ engineCall('/cluster/test-connection', {
20
+ method: 'POST',
21
+ body: JSON.stringify({ host: form.host, port: form.port || 3101 }),
22
+ }).then(function(d) { setTestResult(d); setTesting(false); })
23
+ .catch(function(e) { setTestResult({ success: false, error: e.message }); setTesting(false); });
24
+ };
25
+
26
+ var saveManual = function() {
27
+ if (!form.host) { toast('Host IP/hostname is required', 'error'); return; }
28
+ var nodeId = form.nodeId || form.host.replace(/[^a-zA-Z0-9-]/g, '-');
29
+ setSaving(true);
30
+ engineCall('/cluster/register', {
31
+ method: 'POST',
32
+ body: JSON.stringify({
33
+ nodeId: nodeId,
34
+ name: form.name || nodeId,
35
+ host: form.host,
36
+ port: parseInt(form.port) || 3101,
37
+ }),
38
+ }).then(function(d) { toast('Node added successfully', 'success'); onAdded(); onClose(); })
39
+ .catch(function(e) { toast(e.message, 'error'); setSaving(false); });
40
+ };
41
+
42
+ var deploySsh = function() {
43
+ if (!form.sshHost) { toast('SSH host is required', 'error'); return; }
44
+ setSaving(true);
45
+ engineCall('/cluster/deploy-via-ssh', {
46
+ method: 'POST',
47
+ body: JSON.stringify({
48
+ host: form.sshHost,
49
+ user: form.sshUser || 'root',
50
+ privateKey: form.sshKey || undefined,
51
+ agentIds: form.agentIds ? form.agentIds.split(',').map(function(s) { return s.trim(); }) : [],
52
+ port: parseInt(form.port) || 3101,
53
+ name: form.name || form.sshHost,
54
+ }),
55
+ }).then(function(d) { toast('Deployment started: ' + (d.message || 'check progress'), 'success'); onAdded(); onClose(); })
56
+ .catch(function(e) { toast(e.message, 'error'); setSaving(false); });
57
+ };
58
+
59
+ var generateScript = function() {
60
+ apiCall('/settings').then(function(settings) {
61
+ var dashUrl = settings?.domainStatus?.url || (typeof location !== 'undefined' ? location.origin : 'https://your-dashboard.agenticmail.io');
62
+ var dbUrl = '<YOUR_DATABASE_URL>';
63
+ var nodeId = form.nodeId || form.name?.toLowerCase().replace(/[^a-z0-9-]/g, '-') || 'worker-1';
64
+ var script = '#!/bin/bash\n' +
65
+ '# AgenticMail Worker Node Setup Script\n' +
66
+ '# Generated for: ' + (form.name || nodeId) + '\n' +
67
+ '# Run this on the target machine\n\n' +
68
+ 'set -e\n\n' +
69
+ '# 1. Install Node.js (if not installed)\n' +
70
+ 'if ! command -v node &> /dev/null; then\n' +
71
+ ' echo "Installing Node.js..."\n' +
72
+ ' curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -\n' +
73
+ ' sudo apt-get install -y nodejs || brew install node\n' +
74
+ 'fi\n\n' +
75
+ '# 2. Install AgenticMail Enterprise\n' +
76
+ 'npm install -g @agenticmail/enterprise\n\n' +
77
+ '# 3. Create environment file\n' +
78
+ 'mkdir -p ~/.agenticmail\n' +
79
+ 'cat > ~/.agenticmail/worker.env << \'ENV\'\n' +
80
+ 'ENTERPRISE_URL=' + dashUrl + '\n' +
81
+ 'WORKER_NODE_ID=' + nodeId + '\n' +
82
+ 'WORKER_NAME="' + (form.name || nodeId) + '"\n' +
83
+ 'DATABASE_URL=' + dbUrl + '\n' +
84
+ 'PORT=' + (form.port || 3101) + '\n' +
85
+ 'LOG_LEVEL=warn\n' +
86
+ 'ENV\n\n' +
87
+ '# 4. Install PM2 for process management\n' +
88
+ 'npm install -g pm2\n\n' +
89
+ '# 5. Start agent (replace <AGENT_ID> with actual ID)\n' +
90
+ '# pm2 start "agenticmail-enterprise agent --id <AGENT_ID>" --name agent-1 --env-path ~/.agenticmail/worker.env\n\n' +
91
+ 'echo ""\n' +
92
+ 'echo "Worker node setup complete!"\n' +
93
+ 'echo "Edit ~/.agenticmail/worker.env to set DATABASE_URL"\n' +
94
+ 'echo "Then start agents with: pm2 start \\"agenticmail-enterprise agent --id <AGENT_ID>\\""\n';
95
+ setScriptGenerated(script);
96
+ }).catch(function() {
97
+ toast('Failed to generate script', 'error');
98
+ });
99
+ };
100
+
101
+ var S = { padding: '8px 12px', borderRadius: 6, border: '1px solid var(--border)', background: 'var(--bg-primary)', color: 'var(--text-primary)', width: '100%', fontSize: 13 };
102
+
103
+ return h('div', { className: 'modal-overlay', onClick: function(e) { if (e.target.className === 'modal-overlay') onClose(); } },
104
+ h('div', { className: 'modal', style: { maxWidth: 640 } },
105
+ h('div', { className: 'modal-header' },
106
+ h('h3', null, 'Add Worker Node'),
107
+ h('button', { className: 'btn btn-ghost btn-sm', onClick: onClose }, 'X')
108
+ ),
109
+ h('div', { className: 'modal-body' },
110
+ // Tabs
111
+ h('div', { style: { display: 'flex', gap: 0, marginBottom: 20, borderBottom: '1px solid var(--border)' } },
112
+ ['manual', 'ssh', 'script'].map(function(t) {
113
+ var labels = { manual: 'Manual Registration', ssh: 'Deploy via SSH', script: 'Setup Script' };
114
+ return h('button', {
115
+ key: t,
116
+ style: { padding: '8px 16px', border: 'none', background: 'none', cursor: 'pointer', fontSize: 13, fontWeight: tab === t ? 600 : 400,
117
+ color: tab === t ? 'var(--accent-text)' : 'var(--text-muted)', borderBottom: tab === t ? '2px solid var(--accent)' : '2px solid transparent' },
118
+ onClick: function() { setTab(t); }
119
+ }, labels[t]);
120
+ })
121
+ ),
122
+
123
+ tab === 'manual' && h(Fragment, null,
124
+ h('p', { style: { fontSize: 13, color: 'var(--text-muted)', marginBottom: 16 } },
125
+ 'Register an existing machine that\'s already running AgenticMail. The node will appear in the cluster and start reporting status.'
126
+ ),
127
+ h('div', { style: { display: 'grid', gap: 12 } },
128
+ h('div', null,
129
+ h('label', { style: { fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 } }, 'Node Name'),
130
+ h('input', { style: S, value: form.name, placeholder: 'e.g., Office Mac Mini', onChange: function(e) { set('name', e.target.value); } })
131
+ ),
132
+ h('div', { style: { display: 'grid', gridTemplateColumns: '1fr 120px', gap: 8 } },
133
+ h('div', null,
134
+ h('label', { style: { fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 } }, 'Host IP / Hostname *'),
135
+ h('input', { style: S, value: form.host, placeholder: '192.168.1.50 or worker.example.com', onChange: function(e) { set('host', e.target.value); } })
136
+ ),
137
+ h('div', null,
138
+ h('label', { style: { fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 } }, 'Port'),
139
+ h('input', { style: S, type: 'number', value: form.port, onChange: function(e) { set('port', e.target.value); } })
140
+ )
141
+ ),
142
+ h('div', null,
143
+ h('label', { style: { fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 } }, 'Node ID'),
144
+ h('input', { style: S, value: form.nodeId, placeholder: 'Auto-generated from hostname', onChange: function(e) { set('nodeId', e.target.value); } })
145
+ ),
146
+ // Test connection button
147
+ h('div', { style: { display: 'flex', gap: 8, alignItems: 'center' } },
148
+ h('button', { className: 'btn btn-secondary btn-sm', onClick: testConnection, disabled: testing || !form.host }, testing ? 'Testing...' : 'Test Connection'),
149
+ testResult && h('span', { style: { fontSize: 12, color: testResult.success ? 'var(--accent-green)' : 'var(--accent-red)' } },
150
+ testResult.success ? 'Connected! ' + (testResult.version || '') : 'Failed: ' + (testResult.error || 'unreachable')
151
+ )
152
+ ),
153
+ ),
154
+ h('div', { style: { display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 20 } },
155
+ h('button', { className: 'btn btn-secondary', onClick: onClose }, 'Cancel'),
156
+ h('button', { className: 'btn btn-primary', onClick: saveManual, disabled: saving }, saving ? 'Adding...' : 'Add Node')
157
+ )
158
+ ),
159
+
160
+ tab === 'ssh' && h(Fragment, null,
161
+ h('p', { style: { fontSize: 13, color: 'var(--text-muted)', marginBottom: 16 } },
162
+ 'Automatically install AgenticMail and configure a worker node on a remote machine via SSH. The dashboard will SSH in, install dependencies, and start the agent process.'
163
+ ),
164
+ h('div', { style: { display: 'grid', gap: 12 } },
165
+ h('div', null,
166
+ h('label', { style: { fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 } }, 'Node Name'),
167
+ h('input', { style: S, value: form.name, placeholder: 'e.g., AWS Instance 1', onChange: function(e) { set('name', e.target.value); } })
168
+ ),
169
+ h('div', { style: { display: 'grid', gridTemplateColumns: '1fr 120px', gap: 8 } },
170
+ h('div', null,
171
+ h('label', { style: { fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 } }, 'SSH Host *'),
172
+ h('input', { style: S, value: form.sshHost, placeholder: '1.2.3.4 or server.example.com', onChange: function(e) { set('sshHost', e.target.value); } })
173
+ ),
174
+ h('div', null,
175
+ h('label', { style: { fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 } }, 'SSH User'),
176
+ h('input', { style: S, value: form.sshUser, onChange: function(e) { set('sshUser', e.target.value); } })
177
+ )
178
+ ),
179
+ h('div', null,
180
+ h('label', { style: { fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 } }, 'SSH Private Key (paste or leave blank for default ~/.ssh/id_rsa)'),
181
+ h('textarea', { style: Object.assign({}, S, { height: 80, fontFamily: 'monospace', fontSize: 11 }), value: form.sshKey, placeholder: '-----BEGIN OPENSSH PRIVATE KEY-----\n...', onChange: function(e) { set('sshKey', e.target.value); } })
182
+ ),
183
+ h('div', null,
184
+ h('label', { style: { fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 } }, 'Agent IDs to deploy (comma-separated, or leave blank)'),
185
+ h('input', { style: S, value: form.agentIds, placeholder: 'agent-uuid-1, agent-uuid-2', onChange: function(e) { set('agentIds', e.target.value); } })
186
+ ),
187
+ ),
188
+ h('div', { style: { display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 20 } },
189
+ h('button', { className: 'btn btn-secondary', onClick: onClose }, 'Cancel'),
190
+ h('button', { className: 'btn btn-primary', onClick: deploySsh, disabled: saving || !form.sshHost }, saving ? 'Deploying...' : 'Deploy Worker')
191
+ )
192
+ ),
193
+
194
+ tab === 'script' && h(Fragment, null,
195
+ h('p', { style: { fontSize: 13, color: 'var(--text-muted)', marginBottom: 16 } },
196
+ 'Generate a setup script to run on the target machine. Copy the script, SSH into the machine, and paste it.'
197
+ ),
198
+ h('div', { style: { display: 'grid', gap: 12 } },
199
+ h('div', { style: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 } },
200
+ h('div', null,
201
+ h('label', { style: { fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 } }, 'Node Name'),
202
+ h('input', { style: S, value: form.name, placeholder: 'my-worker', onChange: function(e) { set('name', e.target.value); } })
203
+ ),
204
+ h('div', null,
205
+ h('label', { style: { fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 } }, 'Port'),
206
+ h('input', { style: S, type: 'number', value: form.port, onChange: function(e) { set('port', e.target.value); } })
207
+ )
208
+ ),
209
+ h('button', { className: 'btn btn-primary btn-sm', onClick: generateScript }, 'Generate Setup Script'),
210
+ scriptGenerated && h(Fragment, null,
211
+ h('div', { style: { position: 'relative' } },
212
+ h('pre', { style: { background: 'var(--bg-primary)', border: '1px solid var(--border)', borderRadius: 8, padding: 12, fontSize: 11, maxHeight: 300, overflow: 'auto', whiteSpace: 'pre-wrap' } }, scriptGenerated),
213
+ h('button', { className: 'btn btn-secondary btn-sm', style: { position: 'absolute', top: 8, right: 8 },
214
+ onClick: function() { navigator.clipboard.writeText(scriptGenerated); toast('Script copied to clipboard', 'success'); }
215
+ }, 'Copy')
216
+ )
217
+ )
218
+ ),
219
+ h('div', { style: { display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 20 } },
220
+ h('button', { className: 'btn btn-secondary', onClick: onClose }, 'Close')
221
+ )
222
+ )
223
+ )
224
+ )
225
+ );
226
+ }
227
+
228
+ function NodeDetailModal({ node, onClose, onRefresh, toast }) {
229
+ var [pinging, setPinging] = useState(false);
230
+ var [pingResult, setPingResult] = useState(null);
231
+ var [agents, setAgents] = useState([]);
232
+
233
+ useEffect(function() {
234
+ // Fetch agent details for this node
235
+ if (node.agents && node.agents.length > 0) {
236
+ engineCall('/agents').then(function(d) {
237
+ var all = d.agents || d || [];
238
+ setAgents(all.filter(function(a) { return node.agents.indexOf(a.id) >= 0; }));
239
+ }).catch(function() {});
240
+ }
241
+ }, [node.nodeId]);
242
+
243
+ var pingNode = function() {
244
+ setPinging(true); setPingResult(null);
245
+ engineCall('/cluster/test-connection', {
246
+ method: 'POST',
247
+ body: JSON.stringify({ host: node.host, port: node.port }),
248
+ }).then(function(d) { setPingResult(d); setPinging(false); })
249
+ .catch(function(e) { setPingResult({ success: false, error: e.message }); setPinging(false); });
250
+ };
251
+
252
+ var restartNode = function() {
253
+ if (!confirm('Restart all agents on ' + node.name + '?')) return;
254
+ engineCall('/cluster/nodes/' + node.nodeId + '/restart', { method: 'POST' })
255
+ .then(function() { toast('Restart signal sent', 'success'); })
256
+ .catch(function(e) { toast(e.message, 'error'); });
257
+ };
258
+
259
+ var statusColor = { online: 'var(--accent-green)', degraded: 'var(--accent-orange)', offline: 'var(--text-muted)' }[node.status] || 'var(--text-muted)';
260
+ var uptime = node.onlineSince ? Math.floor((Date.now() - new Date(node.registeredAt).getTime()) / 86400000) + ' days' : '-';
261
+
262
+ return h('div', { className: 'modal-overlay', onClick: function(e) { if (e.target.className === 'modal-overlay') onClose(); } },
263
+ h('div', { className: 'modal', style: { maxWidth: 600 } },
264
+ h('div', { className: 'modal-header' },
265
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: 8 } },
266
+ h('span', { style: { width: 10, height: 10, borderRadius: '50%', background: statusColor, display: 'inline-block' } }),
267
+ h('h3', { style: { margin: 0 } }, node.name || node.nodeId)
268
+ ),
269
+ h('button', { className: 'btn btn-ghost btn-sm', onClick: onClose }, 'X')
270
+ ),
271
+ h('div', { className: 'modal-body' },
272
+ // Info grid
273
+ h('div', { style: { display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 16, marginBottom: 20 } },
274
+ h('div', null, h('div', { style: { fontSize: 11, color: 'var(--text-muted)' } }, 'Platform'), h('div', { style: { fontWeight: 600 } }, node.platform + '/' + node.arch)),
275
+ h('div', null, h('div', { style: { fontSize: 11, color: 'var(--text-muted)' } }, 'CPUs'), h('div', { style: { fontWeight: 600 } }, node.cpuCount)),
276
+ h('div', null, h('div', { style: { fontSize: 11, color: 'var(--text-muted)' } }, 'Memory'), h('div', { style: { fontWeight: 600 } }, node.memoryMb >= 1024 ? (node.memoryMb / 1024).toFixed(1) + ' GB' : node.memoryMb + ' MB')),
277
+ h('div', null, h('div', { style: { fontSize: 11, color: 'var(--text-muted)' } }, 'Address'), h('div', { style: { fontWeight: 600, fontFamily: 'monospace', fontSize: 12 } }, node.host + ':' + node.port)),
278
+ h('div', null, h('div', { style: { fontSize: 11, color: 'var(--text-muted)' } }, 'Version'), h('div', { style: { fontWeight: 600 } }, 'v' + (node.version || '?'))),
279
+ h('div', null, h('div', { style: { fontSize: 11, color: 'var(--text-muted)' } }, 'Registered'), h('div', { style: { fontWeight: 600 } }, node.registeredAt ? new Date(node.registeredAt).toLocaleDateString() : '-')),
280
+ ),
281
+
282
+ // Capabilities
283
+ node.capabilities && node.capabilities.length > 0 && h('div', { style: { marginBottom: 16 } },
284
+ h('div', { style: { fontSize: 12, fontWeight: 600, marginBottom: 6 } }, 'Capabilities'),
285
+ h('div', { style: { display: 'flex', gap: 4, flexWrap: 'wrap' } },
286
+ node.capabilities.map(function(c) { return h('span', { key: c, className: 'badge badge-neutral' }, c); })
287
+ )
288
+ ),
289
+
290
+ // Agents on this node
291
+ h('div', { style: { marginBottom: 16 } },
292
+ h('div', { style: { fontSize: 12, fontWeight: 600, marginBottom: 6 } }, 'Agents (' + (node.agents ? node.agents.length : 0) + ')'),
293
+ node.agents && node.agents.length > 0
294
+ ? h('div', { style: { display: 'grid', gap: 6 } },
295
+ agents.length > 0
296
+ ? agents.map(function(a) {
297
+ return h('div', { key: a.id, style: { display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px', background: 'var(--bg-secondary)', borderRadius: 6 } },
298
+ h('span', { style: { fontWeight: 600, fontSize: 13 } }, a.name || a.id),
299
+ h('span', { style: { fontSize: 11, color: 'var(--text-muted)', fontFamily: 'monospace' } }, a.email || ''),
300
+ h('span', { className: 'badge badge-neutral', style: { marginLeft: 'auto' } }, a.role || 'agent')
301
+ );
302
+ })
303
+ : node.agents.map(function(id) {
304
+ return h('div', { key: id, style: { padding: '6px 10px', background: 'var(--bg-secondary)', borderRadius: 6, fontFamily: 'monospace', fontSize: 12 } }, id);
305
+ })
306
+ )
307
+ : h('div', { style: { fontSize: 13, color: 'var(--text-muted)', fontStyle: 'italic' } }, 'No agents running on this node')
308
+ ),
309
+
310
+ // Actions
311
+ h('div', { style: { display: 'flex', gap: 8, paddingTop: 12, borderTop: '1px solid var(--border)' } },
312
+ h('button', { className: 'btn btn-secondary btn-sm', onClick: pingNode, disabled: pinging }, pinging ? 'Pinging...' : 'Ping Node'),
313
+ pingResult && h('span', { style: { fontSize: 12, color: pingResult.success ? 'var(--accent-green)' : 'var(--accent-red)', alignSelf: 'center' } },
314
+ pingResult.success ? 'Reachable (' + (pingResult.latencyMs || '?') + 'ms)' : 'Unreachable: ' + (pingResult.error || '')
315
+ ),
316
+ h('div', { style: { flex: 1 } }),
317
+ h('button', { className: 'btn btn-secondary btn-sm', onClick: restartNode }, 'Restart Agents'),
318
+ )
319
+ )
320
+ )
321
+ );
322
+ }
323
+
324
+ export function ClusterPage() {
325
+ var app = useApp();
326
+ var toast = app.toast;
327
+ var [nodes, setNodes] = useState([]);
328
+ var [stats, setStats] = useState(null);
329
+ var [loading, setLoading] = useState(true);
330
+ var [addingNode, setAddingNode] = useState(false);
331
+ var [selectedNode, setSelectedNode] = useState(null);
332
+
333
+ var load = function() {
334
+ engineCall('/cluster/nodes').then(function(d) {
335
+ setNodes(d.nodes || []);
336
+ setStats(d.stats || null);
337
+ setLoading(false);
338
+ }).catch(function() { setLoading(false); });
339
+ };
340
+
341
+ useEffect(function() { load(); }, []);
342
+
343
+ // Real-time updates via SSE
344
+ useEffect(function() {
345
+ var es = new EventSource('/api/engine/cluster/stream');
346
+ es.onmessage = function(ev) {
347
+ try {
348
+ var d = JSON.parse(ev.data);
349
+ if (d.type === 'node') {
350
+ setNodes(function(prev) {
351
+ var idx = prev.findIndex(function(n) { return n.nodeId === d.nodeId; });
352
+ var next = prev.slice();
353
+ if (d.event === 'offline' && idx >= 0) { next[idx] = Object.assign({}, next[idx], { status: 'offline', agents: [] }); }
354
+ else if (idx >= 0) { next[idx] = d; }
355
+ else if (d.event === 'register' || d.event === 'snapshot') { next.push(d); }
356
+ return next;
357
+ });
358
+ // Refresh stats
359
+ engineCall('/cluster/nodes').then(function(dd) { setStats(dd.stats || null); }).catch(function() {});
360
+ }
361
+ } catch(e) {}
362
+ };
363
+ return function() { es.close(); };
364
+ }, []);
365
+
366
+ var removeNode = function(e, nodeId) {
367
+ e.stopPropagation();
368
+ if (!confirm('Remove worker node "' + nodeId + '"? This only removes it from the dashboard — agents on it will keep running but stop reporting.')) return;
369
+ engineCall('/cluster/nodes/' + nodeId, { method: 'DELETE' }).then(function() {
370
+ toast('Node removed', 'success');
371
+ load();
372
+ }).catch(function(e) { toast(e.message, 'error'); });
373
+ };
374
+
375
+ var statusColor = function(s) { return { online: 'success', degraded: 'warning', offline: 'neutral' }[s] || 'neutral'; };
376
+ var statusDot = function(s) { return { online: 'var(--accent-green)', degraded: 'var(--accent-orange)', offline: 'var(--text-muted)' }[s] || 'var(--text-muted)'; };
377
+
378
+ var formatBytes = function(mb) {
379
+ if (mb >= 1024) return (mb / 1024).toFixed(1) + ' GB';
380
+ return mb + ' MB';
381
+ };
382
+
383
+ var timeSince = function(iso) {
384
+ if (!iso) return 'never';
385
+ var s = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
386
+ if (s < 60) return s + 's ago';
387
+ if (s < 3600) return Math.floor(s / 60) + 'm ago';
388
+ if (s < 86400) return Math.floor(s / 3600) + 'h ago';
389
+ return Math.floor(s / 86400) + 'd ago';
390
+ };
391
+
392
+ if (loading) return h('div', { style: { padding: 40, textAlign: 'center', color: 'var(--text-muted)' } }, 'Loading cluster...');
393
+
394
+ return h(Fragment, null,
395
+ addingNode && h(AddNodeModal, { onClose: function() { setAddingNode(false); }, onAdded: load, toast: toast }),
396
+ selectedNode && h(NodeDetailModal, { node: selectedNode, onClose: function() { setSelectedNode(null); }, onRefresh: load, toast: toast }),
397
+
398
+ h('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 } },
399
+ h('div', null,
400
+ h('h1', { style: { fontSize: 20, fontWeight: 700, display: 'flex', alignItems: 'center', gap: 8 } }, 'Cluster', h(KnowledgeLink, { page: 'cluster' }),
401
+ h(HelpButton, { label: 'Cluster' },
402
+ h('p', null, 'Manage worker nodes running agents across multiple machines. Scale horizontally by adding machines — each one runs agents that report back to this dashboard.'),
403
+ h('p', { style: { marginTop: 8 } }, h('strong', null, '3 ways to add a worker:'),
404
+ h('ul', { style: { paddingLeft: 16, marginTop: 4 } },
405
+ h('li', null, h('strong', null, 'Manual'), ' — Register an existing machine by IP/hostname'),
406
+ h('li', null, h('strong', null, 'SSH Deploy'), ' — Auto-install on a remote machine via SSH'),
407
+ h('li', null, h('strong', null, 'Setup Script'), ' — Generate a script to run on the target machine')
408
+ )
409
+ )
410
+ )
411
+ ),
412
+ h('p', { style: { color: 'var(--text-muted)', fontSize: 13 } }, 'Scale your AI workforce across multiple machines')
413
+ ),
414
+ h('div', { style: { display: 'flex', gap: 8 } },
415
+ h('button', { className: 'btn btn-secondary btn-sm', onClick: load }, I.refresh(), ' Refresh'),
416
+ h('button', { className: 'btn btn-primary', onClick: function() { setAddingNode(true); } }, I.plus(), ' Add Worker Node')
417
+ )
418
+ ),
419
+
420
+ // Stats cards
421
+ stats && h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: 12, marginBottom: 20 } },
422
+ h('div', { className: 'card' }, h('div', { className: 'card-body', style: { padding: 16, textAlign: 'center' } },
423
+ h('div', { style: { fontSize: 28, fontWeight: 700 } }, stats.totalNodes),
424
+ h('div', { style: { fontSize: 12, color: 'var(--text-muted)' } }, 'Total Nodes')
425
+ )),
426
+ h('div', { className: 'card' }, h('div', { className: 'card-body', style: { padding: 16, textAlign: 'center' } },
427
+ h('div', { style: { fontSize: 28, fontWeight: 700, color: 'var(--accent-green)' } }, stats.onlineNodes),
428
+ h('div', { style: { fontSize: 12, color: 'var(--text-muted)' } }, 'Online')
429
+ )),
430
+ h('div', { className: 'card' }, h('div', { className: 'card-body', style: { padding: 16, textAlign: 'center' } },
431
+ h('div', { style: { fontSize: 28, fontWeight: 700 } }, stats.totalAgents),
432
+ h('div', { style: { fontSize: 12, color: 'var(--text-muted)' } }, 'Running Agents')
433
+ )),
434
+ h('div', { className: 'card' }, h('div', { className: 'card-body', style: { padding: 16, textAlign: 'center' } },
435
+ h('div', { style: { fontSize: 28, fontWeight: 700 } }, stats.totalCpus),
436
+ h('div', { style: { fontSize: 12, color: 'var(--text-muted)' } }, 'Total CPUs')
437
+ )),
438
+ h('div', { className: 'card' }, h('div', { className: 'card-body', style: { padding: 16, textAlign: 'center' } },
439
+ h('div', { style: { fontSize: 28, fontWeight: 700 } }, formatBytes(stats.totalMemoryMb)),
440
+ h('div', { style: { fontSize: 12, color: 'var(--text-muted)' } }, 'Total Memory')
441
+ ))
442
+ ),
443
+
444
+ // Nodes
445
+ nodes.length === 0
446
+ ? h('div', { className: 'card' }, h('div', { className: 'card-body', style: { padding: 40 } },
447
+ h('div', { className: 'empty-state' },
448
+ I.server(),
449
+ h('h3', null, 'No worker nodes'),
450
+ h('p', null, 'Your agents currently run on this machine. Add worker nodes to distribute agents across multiple machines for horizontal scaling.'),
451
+ h('button', { className: 'btn btn-primary', style: { marginTop: 12 }, onClick: function() { setAddingNode(true); } }, I.plus(), ' Add Your First Worker Node')
452
+ )
453
+ ))
454
+ : h('div', { style: { display: 'grid', gap: 12 } },
455
+ nodes.map(function(node) {
456
+ return h('div', { key: node.nodeId, className: 'card', style: { cursor: 'pointer', transition: 'border-color 0.2s' },
457
+ onClick: function() { setSelectedNode(node); },
458
+ onMouseEnter: function(e) { e.currentTarget.style.borderColor = 'var(--accent)'; },
459
+ onMouseLeave: function(e) { e.currentTarget.style.borderColor = ''; }
460
+ },
461
+ h('div', { className: 'card-body', style: { padding: 16 } },
462
+ h('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' } },
463
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: 10 } },
464
+ // Status dot
465
+ h('span', { style: { width: 12, height: 12, borderRadius: '50%', background: statusDot(node.status), flexShrink: 0, boxShadow: node.status === 'online' ? '0 0 6px ' + statusDot(node.status) : 'none' } }),
466
+ h('div', null,
467
+ h('div', { style: { fontSize: 15, fontWeight: 700 } }, node.name || node.nodeId),
468
+ h('div', { style: { fontSize: 12, color: 'var(--text-muted)', fontFamily: 'monospace' } }, node.host + ':' + node.port)
469
+ )
470
+ ),
471
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: 8 } },
472
+ h('span', { className: 'badge badge-' + statusColor(node.status), style: { textTransform: 'capitalize' } }, node.status),
473
+ h('button', { className: 'btn btn-ghost btn-sm', onClick: function(e) { removeNode(e, node.nodeId); }, title: 'Remove node' }, I.trash())
474
+ )
475
+ ),
476
+ // Metrics row
477
+ h('div', { style: { display: 'flex', gap: 24, marginTop: 12, paddingTop: 12, borderTop: '1px solid var(--border)' } },
478
+ h('div', null,
479
+ h('div', { style: { fontSize: 10, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5 } }, 'Platform'),
480
+ h('div', { style: { fontWeight: 600, fontSize: 13 } }, node.platform + '/' + node.arch)
481
+ ),
482
+ h('div', null,
483
+ h('div', { style: { fontSize: 10, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5 } }, 'CPUs'),
484
+ h('div', { style: { fontWeight: 600, fontSize: 13 } }, node.cpuCount || '-')
485
+ ),
486
+ h('div', null,
487
+ h('div', { style: { fontSize: 10, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5 } }, 'Memory'),
488
+ h('div', { style: { fontWeight: 600, fontSize: 13 } }, formatBytes(node.memoryMb))
489
+ ),
490
+ h('div', null,
491
+ h('div', { style: { fontSize: 10, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5 } }, 'Agents'),
492
+ h('div', { style: { fontWeight: 600, fontSize: 13 } }, node.agents ? node.agents.length : 0)
493
+ ),
494
+ h('div', null,
495
+ h('div', { style: { fontSize: 10, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5 } }, 'Version'),
496
+ h('div', { style: { fontWeight: 600, fontSize: 13 } }, 'v' + (node.version || '?'))
497
+ ),
498
+ h('div', null,
499
+ h('div', { style: { fontSize: 10, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5 } }, 'Last Seen'),
500
+ h('div', { style: { fontWeight: 600, fontSize: 13, color: node.status === 'online' ? 'var(--accent-green)' : 'var(--text-muted)' } }, timeSince(node.lastHeartbeat))
501
+ )
502
+ ),
503
+ // Capabilities
504
+ node.capabilities && node.capabilities.length > 0 && h('div', { style: { display: 'flex', gap: 4, marginTop: 8 } },
505
+ node.capabilities.map(function(c) { return h('span', { key: c, className: 'badge badge-neutral', style: { fontSize: 10 } }, c); })
506
+ )
507
+ )
508
+ );
509
+ })
510
+ )
511
+ );
512
+ }