@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.
@@ -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,6 @@
1
+ import { clsx } from 'clsx';
2
+ import { twMerge } from 'tailwind-merge';
3
+
4
+ export function cn(...inputs) {
5
+ return twMerge(clsx(inputs));
6
+ }
@@ -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
+ }