@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,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en" class="dark">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Lamaste Agent</title>
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-D1W7KjDX.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="/assets/index-Cl7aUHaU.css">
|
|
9
|
+
</head>
|
|
10
|
+
<body style="background-color: #09090b">
|
|
11
|
+
<div id="root"></div>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lamalibre/lamaste-agent-ui",
|
|
3
|
+
"version": "4.0.0",
|
|
4
|
+
"description": "Shared React agent UI for Lamaste — consumed by desktop app and web agent panel",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js",
|
|
9
|
+
"./pages/*": "./src/pages/*",
|
|
10
|
+
"./components/*": "./src/components/*",
|
|
11
|
+
"./context/*": "./src/context/*",
|
|
12
|
+
"./lib/*": "./src/lib/*"
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build:web": "vite build"
|
|
16
|
+
},
|
|
17
|
+
"peerDependencies": {
|
|
18
|
+
"@tanstack/react-query": "^5.0.0",
|
|
19
|
+
"clsx": "^2.1.0",
|
|
20
|
+
"lucide-react": ">=0.383.0",
|
|
21
|
+
"react": "^18.3.0",
|
|
22
|
+
"react-dom": "^18.3.0",
|
|
23
|
+
"tailwind-merge": "^2.3.0"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@vitejs/plugin-react": "^6.0.1",
|
|
27
|
+
"autoprefixer": "^10.4.0",
|
|
28
|
+
"postcss": "^8.4.0",
|
|
29
|
+
"tailwindcss": "^3.4.0",
|
|
30
|
+
"vite": "^8.0.5"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"src/",
|
|
34
|
+
"dist-web/"
|
|
35
|
+
],
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "https://github.com/AnonVans/lamaste.git",
|
|
39
|
+
"directory": "packages/agent/ui"
|
|
40
|
+
},
|
|
41
|
+
"license": "SEE LICENSE IN LICENSE.md",
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=20.0.0"
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/WebApp.jsx
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { Activity, Network, Compass, Puzzle, ScrollText, Settings, Terminal } from 'lucide-react';
|
|
3
|
+
import AgentDashboardPage from './pages/Dashboard.jsx';
|
|
4
|
+
import AgentTunnelsPage from './pages/Tunnels.jsx';
|
|
5
|
+
import AgentServicesPage from './pages/Services.jsx';
|
|
6
|
+
import AgentPluginsPage from './pages/Plugins.jsx';
|
|
7
|
+
import AgentLogsPage from './pages/Logs.jsx';
|
|
8
|
+
import AgentSettingsPage from './pages/Settings.jsx';
|
|
9
|
+
|
|
10
|
+
const TABS = [
|
|
11
|
+
{ id: 'dashboard', label: 'Dashboard', icon: Activity },
|
|
12
|
+
{ id: 'tunnels', label: 'Tunnels', icon: Network },
|
|
13
|
+
{ id: 'services', label: 'Services', icon: Compass },
|
|
14
|
+
{ id: 'plugins', label: 'Plugins', icon: Puzzle },
|
|
15
|
+
{ id: 'logs', label: 'Logs', icon: ScrollText },
|
|
16
|
+
{ id: 'settings', label: 'Settings', icon: Settings },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
export default function WebApp() {
|
|
20
|
+
const [activeTab, setActiveTab] = useState('dashboard');
|
|
21
|
+
|
|
22
|
+
const renderPage = () => {
|
|
23
|
+
switch (activeTab) {
|
|
24
|
+
case 'tunnels':
|
|
25
|
+
return <AgentTunnelsPage />;
|
|
26
|
+
case 'services':
|
|
27
|
+
return <AgentServicesPage />;
|
|
28
|
+
case 'plugins':
|
|
29
|
+
return <AgentPluginsPage />;
|
|
30
|
+
case 'logs':
|
|
31
|
+
return <AgentLogsPage />;
|
|
32
|
+
case 'settings':
|
|
33
|
+
return <AgentSettingsPage />;
|
|
34
|
+
case 'dashboard':
|
|
35
|
+
default:
|
|
36
|
+
return <AgentDashboardPage />;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div className="flex h-screen bg-zinc-950">
|
|
42
|
+
{/* Sidebar */}
|
|
43
|
+
<div className="w-48 bg-zinc-900 border-r border-zinc-800 flex flex-col">
|
|
44
|
+
<div className="p-4 border-b border-zinc-800">
|
|
45
|
+
<div className="flex items-center gap-2">
|
|
46
|
+
<Terminal size={18} className="text-cyan-400" />
|
|
47
|
+
<span className="text-sm font-bold text-white">Lamaste Agent</span>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
|
|
51
|
+
<nav className="flex-1 p-2 overflow-y-auto">
|
|
52
|
+
{TABS.map(({ id, label, icon: Icon }) => (
|
|
53
|
+
<button
|
|
54
|
+
key={id}
|
|
55
|
+
type="button"
|
|
56
|
+
onClick={() => setActiveTab(id)}
|
|
57
|
+
className={`w-full flex items-center gap-2 px-3 py-2 rounded text-sm mb-0.5 ${
|
|
58
|
+
activeTab === id
|
|
59
|
+
? 'bg-zinc-800 text-cyan-400'
|
|
60
|
+
: 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800/50'
|
|
61
|
+
}`}
|
|
62
|
+
>
|
|
63
|
+
<Icon size={14} />
|
|
64
|
+
{label}
|
|
65
|
+
</button>
|
|
66
|
+
))}
|
|
67
|
+
</nav>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
{/* Main content */}
|
|
71
|
+
<div className="flex-1 overflow-y-auto">{renderPage()}</div>
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { createContext, useCallback, useContext, useState } from 'react';
|
|
2
|
+
import { X } from 'lucide-react';
|
|
3
|
+
|
|
4
|
+
const ToastContext = createContext(null);
|
|
5
|
+
|
|
6
|
+
export function useToast() {
|
|
7
|
+
const ctx = useContext(ToastContext);
|
|
8
|
+
if (!ctx) throw new Error('useToast must be used within a ToastProvider');
|
|
9
|
+
return ctx;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let toastId = 0;
|
|
13
|
+
|
|
14
|
+
export function ToastProvider({ children }) {
|
|
15
|
+
const [toasts, setToasts] = useState([]);
|
|
16
|
+
|
|
17
|
+
const addToast = useCallback((message, variant = 'success') => {
|
|
18
|
+
const id = ++toastId;
|
|
19
|
+
setToasts((prev) => [...prev, { id, message, variant }]);
|
|
20
|
+
setTimeout(() => {
|
|
21
|
+
setToasts((prev) => prev.filter((t) => t.id !== id));
|
|
22
|
+
}, 3000);
|
|
23
|
+
}, []);
|
|
24
|
+
|
|
25
|
+
const removeToast = useCallback((id) => {
|
|
26
|
+
setToasts((prev) => prev.filter((t) => t.id !== id));
|
|
27
|
+
}, []);
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<ToastContext.Provider value={addToast}>
|
|
31
|
+
{children}
|
|
32
|
+
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
|
|
33
|
+
{toasts.map((toast) => (
|
|
34
|
+
<div
|
|
35
|
+
key={toast.id}
|
|
36
|
+
className={`flex items-center gap-3 rounded-lg border px-4 py-3 text-sm font-mono shadow-lg ${
|
|
37
|
+
toast.variant === 'error'
|
|
38
|
+
? 'bg-red-500/10 border-red-500/20 text-red-400'
|
|
39
|
+
: 'bg-green-500/10 border-green-500/20 text-green-400'
|
|
40
|
+
}`}
|
|
41
|
+
>
|
|
42
|
+
<span className="flex-1">{toast.message}</span>
|
|
43
|
+
<button
|
|
44
|
+
type="button"
|
|
45
|
+
onClick={() => removeToast(toast.id)}
|
|
46
|
+
className="text-zinc-500 hover:text-zinc-300"
|
|
47
|
+
>
|
|
48
|
+
<X size={14} />
|
|
49
|
+
</button>
|
|
50
|
+
</div>
|
|
51
|
+
))}
|
|
52
|
+
</div>
|
|
53
|
+
</ToastContext.Provider>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { createContext, useContext } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AgentClientContext — the data layer abstraction for the agent panel.
|
|
5
|
+
*
|
|
6
|
+
* Each host (desktop app, web agent panel) provides its own implementation:
|
|
7
|
+
* - Desktop app: uses Tauri invoke() → Rust subprocess calls
|
|
8
|
+
* - Web agent panel: uses apiFetch() with HTTP requests
|
|
9
|
+
*
|
|
10
|
+
* All methods return promises. The page components call useAgentClient()
|
|
11
|
+
* and are completely host-agnostic.
|
|
12
|
+
*/
|
|
13
|
+
const AgentClientContext = createContext(null);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @returns {AgentClient} The agent client provided by the host.
|
|
17
|
+
* @throws If used outside an AgentClientProvider.
|
|
18
|
+
*
|
|
19
|
+
* @typedef {Object} AgentClient
|
|
20
|
+
*
|
|
21
|
+
* Status & Control:
|
|
22
|
+
* @property {() => Promise<{running: boolean, pid?: number, chiselVersion?: string, installed?: boolean}>} getStatus
|
|
23
|
+
* @property {() => Promise<any>} startAgent
|
|
24
|
+
* @property {() => Promise<any>} stopAgent
|
|
25
|
+
* @property {() => Promise<any>} restartAgent
|
|
26
|
+
* @property {() => Promise<any>} updateAgent
|
|
27
|
+
*
|
|
28
|
+
* Tunnels:
|
|
29
|
+
* @property {() => Promise<Array>} getTunnels
|
|
30
|
+
* @property {(data: {subdomain: string, port: number, description?: string}) => Promise<any>} createTunnel
|
|
31
|
+
* @property {(id: string, data: {enabled: boolean}) => Promise<any>} toggleTunnel
|
|
32
|
+
* @property {(id: string) => Promise<any>} deleteTunnel
|
|
33
|
+
*
|
|
34
|
+
* Services:
|
|
35
|
+
* @property {() => Promise<{services: Array, dockerContainers: Array}>} scanServices
|
|
36
|
+
* @property {() => Promise<{services: Array}>} getServiceRegistry
|
|
37
|
+
* @property {(data: {name: string, port: number, binary?: string, processName?: string, category: string, description: string}) => Promise<any>} addCustomService
|
|
38
|
+
* @property {(id: string) => Promise<any>} removeCustomService
|
|
39
|
+
*
|
|
40
|
+
* Logs:
|
|
41
|
+
* @property {() => Promise<string>} getLogs
|
|
42
|
+
*
|
|
43
|
+
* Configuration:
|
|
44
|
+
* @property {() => Promise<Object>} getConfig
|
|
45
|
+
* @property {() => Promise<string>} getPanelUrl
|
|
46
|
+
*
|
|
47
|
+
* Certificate:
|
|
48
|
+
* @property {() => Promise<any>} rotateCertificate
|
|
49
|
+
* @property {() => Promise<any>} downloadCertificate
|
|
50
|
+
*
|
|
51
|
+
* Web Panel:
|
|
52
|
+
* @property {() => Promise<{enabled: boolean, fqdn?: string, port?: number, running?: boolean}>} getPanelExposeStatus
|
|
53
|
+
* @property {(enabled: boolean) => Promise<any>} togglePanelExpose
|
|
54
|
+
* @property {() => Promise<any>} startAgentPanel
|
|
55
|
+
* @property {() => Promise<any>} stopAgentPanel
|
|
56
|
+
*
|
|
57
|
+
* Lifecycle:
|
|
58
|
+
* @property {() => Promise<any>} uninstallAgent
|
|
59
|
+
*
|
|
60
|
+
* Plugins:
|
|
61
|
+
* @property {() => Promise<{plugins: Array}>} getAgentPlugins
|
|
62
|
+
* @property {(packageName: string) => Promise<{ok: boolean, plugin: Object}>} installAgentPlugin
|
|
63
|
+
* @property {(name: string) => Promise<any>} enableAgentPlugin
|
|
64
|
+
* @property {(name: string) => Promise<any>} disableAgentPlugin
|
|
65
|
+
* @property {(name: string) => Promise<any>} uninstallAgentPlugin
|
|
66
|
+
* @property {(name: string) => Promise<any>} updateAgentPlugin
|
|
67
|
+
* @property {(name: string) => Promise<{type: 'source', source: string}>} fetchAgentPluginBundle
|
|
68
|
+
* @property {(name: string) => Promise<{name: string, currentVersion: string, latestVersion: string, hasUpdate: boolean}>} checkAgentPluginUpdate
|
|
69
|
+
*
|
|
70
|
+
* External links (host-specific):
|
|
71
|
+
* @property {(url: string) => Promise<void>} openExternal
|
|
72
|
+
*/
|
|
73
|
+
export function useAgentClient() {
|
|
74
|
+
const ctx = useContext(AgentClientContext);
|
|
75
|
+
if (!ctx) throw new Error('useAgentClient must be used within an AgentClientProvider');
|
|
76
|
+
return ctx;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Wrap your agent pages with this provider, supplying the host-specific client.
|
|
81
|
+
*
|
|
82
|
+
* @param {{ client: AgentClient, children: React.ReactNode }} props
|
|
83
|
+
*/
|
|
84
|
+
export function AgentClientProvider({ client, children }) {
|
|
85
|
+
return <AgentClientContext.Provider value={client}>{children}</AgentClientContext.Provider>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export default AgentClientContext;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Context
|
|
2
|
+
export { AgentClientProvider, useAgentClient } from './context/AgentClientContext.jsx';
|
|
3
|
+
|
|
4
|
+
// Components
|
|
5
|
+
export { ToastProvider, useToast } from './components/Toast.jsx';
|
|
6
|
+
|
|
7
|
+
// Pages
|
|
8
|
+
export { default as AgentDashboardPage } from './pages/Dashboard.jsx';
|
|
9
|
+
export { default as AgentTunnelsPage } from './pages/Tunnels.jsx';
|
|
10
|
+
export { default as AgentServicesPage } from './pages/Services.jsx';
|
|
11
|
+
export { default as AgentLogsPage } from './pages/Logs.jsx';
|
|
12
|
+
export { default as AgentSettingsPage } from './pages/Settings.jsx';
|
|
13
|
+
export { default as AgentPluginsPage, AgentPluginPanel } from './pages/Plugins.jsx';
|
|
14
|
+
|
|
15
|
+
// Web client
|
|
16
|
+
export { createWebAgentClient } from './lib/web-agent-client.js';
|
|
17
|
+
|
|
18
|
+
// Utilities
|
|
19
|
+
export { cn } from './lib/cn.js';
|
package/src/lib/cn.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract an error message from any thrown value.
|
|
3
|
+
* Tauri `invoke()` rejects with a plain string, while `fetch`-based clients
|
|
4
|
+
* reject with an Error object. This normalises both.
|
|
5
|
+
*/
|
|
6
|
+
export function errorMessage(err) {
|
|
7
|
+
if (typeof err === 'string') return err || 'An unknown error occurred';
|
|
8
|
+
if (err instanceof Error) return err.message || 'An unknown error occurred';
|
|
9
|
+
return String(err ?? 'An unknown error occurred') || 'An unknown error occurred';
|
|
10
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web-backed AgentClient implementation.
|
|
3
|
+
*
|
|
4
|
+
* Used when the agent panel is accessed via agent-<label>.<domain>.
|
|
5
|
+
* All calls go through the local panel HTTP server's REST API.
|
|
6
|
+
*
|
|
7
|
+
* @returns {import('../context/AgentClientContext.jsx').AgentClient}
|
|
8
|
+
*/
|
|
9
|
+
export function createWebAgentClient() {
|
|
10
|
+
async function apiFetch(path, opts = {}) {
|
|
11
|
+
const { body, method = 'GET', ...rest } = opts;
|
|
12
|
+
const headers = { ...rest.headers };
|
|
13
|
+
if (body) {
|
|
14
|
+
headers['Content-Type'] = 'application/json';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const res = await fetch(`/api${path}`, {
|
|
18
|
+
method,
|
|
19
|
+
headers,
|
|
20
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// No-content responses (204)
|
|
24
|
+
if (res.status === 204) return undefined;
|
|
25
|
+
|
|
26
|
+
const data = await res.json().catch(() => ({}));
|
|
27
|
+
if (!res.ok) {
|
|
28
|
+
throw new Error(data.error || `HTTP ${res.status}`);
|
|
29
|
+
}
|
|
30
|
+
return data;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
// Status & Control
|
|
35
|
+
getStatus: () => apiFetch('/status'),
|
|
36
|
+
startAgent: () => apiFetch('/start', { method: 'POST' }),
|
|
37
|
+
stopAgent: () => apiFetch('/stop', { method: 'POST' }),
|
|
38
|
+
restartAgent: () => apiFetch('/restart', { method: 'POST' }),
|
|
39
|
+
updateAgent: () => apiFetch('/update', { method: 'POST' }),
|
|
40
|
+
|
|
41
|
+
// Tunnels
|
|
42
|
+
getTunnels: () => apiFetch('/tunnels').then((r) => r.tunnels || []),
|
|
43
|
+
createTunnel: (data) => apiFetch('/tunnels', { method: 'POST', body: data }),
|
|
44
|
+
toggleTunnel: (id, data) => apiFetch(`/tunnels/${id}`, { method: 'PATCH', body: data }),
|
|
45
|
+
deleteTunnel: (id) => apiFetch(`/tunnels/${id}`, { method: 'DELETE' }),
|
|
46
|
+
|
|
47
|
+
// Services — /services/scan is a live scan (process detection + docker
|
|
48
|
+
// inspection), /services/registry returns the persisted catalog. Custom
|
|
49
|
+
// services are created/removed under /services/custom.
|
|
50
|
+
scanServices: () => apiFetch('/services/scan'),
|
|
51
|
+
getServiceRegistry: () => apiFetch('/services/registry'),
|
|
52
|
+
addCustomService: (data) => apiFetch('/services/custom', { method: 'POST', body: data }),
|
|
53
|
+
removeCustomService: (id) =>
|
|
54
|
+
apiFetch(`/services/custom/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
|
55
|
+
|
|
56
|
+
// Logs
|
|
57
|
+
getLogs: () => apiFetch('/logs').then((r) => r.logs || ''),
|
|
58
|
+
|
|
59
|
+
// Configuration
|
|
60
|
+
getConfig: () => apiFetch('/config'),
|
|
61
|
+
getPanelUrl: async () => window.location.origin,
|
|
62
|
+
|
|
63
|
+
// Certificate
|
|
64
|
+
rotateCertificate: () => apiFetch('/certificate/rotate', { method: 'POST' }),
|
|
65
|
+
downloadCertificate: () => apiFetch('/certificate/download'),
|
|
66
|
+
|
|
67
|
+
// Web Panel
|
|
68
|
+
getPanelExposeStatus: () => apiFetch('/panel-expose-status'),
|
|
69
|
+
togglePanelExpose: (enabled) =>
|
|
70
|
+
apiFetch(enabled ? '/panel-expose' : '/panel-retract', { method: 'POST' }),
|
|
71
|
+
|
|
72
|
+
// Agent panel service — the panel hosting this web client is, by
|
|
73
|
+
// definition, running. `startAgentPanel` is idempotent and resolves the
|
|
74
|
+
// current expose status so callers can branch on it. `stopAgentPanel` is
|
|
75
|
+
// refused: a web client cannot kill the server it is served from without
|
|
76
|
+
// orphaning its own session — the desktop app remains the control plane
|
|
77
|
+
// for the panel lifecycle.
|
|
78
|
+
startAgentPanel: () => apiFetch('/panel-expose-status'),
|
|
79
|
+
stopAgentPanel: () =>
|
|
80
|
+
Promise.reject(
|
|
81
|
+
new Error(
|
|
82
|
+
'Stopping the agent panel from the web client is not supported — use the desktop app.',
|
|
83
|
+
),
|
|
84
|
+
),
|
|
85
|
+
|
|
86
|
+
// Lifecycle
|
|
87
|
+
uninstallAgent: () => apiFetch('/uninstall', { method: 'POST' }),
|
|
88
|
+
|
|
89
|
+
// Plugins
|
|
90
|
+
getAgentPlugins: () => apiFetch('/plugins'),
|
|
91
|
+
installAgentPlugin: (packageName) =>
|
|
92
|
+
apiFetch('/plugins/install', { method: 'POST', body: { packageName } }),
|
|
93
|
+
enableAgentPlugin: (name) =>
|
|
94
|
+
apiFetch(`/plugins/${encodeURIComponent(name)}/enable`, { method: 'POST' }),
|
|
95
|
+
disableAgentPlugin: (name) =>
|
|
96
|
+
apiFetch(`/plugins/${encodeURIComponent(name)}/disable`, { method: 'POST' }),
|
|
97
|
+
uninstallAgentPlugin: (name) =>
|
|
98
|
+
apiFetch(`/plugins/${encodeURIComponent(name)}`, { method: 'DELETE' }),
|
|
99
|
+
updateAgentPlugin: (name) =>
|
|
100
|
+
apiFetch(`/plugins/${encodeURIComponent(name)}/update`, { method: 'POST' }),
|
|
101
|
+
fetchAgentPluginBundle: (name) =>
|
|
102
|
+
apiFetch(`/plugins/${encodeURIComponent(name)}/bundle`).then((r) => ({
|
|
103
|
+
type: 'source',
|
|
104
|
+
source: r.source,
|
|
105
|
+
})),
|
|
106
|
+
checkAgentPluginUpdate: (name) => apiFetch(`/plugins/${encodeURIComponent(name)}/check-update`),
|
|
107
|
+
|
|
108
|
+
// External links — opens in new tab in web context
|
|
109
|
+
openExternal: (url) => {
|
|
110
|
+
window.open(url, '_blank', 'noopener,noreferrer');
|
|
111
|
+
return Promise.resolve();
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { Activity, HardDrive, RefreshCw, Loader2, Play, Square, Download } from 'lucide-react';
|
|
2
|
+
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import { useAgentClient } from '../context/AgentClientContext.jsx';
|
|
4
|
+
|
|
5
|
+
export default function Dashboard() {
|
|
6
|
+
const client = useAgentClient();
|
|
7
|
+
const queryClient = useQueryClient();
|
|
8
|
+
|
|
9
|
+
const invalidateAll = () => {
|
|
10
|
+
queryClient.invalidateQueries({ queryKey: ['agent', 'status'] });
|
|
11
|
+
queryClient.invalidateQueries({ queryKey: ['agent', 'tunnels'] });
|
|
12
|
+
queryClient.invalidateQueries({ queryKey: ['agents'] });
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const statusQuery = useQuery({
|
|
16
|
+
queryKey: ['agent', 'status'],
|
|
17
|
+
queryFn: () => client.getStatus(),
|
|
18
|
+
refetchInterval: 3000,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const stopMutation = useMutation({
|
|
22
|
+
mutationFn: () => client.stopAgent(),
|
|
23
|
+
onSuccess: invalidateAll,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const startMutation = useMutation({
|
|
27
|
+
mutationFn: () => client.startAgent(),
|
|
28
|
+
onSuccess: invalidateAll,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const restartMutation = useMutation({
|
|
32
|
+
mutationFn: () => client.restartAgent(),
|
|
33
|
+
onSuccess: invalidateAll,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const updateMutation = useMutation({
|
|
37
|
+
mutationFn: () => client.updateAgent(),
|
|
38
|
+
onSuccess: invalidateAll,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const anyPending =
|
|
42
|
+
stopMutation.isPending ||
|
|
43
|
+
startMutation.isPending ||
|
|
44
|
+
restartMutation.isPending ||
|
|
45
|
+
updateMutation.isPending;
|
|
46
|
+
|
|
47
|
+
const lastResult = updateMutation.isSuccess
|
|
48
|
+
? { type: 'success', msg: updateMutation.data }
|
|
49
|
+
: updateMutation.isError
|
|
50
|
+
? { type: 'error', msg: updateMutation.error?.message || 'Update failed' }
|
|
51
|
+
: stopMutation.isSuccess
|
|
52
|
+
? { type: 'success', msg: stopMutation.data }
|
|
53
|
+
: stopMutation.isError
|
|
54
|
+
? { type: 'error', msg: stopMutation.error?.message || 'Stop failed' }
|
|
55
|
+
: startMutation.isSuccess
|
|
56
|
+
? { type: 'success', msg: startMutation.data }
|
|
57
|
+
: startMutation.isError
|
|
58
|
+
? { type: 'error', msg: startMutation.error?.message || 'Start failed' }
|
|
59
|
+
: restartMutation.isSuccess
|
|
60
|
+
? { type: 'success', msg: restartMutation.data }
|
|
61
|
+
: restartMutation.isError
|
|
62
|
+
? { type: 'error', msg: restartMutation.error?.message || 'Restart failed' }
|
|
63
|
+
: null;
|
|
64
|
+
|
|
65
|
+
const chisel = statusQuery.data;
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div className="p-6">
|
|
69
|
+
<h1 className="text-lg font-bold text-white mb-6">Dashboard</h1>
|
|
70
|
+
|
|
71
|
+
<div className="grid grid-cols-2 gap-4 mb-6">
|
|
72
|
+
{/* Connection Status */}
|
|
73
|
+
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-5">
|
|
74
|
+
<div className="flex items-center gap-2 mb-3">
|
|
75
|
+
<Activity size={14} className="text-cyan-400" />
|
|
76
|
+
<span className="text-xs font-medium uppercase text-zinc-400">Connection</span>
|
|
77
|
+
</div>
|
|
78
|
+
<div className="flex items-center gap-2 mb-2">
|
|
79
|
+
<span
|
|
80
|
+
className={`h-3 w-3 rounded-full ${chisel?.running ? 'bg-green-400' : 'bg-red-400'}`}
|
|
81
|
+
/>
|
|
82
|
+
<span className="text-white font-semibold">
|
|
83
|
+
{chisel?.running ? 'Connected' : 'Disconnected'}
|
|
84
|
+
</span>
|
|
85
|
+
</div>
|
|
86
|
+
{chisel?.pid && <p className="text-xs text-zinc-500">PID: {chisel.pid}</p>}
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
{/* Chisel Info */}
|
|
90
|
+
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-5">
|
|
91
|
+
<div className="flex items-center gap-2 mb-3">
|
|
92
|
+
<HardDrive size={14} className="text-cyan-400" />
|
|
93
|
+
<span className="text-xs font-medium uppercase text-zinc-400">Chisel</span>
|
|
94
|
+
</div>
|
|
95
|
+
<p className="text-white font-semibold mb-1">
|
|
96
|
+
{chisel?.installed ? `v${chisel.chiselVersion || '?'}` : 'Not installed'}
|
|
97
|
+
</p>
|
|
98
|
+
<p className="text-xs text-zinc-500">
|
|
99
|
+
{chisel?.installed ? 'Installed' : 'Run lamaste-agent setup'}
|
|
100
|
+
</p>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
{/* Chisel Controls */}
|
|
105
|
+
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-5 mb-4">
|
|
106
|
+
<h2 className="text-xs font-medium uppercase text-zinc-400 mb-3">Chisel Controls</h2>
|
|
107
|
+
<div className="flex gap-3">
|
|
108
|
+
<button
|
|
109
|
+
onClick={() => stopMutation.mutate()}
|
|
110
|
+
disabled={anyPending || !chisel?.running}
|
|
111
|
+
className="flex items-center gap-2 rounded bg-zinc-800 border border-zinc-700 px-4 py-2 text-sm text-zinc-300 hover:bg-zinc-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
112
|
+
>
|
|
113
|
+
{stopMutation.isPending ? (
|
|
114
|
+
<Loader2 size={14} className="animate-spin" />
|
|
115
|
+
) : (
|
|
116
|
+
<Square size={14} />
|
|
117
|
+
)}
|
|
118
|
+
Stop
|
|
119
|
+
</button>
|
|
120
|
+
<button
|
|
121
|
+
onClick={() => startMutation.mutate()}
|
|
122
|
+
disabled={anyPending || chisel?.running}
|
|
123
|
+
className="flex items-center gap-2 rounded bg-zinc-800 border border-zinc-700 px-4 py-2 text-sm text-zinc-300 hover:bg-zinc-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
124
|
+
>
|
|
125
|
+
{startMutation.isPending ? (
|
|
126
|
+
<Loader2 size={14} className="animate-spin" />
|
|
127
|
+
) : (
|
|
128
|
+
<Play size={14} />
|
|
129
|
+
)}
|
|
130
|
+
Start
|
|
131
|
+
</button>
|
|
132
|
+
<button
|
|
133
|
+
onClick={() => restartMutation.mutate()}
|
|
134
|
+
disabled={anyPending || !chisel?.installed}
|
|
135
|
+
className="flex items-center gap-2 rounded bg-zinc-800 border border-zinc-700 px-4 py-2 text-sm text-zinc-300 hover:bg-zinc-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
136
|
+
>
|
|
137
|
+
{restartMutation.isPending ? (
|
|
138
|
+
<Loader2 size={14} className="animate-spin" />
|
|
139
|
+
) : (
|
|
140
|
+
<RefreshCw size={14} />
|
|
141
|
+
)}
|
|
142
|
+
Restart
|
|
143
|
+
</button>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
{/* Update Agent */}
|
|
148
|
+
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-5">
|
|
149
|
+
<h2 className="text-xs font-medium uppercase text-zinc-400 mb-3">Agent</h2>
|
|
150
|
+
<button
|
|
151
|
+
onClick={() => updateMutation.mutate()}
|
|
152
|
+
disabled={anyPending}
|
|
153
|
+
className="flex items-center gap-2 rounded bg-cyan-600 hover:bg-cyan-500 px-4 py-2 text-sm text-white disabled:opacity-50 disabled:cursor-not-allowed"
|
|
154
|
+
>
|
|
155
|
+
{updateMutation.isPending ? (
|
|
156
|
+
<Loader2 size={14} className="animate-spin" />
|
|
157
|
+
) : (
|
|
158
|
+
<Download size={14} />
|
|
159
|
+
)}
|
|
160
|
+
Update Agent
|
|
161
|
+
</button>
|
|
162
|
+
<p className="text-xs text-zinc-500 mt-2">
|
|
163
|
+
Re-fetch tunnel configuration from the panel and reload Chisel.
|
|
164
|
+
</p>
|
|
165
|
+
{lastResult && (
|
|
166
|
+
<p
|
|
167
|
+
className={`text-xs mt-2 ${lastResult.type === 'success' ? 'text-green-400' : 'text-red-400'}`}
|
|
168
|
+
>
|
|
169
|
+
{lastResult.msg}
|
|
170
|
+
</p>
|
|
171
|
+
)}
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { useQuery } from '@tanstack/react-query';
|
|
2
|
+
import { useEffect, useRef } from 'react';
|
|
3
|
+
import { ScrollText, RefreshCw } from 'lucide-react';
|
|
4
|
+
import { useAgentClient } from '../context/AgentClientContext.jsx';
|
|
5
|
+
|
|
6
|
+
export default function Logs() {
|
|
7
|
+
const client = useAgentClient();
|
|
8
|
+
const logsRef = useRef(null);
|
|
9
|
+
|
|
10
|
+
const logsQuery = useQuery({
|
|
11
|
+
queryKey: ['agent', 'logs'],
|
|
12
|
+
queryFn: () => client.getLogs(),
|
|
13
|
+
refetchInterval: 3000,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const logs = logsQuery.data || '';
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
if (logsRef.current) {
|
|
20
|
+
logsRef.current.scrollTop = logsRef.current.scrollHeight;
|
|
21
|
+
}
|
|
22
|
+
}, [logs]);
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div className="p-6 h-full flex flex-col">
|
|
26
|
+
<div className="flex items-center justify-between mb-4">
|
|
27
|
+
<h1 className="text-lg font-bold text-white">Logs</h1>
|
|
28
|
+
<button
|
|
29
|
+
onClick={() => logsQuery.refetch()}
|
|
30
|
+
className="flex items-center gap-1.5 text-xs text-zinc-400 hover:text-zinc-200"
|
|
31
|
+
>
|
|
32
|
+
<RefreshCw size={12} />
|
|
33
|
+
Refresh
|
|
34
|
+
</button>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
<div
|
|
38
|
+
ref={logsRef}
|
|
39
|
+
className="flex-1 min-h-0 bg-zinc-900 border border-zinc-800 rounded-lg p-4 overflow-y-auto"
|
|
40
|
+
>
|
|
41
|
+
{logs ? (
|
|
42
|
+
<pre className="text-xs text-zinc-300 whitespace-pre-wrap break-all leading-relaxed">
|
|
43
|
+
{logs}
|
|
44
|
+
</pre>
|
|
45
|
+
) : (
|
|
46
|
+
<div className="flex flex-col items-center justify-center h-full text-zinc-600">
|
|
47
|
+
<ScrollText size={24} className="mb-2" />
|
|
48
|
+
<p className="text-xs">No logs available</p>
|
|
49
|
+
</div>
|
|
50
|
+
)}
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|