@lamalibre/lamaste-agent-ui 4.0.0
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/LICENSE.md +118 -0
- package/dist-web/assets/index-Cl7aUHaU.css +1 -0
- package/dist-web/assets/index-D1W7KjDX.js +8 -0
- package/dist-web/index.html +13 -0
- package/package.json +45 -0
- package/src/WebApp.jsx +74 -0
- package/src/components/Toast.jsx +55 -0
- package/src/context/AgentClientContext.jsx +88 -0
- package/src/index.js +19 -0
- package/src/lib/cn.js +6 -0
- package/src/lib/errorMessage.js +10 -0
- package/src/lib/web-agent-client.js +114 -0
- package/src/pages/Dashboard.jsx +175 -0
- package/src/pages/Logs.jsx +54 -0
- package/src/pages/Plugins.jsx +866 -0
- package/src/pages/Services.jsx +644 -0
- package/src/pages/Settings.jsx +409 -0
- package/src/pages/Tunnels.jsx +273 -0
- package/src/web-entry.jsx +31 -0
- package/src/web.css +3 -0
|
@@ -0,0 +1,866 @@
|
|
|
1
|
+
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
2
|
+
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import {
|
|
4
|
+
Package,
|
|
5
|
+
Power,
|
|
6
|
+
PowerOff,
|
|
7
|
+
Trash2,
|
|
8
|
+
Download,
|
|
9
|
+
AlertCircle,
|
|
10
|
+
Play,
|
|
11
|
+
Square,
|
|
12
|
+
ExternalLink,
|
|
13
|
+
ChevronLeft,
|
|
14
|
+
ArrowUpCircle,
|
|
15
|
+
ChevronUp,
|
|
16
|
+
ChevronsUp,
|
|
17
|
+
X,
|
|
18
|
+
} from 'lucide-react';
|
|
19
|
+
import { Cpu, Terminal, Folder, Shield } from 'lucide-react';
|
|
20
|
+
import { useToast } from '../components/Toast.jsx';
|
|
21
|
+
import { useAgentClient } from '../context/AgentClientContext.jsx';
|
|
22
|
+
import { errorMessage } from '../lib/errorMessage.js';
|
|
23
|
+
|
|
24
|
+
// Curated plugin list — same as local plugins, filtered to agent-capable
|
|
25
|
+
const KNOWN_PLUGINS = [
|
|
26
|
+
{
|
|
27
|
+
name: 'herd',
|
|
28
|
+
packageName: '@lamalibre/herd-server',
|
|
29
|
+
description: 'Zero-config LLM inference pooling',
|
|
30
|
+
icon: 'cpu',
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: 'shell',
|
|
34
|
+
packageName: '@lamalibre/shell-server',
|
|
35
|
+
description: 'Secure remote terminal via tmux',
|
|
36
|
+
icon: 'terminal',
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: 'sync',
|
|
40
|
+
packageName: '@lamalibre/sync-server',
|
|
41
|
+
description: 'Bidirectional file sync',
|
|
42
|
+
icon: 'folder',
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: 'gate',
|
|
46
|
+
packageName: '@lamalibre/gate-server',
|
|
47
|
+
description: 'VPN tunnel management',
|
|
48
|
+
icon: 'shield',
|
|
49
|
+
},
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
const ICON_MAP = {
|
|
53
|
+
cpu: Cpu,
|
|
54
|
+
terminal: Terminal,
|
|
55
|
+
folder: Folder,
|
|
56
|
+
shield: Shield,
|
|
57
|
+
package: Package,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function StatusBadge({ status }) {
|
|
61
|
+
const styles =
|
|
62
|
+
status === 'enabled' ? 'bg-green-500/20 text-green-400' : 'bg-zinc-500/20 text-zinc-400';
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<span className={`text-xs px-2 py-0.5 rounded-full ${styles}`}>
|
|
66
|
+
{status === 'enabled' ? 'Enabled' : 'Disabled'}
|
|
67
|
+
</span>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function AvailablePluginCard({ plugin, installed, onInstall, isInstalling }) {
|
|
72
|
+
const Icon = ICON_MAP[plugin.icon] || Package;
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<div className="rounded-lg bg-zinc-900 border border-zinc-800 p-5">
|
|
76
|
+
<div className="flex items-center gap-2 mb-2">
|
|
77
|
+
<Icon size={16} className="text-cyan-400" />
|
|
78
|
+
<span className="text-white font-semibold">{plugin.name}</span>
|
|
79
|
+
</div>
|
|
80
|
+
<p className="text-zinc-400 text-sm mb-3">{plugin.description}</p>
|
|
81
|
+
<p className="text-zinc-500 text-xs mb-4 font-mono">{plugin.packageName}</p>
|
|
82
|
+
{installed ? (
|
|
83
|
+
<span className="text-xs text-zinc-500 bg-zinc-800 px-3 py-1.5 rounded inline-block">
|
|
84
|
+
Installed
|
|
85
|
+
</span>
|
|
86
|
+
) : (
|
|
87
|
+
<button
|
|
88
|
+
type="button"
|
|
89
|
+
disabled={isInstalling}
|
|
90
|
+
onClick={() => onInstall(plugin.packageName)}
|
|
91
|
+
className="flex items-center gap-1.5 rounded bg-cyan-600 px-3 py-1.5 text-xs text-white hover:bg-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
92
|
+
>
|
|
93
|
+
<Download size={12} />
|
|
94
|
+
{isInstalling ? 'Installing...' : 'Install'}
|
|
95
|
+
</button>
|
|
96
|
+
)}
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function getUpdateType(current, latest) {
|
|
102
|
+
const parse = (v) => (v || '0.0.0').split('.').map(Number);
|
|
103
|
+
const [cMaj, cMin] = parse(current);
|
|
104
|
+
const [lMaj, lMin] = parse(latest);
|
|
105
|
+
if (lMaj > cMaj) return 'major';
|
|
106
|
+
if (lMin > cMin) return 'minor';
|
|
107
|
+
return 'patch';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function UpdateIcon({ type, size = 14 }) {
|
|
111
|
+
if (type === 'major') return <ChevronsUp size={size} className="text-red-400" />;
|
|
112
|
+
if (type === 'minor') return <ChevronUp size={size} className="text-amber-400" />;
|
|
113
|
+
return <ArrowUpCircle size={size} className="text-cyan-400" />;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function UpdateDialog({ plugin, updateInfo, onUpdate, onClose, isUpdating }) {
|
|
117
|
+
const updateType = getUpdateType(updateInfo.currentVersion, updateInfo.latestVersion);
|
|
118
|
+
const typeLabel = { major: 'Major', minor: 'Minor', patch: 'Patch' }[updateType];
|
|
119
|
+
const typeColor = { major: 'text-red-400', minor: 'text-amber-400', patch: 'text-cyan-400' }[
|
|
120
|
+
updateType
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
|
|
125
|
+
<div className="w-full max-w-sm rounded-lg bg-zinc-900 border border-zinc-700 shadow-xl">
|
|
126
|
+
<div className="flex items-center justify-between p-4 border-b border-zinc-800">
|
|
127
|
+
<div className="flex items-center gap-2">
|
|
128
|
+
<UpdateIcon type={updateType} size={16} />
|
|
129
|
+
<span className="text-white font-semibold text-sm">{typeLabel} Update Available</span>
|
|
130
|
+
</div>
|
|
131
|
+
<button type="button" onClick={onClose} className="text-zinc-500 hover:text-zinc-300">
|
|
132
|
+
<X size={16} />
|
|
133
|
+
</button>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
<div className="p-4 space-y-3">
|
|
137
|
+
<div className="flex items-center justify-between text-sm">
|
|
138
|
+
<span className="text-zinc-400">Plugin</span>
|
|
139
|
+
<span className="text-white">{plugin.displayName || plugin.name}</span>
|
|
140
|
+
</div>
|
|
141
|
+
<div className="flex items-center justify-between text-sm">
|
|
142
|
+
<span className="text-zinc-400">Current</span>
|
|
143
|
+
<span className="text-zinc-300 font-mono text-xs">v{updateInfo.currentVersion}</span>
|
|
144
|
+
</div>
|
|
145
|
+
<div className="flex items-center justify-between text-sm">
|
|
146
|
+
<span className="text-zinc-400">Latest</span>
|
|
147
|
+
<span className={`font-mono text-xs ${typeColor}`}>v{updateInfo.latestVersion}</span>
|
|
148
|
+
</div>
|
|
149
|
+
{plugin.status === 'enabled' && (
|
|
150
|
+
<p className="text-xs text-zinc-500">
|
|
151
|
+
The panel server will restart automatically after update.
|
|
152
|
+
</p>
|
|
153
|
+
)}
|
|
154
|
+
</div>
|
|
155
|
+
|
|
156
|
+
<div className="flex items-center justify-end gap-2 p-4 border-t border-zinc-800">
|
|
157
|
+
<button
|
|
158
|
+
type="button"
|
|
159
|
+
onClick={onClose}
|
|
160
|
+
className="rounded px-3 py-1.5 text-xs text-zinc-300 bg-zinc-700 hover:bg-zinc-600"
|
|
161
|
+
>
|
|
162
|
+
Cancel
|
|
163
|
+
</button>
|
|
164
|
+
<button
|
|
165
|
+
type="button"
|
|
166
|
+
disabled={isUpdating}
|
|
167
|
+
onClick={() => {
|
|
168
|
+
onUpdate(plugin.name);
|
|
169
|
+
onClose();
|
|
170
|
+
}}
|
|
171
|
+
className="rounded px-3 py-1.5 text-xs text-white bg-cyan-600 hover:bg-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
172
|
+
>
|
|
173
|
+
{isUpdating ? 'Updating...' : 'Update'}
|
|
174
|
+
</button>
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Default agent panel port — matches AGENT_PANEL_PORT in agents.rs
|
|
182
|
+
const AGENT_PANEL_DEFAULT_PORT = 9393;
|
|
183
|
+
|
|
184
|
+
// Theme override — maps Lamaste Desktop's zinc/cyan palette to sync-panel's
|
|
185
|
+
// custom CSS tokens (--color-surface, --color-card, etc.) so microfrontend
|
|
186
|
+
// plugins look native inside the host.
|
|
187
|
+
const HOST_THEME = {
|
|
188
|
+
surface: 'oklch(0.145 0.000 0)', // zinc-950
|
|
189
|
+
card: 'oklch(0.210 0.006 285.885)', // zinc-900
|
|
190
|
+
cardHover: 'oklch(0.274 0.006 286.033)', // zinc-800
|
|
191
|
+
border: 'oklch(0.274 0.006 286.033)', // zinc-800
|
|
192
|
+
accent: 'oklch(0.789 0.154 211.53)', // cyan-400
|
|
193
|
+
accentDim: 'oklch(0.609 0.126 211.53)', // cyan-500
|
|
194
|
+
textPrimary: 'oklch(1.000 0.000 0)', // white
|
|
195
|
+
textSecondary: 'oklch(0.552 0.016 285.938)', // zinc-400
|
|
196
|
+
success: 'oklch(0.723 0.191 149.579)', // green-400
|
|
197
|
+
warning: 'oklch(0.795 0.184 86.047)', // amber-400
|
|
198
|
+
error: 'oklch(0.637 0.237 25.331)', // red-400
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
function AgentPluginPanel({ pluginName, client, onBack, subPath, onPagesDiscovered }) {
|
|
202
|
+
const mountRef = useRef(null);
|
|
203
|
+
const cleanupRef = useRef(null);
|
|
204
|
+
const [error, setError] = useState(null);
|
|
205
|
+
const [loading, setLoading] = useState(true);
|
|
206
|
+
const [retryCount, setRetryCount] = useState(0);
|
|
207
|
+
const subPathRef = useRef(subPath || '');
|
|
208
|
+
const lastMountedSubPathRef = useRef(null);
|
|
209
|
+
|
|
210
|
+
// Keep subPathRef in sync for remounting
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
subPathRef.current = subPath || '';
|
|
213
|
+
}, [subPath]);
|
|
214
|
+
|
|
215
|
+
useEffect(() => {
|
|
216
|
+
let cancelled = false;
|
|
217
|
+
|
|
218
|
+
function cleanupPlugin() {
|
|
219
|
+
if (cleanupRef.current) {
|
|
220
|
+
try {
|
|
221
|
+
cleanupRef.current();
|
|
222
|
+
} catch {
|
|
223
|
+
// best-effort cleanup
|
|
224
|
+
}
|
|
225
|
+
cleanupRef.current = null;
|
|
226
|
+
}
|
|
227
|
+
if (window.__lamalibrePlugins?.[pluginName]) {
|
|
228
|
+
delete window.__lamalibrePlugins[pluginName];
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function mountPlugin() {
|
|
233
|
+
const pluginEntry = window.__lamalibrePlugins?.[pluginName];
|
|
234
|
+
if (!pluginEntry || typeof pluginEntry.mount !== 'function') {
|
|
235
|
+
setError('Plugin did not register a mount function');
|
|
236
|
+
setLoading(false);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Report pages metadata to the parent for sidebar injection
|
|
241
|
+
if (onPagesDiscovered && Array.isArray(pluginEntry.pages)) {
|
|
242
|
+
onPagesDiscovered(pluginEntry.pages);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
try {
|
|
246
|
+
const isDesktop = !!window.__TAURI_INTERNALS__;
|
|
247
|
+
const panelUrl = isDesktop
|
|
248
|
+
? `http://127.0.0.1:${AGENT_PANEL_DEFAULT_PORT}`
|
|
249
|
+
: window.location.origin;
|
|
250
|
+
|
|
251
|
+
const ctx = {
|
|
252
|
+
mountPoint: mountRef.current,
|
|
253
|
+
panelUrl,
|
|
254
|
+
// Plugins mount at /<name>/ on the agent daemon (matches both the
|
|
255
|
+
// desktop-loaded panel and the Authelia-protected plugin tunnel
|
|
256
|
+
// flow, where nginx rewrites herd.example.com/x -> :9393/herd/x).
|
|
257
|
+
basePath: `/${pluginName}`,
|
|
258
|
+
subPath: subPathRef.current,
|
|
259
|
+
theme: HOST_THEME,
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const result = pluginEntry.mount(ctx);
|
|
263
|
+
if (result && typeof result === 'object' && typeof result.unmount === 'function') {
|
|
264
|
+
cleanupRef.current = result.unmount;
|
|
265
|
+
} else if (typeof result === 'function') {
|
|
266
|
+
cleanupRef.current = result;
|
|
267
|
+
}
|
|
268
|
+
lastMountedSubPathRef.current = subPathRef.current;
|
|
269
|
+
|
|
270
|
+
setLoading(false);
|
|
271
|
+
} catch (err) {
|
|
272
|
+
setError(`[mount] ${errorMessage(err)}`);
|
|
273
|
+
setLoading(false);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function loadBundle() {
|
|
278
|
+
setError(null);
|
|
279
|
+
setLoading(true);
|
|
280
|
+
cleanupPlugin();
|
|
281
|
+
|
|
282
|
+
let bundle;
|
|
283
|
+
try {
|
|
284
|
+
bundle = await client.fetchAgentPluginBundle(pluginName);
|
|
285
|
+
} catch (err) {
|
|
286
|
+
if (!cancelled) {
|
|
287
|
+
const msg = typeof err === 'string' ? err : err?.message || String(err);
|
|
288
|
+
setError(`[fetch] ${msg}`);
|
|
289
|
+
setLoading(false);
|
|
290
|
+
}
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (cancelled) return;
|
|
295
|
+
|
|
296
|
+
if (!bundle?.source) {
|
|
297
|
+
setError('[fetch] Bundle response was empty or invalid');
|
|
298
|
+
setLoading(false);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
try {
|
|
303
|
+
const evalFn = new Function(bundle.source);
|
|
304
|
+
evalFn();
|
|
305
|
+
} catch (err) {
|
|
306
|
+
if (!cancelled) {
|
|
307
|
+
setError(`[eval] ${errorMessage(err)}`);
|
|
308
|
+
setLoading(false);
|
|
309
|
+
}
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (!cancelled) mountPlugin();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
loadBundle();
|
|
316
|
+
|
|
317
|
+
return () => {
|
|
318
|
+
cancelled = true;
|
|
319
|
+
cleanupPlugin();
|
|
320
|
+
};
|
|
321
|
+
}, [pluginName, client, retryCount]);
|
|
322
|
+
|
|
323
|
+
// Re-mount when subPath changes (unmount old, mount new with updated subPath).
|
|
324
|
+
useEffect(() => {
|
|
325
|
+
if (loading || error) return;
|
|
326
|
+
// Skip if this subPath was already mounted by the initial loadBundle effect
|
|
327
|
+
if (lastMountedSubPathRef.current === (subPath || '')) return;
|
|
328
|
+
|
|
329
|
+
const pluginEntry = window.__lamalibrePlugins?.[pluginName];
|
|
330
|
+
if (!pluginEntry || typeof pluginEntry.mount !== 'function') return;
|
|
331
|
+
|
|
332
|
+
// Cleanup previous mount
|
|
333
|
+
if (cleanupRef.current) {
|
|
334
|
+
try {
|
|
335
|
+
cleanupRef.current();
|
|
336
|
+
} catch {
|
|
337
|
+
/* best-effort */
|
|
338
|
+
}
|
|
339
|
+
cleanupRef.current = null;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Clear mount point
|
|
343
|
+
if (mountRef.current) {
|
|
344
|
+
mountRef.current.innerHTML = '';
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
try {
|
|
348
|
+
const isDesktop = !!window.__TAURI_INTERNALS__;
|
|
349
|
+
const panelUrl = isDesktop
|
|
350
|
+
? `http://127.0.0.1:${AGENT_PANEL_DEFAULT_PORT}`
|
|
351
|
+
: window.location.origin;
|
|
352
|
+
|
|
353
|
+
const ctx = {
|
|
354
|
+
mountPoint: mountRef.current,
|
|
355
|
+
panelUrl,
|
|
356
|
+
// Plugins mount at /<name>/ on the agent daemon (see initial mount).
|
|
357
|
+
basePath: `/${pluginName}`,
|
|
358
|
+
subPath: subPath || '',
|
|
359
|
+
theme: HOST_THEME,
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
const result = pluginEntry.mount(ctx);
|
|
363
|
+
if (result && typeof result === 'object' && typeof result.unmount === 'function') {
|
|
364
|
+
cleanupRef.current = result.unmount;
|
|
365
|
+
} else if (typeof result === 'function') {
|
|
366
|
+
cleanupRef.current = result;
|
|
367
|
+
}
|
|
368
|
+
lastMountedSubPathRef.current = subPath || '';
|
|
369
|
+
} catch (err) {
|
|
370
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
371
|
+
setError(`[mount] ${errorMessage(err)}`);
|
|
372
|
+
}
|
|
373
|
+
}, [subPath]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
374
|
+
|
|
375
|
+
return (
|
|
376
|
+
<div>
|
|
377
|
+
{!onPagesDiscovered && (
|
|
378
|
+
<button
|
|
379
|
+
type="button"
|
|
380
|
+
onClick={onBack}
|
|
381
|
+
className="flex items-center gap-1.5 text-sm text-zinc-400 hover:text-zinc-200 mb-4"
|
|
382
|
+
>
|
|
383
|
+
<ChevronLeft size={14} />
|
|
384
|
+
Back to Plugins
|
|
385
|
+
</button>
|
|
386
|
+
)}
|
|
387
|
+
|
|
388
|
+
{loading && <div className="text-zinc-500 text-sm">Loading plugin panel...</div>}
|
|
389
|
+
|
|
390
|
+
{error && (
|
|
391
|
+
<div className="rounded-lg bg-zinc-900 border border-red-800/50 p-4">
|
|
392
|
+
<div className="flex items-center gap-2 text-red-400 text-sm mb-3">
|
|
393
|
+
<AlertCircle size={16} />
|
|
394
|
+
{error}
|
|
395
|
+
</div>
|
|
396
|
+
<button
|
|
397
|
+
type="button"
|
|
398
|
+
onClick={() => setRetryCount((c) => c + 1)}
|
|
399
|
+
className="rounded bg-zinc-700 px-3 py-1.5 text-xs text-zinc-300 hover:bg-zinc-600"
|
|
400
|
+
>
|
|
401
|
+
Retry
|
|
402
|
+
</button>
|
|
403
|
+
</div>
|
|
404
|
+
)}
|
|
405
|
+
|
|
406
|
+
<div ref={mountRef} />
|
|
407
|
+
</div>
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function PluginCard({
|
|
412
|
+
plugin,
|
|
413
|
+
onEnable,
|
|
414
|
+
onDisable,
|
|
415
|
+
onUninstall,
|
|
416
|
+
onOpen,
|
|
417
|
+
onUpdate,
|
|
418
|
+
updateInfo,
|
|
419
|
+
isActing,
|
|
420
|
+
isUpdating,
|
|
421
|
+
}) {
|
|
422
|
+
const [confirmUninstall, setConfirmUninstall] = useState(false);
|
|
423
|
+
const [showUpdateDialog, setShowUpdateDialog] = useState(false);
|
|
424
|
+
|
|
425
|
+
const updateType = updateInfo?.hasUpdate
|
|
426
|
+
? getUpdateType(updateInfo.currentVersion, updateInfo.latestVersion)
|
|
427
|
+
: null;
|
|
428
|
+
|
|
429
|
+
return (
|
|
430
|
+
<div className="rounded-lg bg-zinc-900 border border-zinc-800 p-5">
|
|
431
|
+
<div className="flex items-center justify-between mb-3">
|
|
432
|
+
<div className="flex items-center gap-2">
|
|
433
|
+
<Package size={16} className="text-cyan-400" />
|
|
434
|
+
<span className="text-white font-semibold">{plugin.displayName || plugin.name}</span>
|
|
435
|
+
<span className="text-zinc-500 text-xs">v{plugin.version}</span>
|
|
436
|
+
{updateType && (
|
|
437
|
+
<button
|
|
438
|
+
type="button"
|
|
439
|
+
onClick={() => setShowUpdateDialog(true)}
|
|
440
|
+
title={`${updateType} update: v${updateInfo.latestVersion}`}
|
|
441
|
+
className="hover:opacity-80"
|
|
442
|
+
>
|
|
443
|
+
<UpdateIcon type={updateType} size={14} />
|
|
444
|
+
</button>
|
|
445
|
+
)}
|
|
446
|
+
</div>
|
|
447
|
+
<StatusBadge status={plugin.status} />
|
|
448
|
+
</div>
|
|
449
|
+
|
|
450
|
+
{plugin.displayName && <p className="text-zinc-500 text-xs font-mono mb-1">{plugin.name}</p>}
|
|
451
|
+
|
|
452
|
+
{plugin.description && <p className="text-zinc-400 text-sm mb-2">{plugin.description}</p>}
|
|
453
|
+
|
|
454
|
+
<p className="text-zinc-500 text-xs mb-4">
|
|
455
|
+
{plugin.packageName}
|
|
456
|
+
{plugin.installedAt && (
|
|
457
|
+
<span> · Installed {new Date(plugin.installedAt).toLocaleDateString()}</span>
|
|
458
|
+
)}
|
|
459
|
+
{plugin.panel?.pages?.length > 0 && (
|
|
460
|
+
<span>
|
|
461
|
+
{' '}
|
|
462
|
+
· {plugin.panel.pages.length} page{plugin.panel.pages.length > 1 ? 's' : ''}
|
|
463
|
+
</span>
|
|
464
|
+
)}
|
|
465
|
+
</p>
|
|
466
|
+
|
|
467
|
+
{plugin.capabilities && plugin.capabilities.length > 0 && (
|
|
468
|
+
<div className="flex flex-wrap gap-1 mb-4">
|
|
469
|
+
{plugin.capabilities.map((cap) => (
|
|
470
|
+
<span key={cap} className="text-xs bg-zinc-800 text-zinc-400 px-2 py-0.5 rounded">
|
|
471
|
+
{cap}
|
|
472
|
+
</span>
|
|
473
|
+
))}
|
|
474
|
+
</div>
|
|
475
|
+
)}
|
|
476
|
+
|
|
477
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
478
|
+
{plugin.status === 'disabled' ? (
|
|
479
|
+
<>
|
|
480
|
+
<button
|
|
481
|
+
type="button"
|
|
482
|
+
disabled={isActing}
|
|
483
|
+
onClick={() => onEnable(plugin.name)}
|
|
484
|
+
className="flex items-center gap-1.5 rounded bg-zinc-700 px-3 py-1.5 text-xs text-zinc-300 hover:bg-zinc-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
485
|
+
>
|
|
486
|
+
<Power size={12} />
|
|
487
|
+
Enable
|
|
488
|
+
</button>
|
|
489
|
+
{confirmUninstall ? (
|
|
490
|
+
<span className="flex items-center gap-2 text-xs">
|
|
491
|
+
<span className="text-red-400">Uninstall?</span>
|
|
492
|
+
<button
|
|
493
|
+
type="button"
|
|
494
|
+
onClick={() => {
|
|
495
|
+
setConfirmUninstall(false);
|
|
496
|
+
onUninstall(plugin.name);
|
|
497
|
+
}}
|
|
498
|
+
className="rounded bg-red-600 px-2 py-1 text-white hover:bg-red-500"
|
|
499
|
+
>
|
|
500
|
+
Yes
|
|
501
|
+
</button>
|
|
502
|
+
<button
|
|
503
|
+
type="button"
|
|
504
|
+
onClick={() => setConfirmUninstall(false)}
|
|
505
|
+
className="rounded bg-zinc-700 px-2 py-1 text-zinc-300 hover:bg-zinc-600"
|
|
506
|
+
>
|
|
507
|
+
No
|
|
508
|
+
</button>
|
|
509
|
+
</span>
|
|
510
|
+
) : (
|
|
511
|
+
<button
|
|
512
|
+
type="button"
|
|
513
|
+
disabled={isActing}
|
|
514
|
+
onClick={() => setConfirmUninstall(true)}
|
|
515
|
+
className="flex items-center gap-1.5 rounded bg-zinc-700 px-3 py-1.5 text-xs text-zinc-300 hover:bg-zinc-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
516
|
+
>
|
|
517
|
+
<Trash2 size={12} />
|
|
518
|
+
Uninstall
|
|
519
|
+
</button>
|
|
520
|
+
)}
|
|
521
|
+
</>
|
|
522
|
+
) : (
|
|
523
|
+
<>
|
|
524
|
+
<button
|
|
525
|
+
type="button"
|
|
526
|
+
disabled={isActing}
|
|
527
|
+
onClick={() => onDisable(plugin.name)}
|
|
528
|
+
className="flex items-center gap-1.5 rounded bg-zinc-700 px-3 py-1.5 text-xs text-zinc-300 hover:bg-zinc-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
529
|
+
>
|
|
530
|
+
<PowerOff size={12} />
|
|
531
|
+
Disable
|
|
532
|
+
</button>
|
|
533
|
+
{plugin.packages?.server && (
|
|
534
|
+
<button
|
|
535
|
+
type="button"
|
|
536
|
+
onClick={() => onOpen(plugin.name)}
|
|
537
|
+
className="flex items-center gap-1.5 rounded bg-zinc-700 px-3 py-1.5 text-xs text-cyan-400 hover:bg-zinc-600"
|
|
538
|
+
>
|
|
539
|
+
<ExternalLink size={12} />
|
|
540
|
+
Open
|
|
541
|
+
</button>
|
|
542
|
+
)}
|
|
543
|
+
</>
|
|
544
|
+
)}
|
|
545
|
+
</div>
|
|
546
|
+
|
|
547
|
+
{showUpdateDialog && updateInfo?.hasUpdate && (
|
|
548
|
+
<UpdateDialog
|
|
549
|
+
plugin={plugin}
|
|
550
|
+
updateInfo={updateInfo}
|
|
551
|
+
onUpdate={onUpdate}
|
|
552
|
+
onClose={() => setShowUpdateDialog(false)}
|
|
553
|
+
isUpdating={isUpdating}
|
|
554
|
+
/>
|
|
555
|
+
)}
|
|
556
|
+
</div>
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function InstallForm({ onInstall, isInstalling }) {
|
|
561
|
+
const [packageName, setPackageName] = useState('');
|
|
562
|
+
|
|
563
|
+
const handleSubmit = (e) => {
|
|
564
|
+
e.preventDefault();
|
|
565
|
+
if (!packageName.trim()) return;
|
|
566
|
+
onInstall(packageName.trim());
|
|
567
|
+
setPackageName('');
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
return (
|
|
571
|
+
<form onSubmit={handleSubmit} className="flex items-end gap-3">
|
|
572
|
+
<div className="flex-1">
|
|
573
|
+
<label className="block text-xs text-zinc-400 mb-1" htmlFor="agent-plugin-package-name">
|
|
574
|
+
Package name
|
|
575
|
+
</label>
|
|
576
|
+
<input
|
|
577
|
+
id="agent-plugin-package-name"
|
|
578
|
+
type="text"
|
|
579
|
+
value={packageName}
|
|
580
|
+
onChange={(e) => setPackageName(e.target.value)}
|
|
581
|
+
placeholder="@lamalibre/..."
|
|
582
|
+
className="w-full rounded bg-zinc-800 border border-zinc-700 px-3 py-1.5 text-sm text-zinc-300 placeholder-zinc-600 focus:outline-none focus:border-cyan-400"
|
|
583
|
+
/>
|
|
584
|
+
</div>
|
|
585
|
+
<button
|
|
586
|
+
type="submit"
|
|
587
|
+
disabled={isInstalling || !packageName.trim()}
|
|
588
|
+
className="flex items-center gap-1.5 rounded bg-cyan-600 px-4 py-1.5 text-sm text-white hover:bg-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
589
|
+
>
|
|
590
|
+
<Download size={14} />
|
|
591
|
+
Install
|
|
592
|
+
</button>
|
|
593
|
+
</form>
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function PanelNotRunning({ onStart, isStarting }) {
|
|
598
|
+
return (
|
|
599
|
+
<div className="rounded-lg bg-zinc-900 border border-zinc-800 p-8 text-center">
|
|
600
|
+
<AlertCircle size={32} className="text-zinc-500 mx-auto mb-4" />
|
|
601
|
+
<h2 className="text-white font-semibold mb-2">Agent Plugin Server Not Running</h2>
|
|
602
|
+
<p className="text-zinc-400 text-sm mb-6 max-w-md mx-auto">
|
|
603
|
+
The agent plugin server needs to be running to manage plugins. Start it to install, enable,
|
|
604
|
+
and configure plugins on this agent.
|
|
605
|
+
</p>
|
|
606
|
+
<button
|
|
607
|
+
type="button"
|
|
608
|
+
disabled={isStarting}
|
|
609
|
+
onClick={onStart}
|
|
610
|
+
className="inline-flex items-center gap-2 rounded bg-cyan-600 px-5 py-2 text-sm text-white hover:bg-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
611
|
+
>
|
|
612
|
+
<Play size={16} />
|
|
613
|
+
{isStarting ? 'Starting...' : 'Start Plugin Server'}
|
|
614
|
+
</button>
|
|
615
|
+
</div>
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
export { AgentPluginPanel };
|
|
620
|
+
|
|
621
|
+
export default function AgentPluginsPage({ onOpenPlugin }) {
|
|
622
|
+
const client = useAgentClient();
|
|
623
|
+
const queryClient = useQueryClient();
|
|
624
|
+
const addToast = useToast();
|
|
625
|
+
const [openPlugin, setOpenPlugin] = useState(null);
|
|
626
|
+
|
|
627
|
+
// Fetch plugins directly — if the panel isn't running, the query will error
|
|
628
|
+
const pluginsQuery = useQuery({
|
|
629
|
+
queryKey: ['agent-plugins'],
|
|
630
|
+
queryFn: () => client.getAgentPlugins(),
|
|
631
|
+
refetchInterval: 10000,
|
|
632
|
+
retry: 1,
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
// Derive panel status from the plugins query
|
|
636
|
+
const panelRunning = pluginsQuery.isSuccess;
|
|
637
|
+
|
|
638
|
+
const startPanelMutation = useMutation({
|
|
639
|
+
mutationFn: () => client.startAgentPanel(),
|
|
640
|
+
onSuccess: () => {
|
|
641
|
+
queryClient.invalidateQueries({ queryKey: ['agent-plugins'] });
|
|
642
|
+
addToast('Plugin server started');
|
|
643
|
+
},
|
|
644
|
+
onError: (err) => addToast(errorMessage(err) || 'Failed to start plugin server', 'error'),
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
const stopPanelMutation = useMutation({
|
|
648
|
+
mutationFn: () => client.stopAgentPanel(),
|
|
649
|
+
onSuccess: () => {
|
|
650
|
+
queryClient.invalidateQueries({ queryKey: ['agent-plugins'] });
|
|
651
|
+
addToast('Plugin server stopped');
|
|
652
|
+
},
|
|
653
|
+
onError: (err) => addToast(errorMessage(err) || 'Failed to stop plugin server', 'error'),
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
// Check for updates on all installed plugins
|
|
657
|
+
const installedList = pluginsQuery.data?.plugins || [];
|
|
658
|
+
const updatesQuery = useQuery({
|
|
659
|
+
queryKey: [
|
|
660
|
+
'agent-plugin-updates',
|
|
661
|
+
installedList.map((p) => `${p.name}@${p.version}`).join(','),
|
|
662
|
+
],
|
|
663
|
+
queryFn: async () => {
|
|
664
|
+
const results = {};
|
|
665
|
+
await Promise.all(
|
|
666
|
+
installedList.map(async (p) => {
|
|
667
|
+
try {
|
|
668
|
+
results[p.name] = await client.checkAgentPluginUpdate(p.name);
|
|
669
|
+
} catch {
|
|
670
|
+
// Silently skip — network errors shouldn't block the UI
|
|
671
|
+
}
|
|
672
|
+
}),
|
|
673
|
+
);
|
|
674
|
+
return results;
|
|
675
|
+
},
|
|
676
|
+
enabled: installedList.length > 0,
|
|
677
|
+
refetchInterval: 60000,
|
|
678
|
+
staleTime: 30000,
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
const updateInfoMap = updatesQuery.data || {};
|
|
682
|
+
|
|
683
|
+
const installMutation = useMutation({
|
|
684
|
+
mutationFn: (packageName) => client.installAgentPlugin(packageName),
|
|
685
|
+
onSuccess: (data) => {
|
|
686
|
+
queryClient.invalidateQueries({ queryKey: ['agent-plugins'] });
|
|
687
|
+
addToast(`Plugin "${data.plugin?.name || 'unknown'}" installed`);
|
|
688
|
+
},
|
|
689
|
+
onError: (err) => addToast(errorMessage(err), 'error'),
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
const enableMutation = useMutation({
|
|
693
|
+
mutationFn: (name) => client.enableAgentPlugin(name),
|
|
694
|
+
onSuccess: (data) => {
|
|
695
|
+
queryClient.invalidateQueries({ queryKey: ['agent-plugins'] });
|
|
696
|
+
addToast(`Plugin "${data.name}" enabled — panel server is restarting...`);
|
|
697
|
+
},
|
|
698
|
+
onError: (err) => addToast(errorMessage(err), 'error'),
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
const disableMutation = useMutation({
|
|
702
|
+
mutationFn: (name) => client.disableAgentPlugin(name),
|
|
703
|
+
onSuccess: (data) => {
|
|
704
|
+
queryClient.invalidateQueries({ queryKey: ['agent-plugins'] });
|
|
705
|
+
addToast(`Plugin "${data.name}" disabled — panel server is restarting...`);
|
|
706
|
+
},
|
|
707
|
+
onError: (err) => addToast(errorMessage(err), 'error'),
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
const uninstallMutation = useMutation({
|
|
711
|
+
mutationFn: (name) => client.uninstallAgentPlugin(name),
|
|
712
|
+
onSuccess: (data) => {
|
|
713
|
+
queryClient.invalidateQueries({ queryKey: ['agent-plugins'] });
|
|
714
|
+
addToast(`Plugin "${data.name}" uninstalled`);
|
|
715
|
+
},
|
|
716
|
+
onError: (err) => addToast(errorMessage(err), 'error'),
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
const updateMutation = useMutation({
|
|
720
|
+
mutationFn: (name) => client.updateAgentPlugin(name),
|
|
721
|
+
onSuccess: (data) => {
|
|
722
|
+
queryClient.invalidateQueries({ queryKey: ['agent-plugins'] });
|
|
723
|
+
queryClient.invalidateQueries({ queryKey: ['agent-plugin-updates'] });
|
|
724
|
+
const p = data.plugin || data;
|
|
725
|
+
addToast(`Plugin "${p.name || 'unknown'}" updated to v${p.version || '?'}`);
|
|
726
|
+
},
|
|
727
|
+
onError: (err) => addToast(errorMessage(err), 'error'),
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
const isActing =
|
|
731
|
+
installMutation.isPending ||
|
|
732
|
+
enableMutation.isPending ||
|
|
733
|
+
disableMutation.isPending ||
|
|
734
|
+
uninstallMutation.isPending ||
|
|
735
|
+
updateMutation.isPending;
|
|
736
|
+
|
|
737
|
+
const handleInstall = useCallback(
|
|
738
|
+
(packageName) => installMutation.mutate(packageName),
|
|
739
|
+
[installMutation],
|
|
740
|
+
);
|
|
741
|
+
|
|
742
|
+
const handleEnable = useCallback((name) => enableMutation.mutate(name), [enableMutation]);
|
|
743
|
+
|
|
744
|
+
const handleDisable = useCallback((name) => disableMutation.mutate(name), [disableMutation]);
|
|
745
|
+
|
|
746
|
+
const handleUninstall = useCallback(
|
|
747
|
+
(name) => uninstallMutation.mutate(name),
|
|
748
|
+
[uninstallMutation],
|
|
749
|
+
);
|
|
750
|
+
|
|
751
|
+
const handleUpdate = useCallback((name) => updateMutation.mutate(name), [updateMutation]);
|
|
752
|
+
|
|
753
|
+
const plugins = pluginsQuery.data?.plugins;
|
|
754
|
+
const installedNames = new Set((plugins || []).map((p) => p.packageName));
|
|
755
|
+
|
|
756
|
+
// If a plugin panel is open, show it
|
|
757
|
+
if (openPlugin) {
|
|
758
|
+
return (
|
|
759
|
+
<div className="p-6 max-w-4xl mx-auto">
|
|
760
|
+
<AgentPluginPanel
|
|
761
|
+
pluginName={openPlugin}
|
|
762
|
+
client={client}
|
|
763
|
+
onBack={() => setOpenPlugin(null)}
|
|
764
|
+
subPath=""
|
|
765
|
+
/>
|
|
766
|
+
</div>
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
return (
|
|
771
|
+
<div className="p-6 max-w-4xl mx-auto">
|
|
772
|
+
<div className="flex items-center justify-between mb-6">
|
|
773
|
+
<div>
|
|
774
|
+
<h1 className="text-xl font-bold text-white">Plugins</h1>
|
|
775
|
+
<p className="text-zinc-500 text-sm mt-1">Install, enable, and manage agent plugins</p>
|
|
776
|
+
</div>
|
|
777
|
+
{panelRunning && (
|
|
778
|
+
<div className="flex items-center gap-3">
|
|
779
|
+
<span className="flex items-center gap-1.5 text-xs text-green-400">
|
|
780
|
+
<span className="w-1.5 h-1.5 rounded-full bg-green-400" />
|
|
781
|
+
Server running
|
|
782
|
+
</span>
|
|
783
|
+
<button
|
|
784
|
+
type="button"
|
|
785
|
+
disabled={stopPanelMutation.isPending}
|
|
786
|
+
onClick={() => stopPanelMutation.mutate()}
|
|
787
|
+
className="flex items-center gap-1.5 rounded bg-zinc-700 px-3 py-1.5 text-xs text-zinc-300 hover:bg-zinc-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
788
|
+
>
|
|
789
|
+
<Square size={10} />
|
|
790
|
+
{stopPanelMutation.isPending ? 'Stopping...' : 'Stop'}
|
|
791
|
+
</button>
|
|
792
|
+
</div>
|
|
793
|
+
)}
|
|
794
|
+
</div>
|
|
795
|
+
|
|
796
|
+
{/* Panel server not running */}
|
|
797
|
+
{pluginsQuery.isError ? (
|
|
798
|
+
<PanelNotRunning
|
|
799
|
+
onStart={() => startPanelMutation.mutate()}
|
|
800
|
+
isStarting={startPanelMutation.isPending}
|
|
801
|
+
/>
|
|
802
|
+
) : (
|
|
803
|
+
<>
|
|
804
|
+
{/* Custom install form */}
|
|
805
|
+
<div className="rounded-lg bg-zinc-900 border border-zinc-800 p-5 mb-8">
|
|
806
|
+
<h2 className="text-sm font-semibold text-zinc-300 mb-4">Install Custom Plugin</h2>
|
|
807
|
+
<InstallForm onInstall={handleInstall} isInstalling={installMutation.isPending} />
|
|
808
|
+
{installMutation.isPending && (
|
|
809
|
+
<p className="text-xs text-zinc-500 mt-2">Installing...</p>
|
|
810
|
+
)}
|
|
811
|
+
</div>
|
|
812
|
+
|
|
813
|
+
{/* Unified plugin grid: installed first, then available */}
|
|
814
|
+
<div className="mb-6">
|
|
815
|
+
<h2 className="text-sm font-semibold text-zinc-300 mb-3">Plugins</h2>
|
|
816
|
+
{pluginsQuery.isLoading ? (
|
|
817
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
818
|
+
{Array.from({ length: 4 }).map((_, i) => (
|
|
819
|
+
<div
|
|
820
|
+
key={i}
|
|
821
|
+
className="h-32 animate-pulse rounded-lg bg-zinc-900 border border-zinc-800"
|
|
822
|
+
/>
|
|
823
|
+
))}
|
|
824
|
+
</div>
|
|
825
|
+
) : pluginsQuery.isError ? (
|
|
826
|
+
<div className="rounded-lg bg-zinc-900 border border-zinc-800 p-6">
|
|
827
|
+
<div className="flex items-center gap-2 text-red-400 text-sm">
|
|
828
|
+
<AlertCircle size={16} />
|
|
829
|
+
Failed to load plugins
|
|
830
|
+
</div>
|
|
831
|
+
</div>
|
|
832
|
+
) : (
|
|
833
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
834
|
+
{/* Installed plugins first */}
|
|
835
|
+
{plugins?.map((plugin) => (
|
|
836
|
+
<PluginCard
|
|
837
|
+
key={plugin.name}
|
|
838
|
+
plugin={plugin}
|
|
839
|
+
onEnable={handleEnable}
|
|
840
|
+
onDisable={handleDisable}
|
|
841
|
+
onUninstall={handleUninstall}
|
|
842
|
+
onOpen={(name) => (onOpenPlugin ? onOpenPlugin(name) : setOpenPlugin(name))}
|
|
843
|
+
onUpdate={handleUpdate}
|
|
844
|
+
updateInfo={updateInfoMap[plugin.name]}
|
|
845
|
+
isActing={isActing}
|
|
846
|
+
isUpdating={updateMutation.isPending}
|
|
847
|
+
/>
|
|
848
|
+
))}
|
|
849
|
+
{/* Available but not yet installed */}
|
|
850
|
+
{KNOWN_PLUGINS.filter((kp) => !installedNames.has(kp.packageName)).map((plugin) => (
|
|
851
|
+
<AvailablePluginCard
|
|
852
|
+
key={plugin.name}
|
|
853
|
+
plugin={plugin}
|
|
854
|
+
installed={false}
|
|
855
|
+
onInstall={handleInstall}
|
|
856
|
+
isInstalling={installMutation.isPending}
|
|
857
|
+
/>
|
|
858
|
+
))}
|
|
859
|
+
</div>
|
|
860
|
+
)}
|
|
861
|
+
</div>
|
|
862
|
+
</>
|
|
863
|
+
)}
|
|
864
|
+
</div>
|
|
865
|
+
);
|
|
866
|
+
}
|