diff --git a/gui/server/routes/claws.js b/gui/server/routes/claws.js new file mode 100644 index 000000000..d85ff342b --- /dev/null +++ b/gui/server/routes/claws.js @@ -0,0 +1,178 @@ +// NemoClaw — Claw Instance REST Routes +// Provides endpoints for claw CRUD, monitoring, and lifecycle management. + +import { Router } from 'express'; +import { spawn, execSync } from 'child_process'; +import { join } from 'path'; +import { + listClaws, + getClaw, + registerClaw, + updateClaw, + removeClaw, + touchClaw, + syncWithOpenShell, + getGateways, +} from '../services/clawManager.js'; + +const router = Router(); +const NEMOCLAW_ROOT = process.env.NEMOCLAW_ROOT || join(new URL('.', import.meta.url).pathname, '..', '..', '..'); + +router.get('/api/claws', (req, res) => { + try { + const claws = listClaws(); + res.json({ ok: true, claws }); + } catch (err) { + res.status(500).json({ ok: false, error: err.message, claws: [] }); + } +}); + +router.get('/api/claws/:id', (req, res) => { + try { + const claw = getClaw(req.params.id); + if (!claw) return res.status(404).json({ ok: false, error: `Claw '${req.params.id}' not found` }); + res.json({ ok: true, claw }); + } catch (err) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +router.get('/api/claws/:id/status', (req, res) => { + try { + const claw = getClaw(req.params.id); + if (!claw) return res.status(404).json({ ok: false, error: `Claw '${req.params.id}' not found` }); + res.json({ ok: true, id: claw.id, status: claw.status, sandboxStatus: claw.sandboxStatus, gatewayName: claw.gatewayName, lastConnected: claw.lastConnected }); + } catch (err) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +router.post('/api/claws', (req, res) => { + const { name, gatewayName, provider, model, apiKey, endpoint } = req.body; + if (!name || !/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(name)) { + return res.status(400).json({ ok: false, error: 'Invalid claw name.' }); + } + res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no' }); + res.flushHeaders(); + let clientDisconnected = false; + const sendEvent = (data) => { if (!clientDisconnected) try { res.write(`data: ${JSON.stringify(data)}\n\n`); } catch {} }; + try { + registerClaw({ id: name, sandboxName: name, gatewayName: gatewayName || 'nemoclaw', config: { provider: provider || 'cloud', model: model || '', endpointUrl: endpoint || '' } }); + sendEvent({ step: 'register', status: 'complete', message: `Claw '${name}' registered` }); + } catch (err) { + sendEvent({ step: 'register', status: 'error', message: err.message }); + sendEvent({ done: true, success: false }); + res.end(); + return; + } + const providerMapping = { 'cloud': 'cloud', 'ollama': 'ollama', 'openrouter': 'cloud', 'gemini': 'cloud', 'vllm': 'vllm', 'nim-local': 'nim' }; + const apiKeyMapping = { 'cloud': 'NVIDIA_API_KEY', 'ollama': 'OLLAMA_API_KEY', 'openrouter': 'OPENROUTER_API_KEY', 'gemini': 'GEMINI_API_KEY', 'vllm': 'OPENAI_API_KEY', 'nim-local': 'NIM_API_KEY' }; + const cliProvider = providerMapping[provider] || 'cloud'; + const apiKeyEnvVar = apiKeyMapping[provider] || 'NVIDIA_API_KEY'; + const cliEnv = { ...process.env, NEMOCLAW_NON_INTERACTIVE: '1', NEMOCLAW_SANDBOX_NAME: name, NEMOCLAW_PROVIDER: cliProvider }; + if (model) cliEnv.NEMOCLAW_MODEL = model; + if (apiKey) { cliEnv[apiKeyEnvVar] = apiKey; if (cliProvider === 'cloud' && apiKeyEnvVar !== 'NVIDIA_API_KEY' && !cliEnv.NVIDIA_API_KEY) cliEnv.NVIDIA_API_KEY = apiKey; } + sendEvent({ step: 'deploy', status: 'running', message: 'Starting deployment...' }); + let currentCliStep = '', lastStepSent = '', cliFinished = false; + function parseCliLine(rawLine) { + const line = rawLine.replace(/\x1b\[[0-9;]*m/g, '').trim(); + if (!line) return; + const stepMatch = line.match(/^\[(\d+)\/7\]\s*(.+)/); + if (stepMatch) { + const stepNum = parseInt(stepMatch[1], 10); + const stepNames = ['preflight', 'gateway', 'sandbox', 'nim', 'inference', 'openclaw', 'policy']; + currentCliStep = stepNames[stepNum - 1] || `step-${stepNum}`; + if (lastStepSent && lastStepSent !== currentCliStep) sendEvent({ step: lastStepSent, status: 'complete', message: `${lastStepSent} complete` }); + lastStepSent = currentCliStep; + sendEvent({ step: currentCliStep, status: 'running', message: stepMatch[2] }); + return; + } + if (line.includes('✓')) { if (currentCliStep) sendEvent({ step: currentCliStep, status: 'complete', message: line.replace(/^✓\s*/, '') }); return; } + if (line.startsWith('!!') || line.includes('Failed') || line.includes('Error')) { if (currentCliStep) sendEvent({ step: currentCliStep, status: 'error', message: line }); return; } + if (/Creating|Waiting|Building|Pulling|Starting|Configuring/.test(line)) { if (currentCliStep) sendEvent({ step: currentCliStep, status: 'running', message: line }); } + } + const cliCmd = join(NEMOCLAW_ROOT, 'bin', 'nemoclaw.js'); + const cliProcess = spawn('node', [cliCmd, 'onboard', '--non-interactive'], { cwd: NEMOCLAW_ROOT, env: cliEnv, stdio: ['ignore', 'pipe', 'pipe'] }); + let buffer = '', errBuffer = ''; + cliProcess.stdout.on('data', (data) => { buffer += data.toString(); const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines) parseCliLine(line); }); + cliProcess.stderr.on('data', (data) => { errBuffer += data.toString(); const lines = errBuffer.split('\n'); errBuffer = lines.pop() || ''; for (const line of lines) parseCliLine(line); }); + cliProcess.on('error', (err) => { try { removeClaw(name); } catch {} sendEvent({ step: 'deploy', status: 'error', message: `Failed: ${err.message}` }); sendEvent({ done: true, success: false }); res.end(); }); + cliProcess.on('close', (code) => { + cliFinished = true; + if (buffer.trim()) parseCliLine(buffer); + if (errBuffer.trim()) parseCliLine(errBuffer); + const success = code === 0; + if (success) { if (lastStepSent) sendEvent({ step: lastStepSent, status: 'complete', message: `${lastStepSent} complete` }); try { updateClaw(name, { status: 'running' }); } catch {} sendEvent({ step: 'complete', status: 'complete', message: `Claw '${name}' deployed` }); } + else { try { updateClaw(name, { status: 'error' }); } catch {} sendEvent({ step: 'deploy', status: 'error', message: `Failed (exit ${code})` }); } + sendEvent({ done: true, success, clawId: name }); + res.end(); + }); + res.on('close', () => { clientDisconnected = true; if (cliProcess && !cliProcess.killed && !cliFinished) cliProcess.kill('SIGTERM'); }); +}); + +router.post('/api/claws/:id/reconnect', (req, res) => { + try { + const claw = getClaw(req.params.id); + if (!claw) return res.status(404).json({ ok: false, error: `Claw '${req.params.id}' not found` }); + touchClaw(req.params.id); + res.json({ ok: true, claw: getClaw(req.params.id), connectCmd: `openshell sandbox connect ${claw.sandboxName}` }); + } catch (err) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +router.put('/api/claws/:id/config', (req, res) => { + try { + const claw = getClaw(req.params.id); + if (!claw) return res.status(404).json({ ok: false, error: `Claw '${req.params.id}' not found` }); + const updated = updateClaw(req.params.id, { config: req.body }); + res.json({ ok: true, claw: updated }); + } catch (err) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +router.delete('/api/claws/:id', (req, res) => { + const { id } = req.params; + const preserveSandbox = req.query.preserveSandbox === 'true'; + try { + const claw = getClaw(id); + if (!claw) return res.status(404).json({ ok: false, error: `Claw '${id}' not found` }); + if (!preserveSandbox) { try { execSync(`openshell sandbox delete "${claw.sandboxName}" 2>/dev/null || true`, { encoding: 'utf-8', timeout: 30000 }); } catch {} } + removeClaw(id); + res.json({ ok: true, message: `Claw '${id}' destroyed${preserveSandbox ? ' (sandbox preserved)' : ''}` }); + } catch (err) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +router.get('/api/claws/:id/logs', (req, res) => { + const claw = getClaw(req.params.id); + if (!claw) return res.status(404).json({ ok: false, error: `Claw '${req.params.id}' not found` }); + res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' }); + const proc = spawn('openshell', ['logs', claw.sandboxName, '--tail'], { env: process.env }); + proc.stdout.on('data', (data) => { for (const line of data.toString().split('\n')) { if (line.trim()) res.write(`data: ${JSON.stringify({ line: line.trim() })}\n\n`); } }); + proc.stderr.on('data', (data) => { res.write(`data: ${JSON.stringify({ error: data.toString().trim() })}\n\n`); }); + proc.on('close', () => { res.write(`data: ${JSON.stringify({ done: true })}\n\n`); res.end(); }); + req.on('close', () => { proc.kill(); }); +}); + +router.post('/api/claws/sync', (req, res) => { + try { + const claws = syncWithOpenShell(); + res.json({ ok: true, claws }); + } catch (err) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +router.get('/api/claws/gateways', (req, res) => { + try { + const gateways = getGateways(); + res.json({ ok: true, gateways }); + } catch (err) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +export default router; diff --git a/gui/server/services/clawManager.js b/gui/server/services/clawManager.js new file mode 100644 index 000000000..602b0d51d --- /dev/null +++ b/gui/server/services/clawManager.js @@ -0,0 +1,260 @@ +// NemoClaw — Claw Instance Manager Service +// Provides CRUD operations for claw instances, persisted to ~/.nemoclaw/claws.json. +// Each claw maps to one OpenShell sandbox and tracks its own config + metadata. + +import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; +import { execSync } from 'child_process'; + +const NEMOCLAW_DIR = join(homedir(), '.nemoclaw'); +const CLAWS_FILE = join(NEMOCLAW_DIR, 'claws.json'); + +// ── Persistence ──────────────────────────────────────────────── + +function ensureDir() { + if (!existsSync(NEMOCLAW_DIR)) { + mkdirSync(NEMOCLAW_DIR, { recursive: true }); + } +} + +function loadClaws() { + ensureDir(); + if (!existsSync(CLAWS_FILE)) { + return []; + } + try { + return JSON.parse(readFileSync(CLAWS_FILE, 'utf-8')); + } catch { + return []; + } +} + +function saveClaws(claws) { + ensureDir(); + writeFileSync(CLAWS_FILE, JSON.stringify(claws, null, 2)); +} + +// ── OpenShell Integration ────────────────────────────────────── + +function runCli(cmd, opts = {}) { + try { + const output = execSync(cmd, { + encoding: 'utf-8', + timeout: opts.timeout || 15000, + env: { ...process.env }, + }); + return { ok: true, output: output.trim() }; + } catch (err) { + return { + ok: false, + output: (err.stdout || '') + (err.stderr || ''), + code: err.status, + }; + } +} + +/** Parse `openshell sandbox list` output into structured objects */ +function parseSandboxList(output) { + const lines = output.split('\n').filter(l => l.trim()); + const sandboxes = []; + for (const line of lines) { + const clean = line.replace(/\x1b\[[0-9;]*m/g, '').trim(); + const cols = clean.split(/\s+/); + if (cols.length >= 2 && !clean.startsWith('NAME') && !clean.startsWith('─')) { + sandboxes.push({ + name: cols[0], + image: cols[1] || '', + created: cols[2] || '', + status: cols[cols.length - 1] || 'Unknown', + }); + } + } + return sandboxes; +} + +/** Get live sandbox statuses from openshell */ +function getLiveSandboxes() { + const result = runCli('openshell sandbox list 2>/dev/null'); + if (!result.ok) return []; + return parseSandboxList(result.output); +} + +/** Get detailed info for a single sandbox */ +function getSandboxDetail(name) { + const result = runCli(`openshell sandbox get "${name}" 2>/dev/null`); + return { ok: result.ok, output: result.output }; +} + +/** List registered gateways */ +function listGateways() { + const result = runCli('openshell gateway select 2>/dev/null'); + if (!result.ok) return []; + const lines = result.output.split('\n').filter(l => l.trim()); + const gateways = []; + for (const line of lines) { + const clean = line.replace(/\x1b\[[0-9;]*m/g, '').trim(); + if (!clean || clean.startsWith('NAME') || clean.startsWith('─')) continue; + const isActive = clean.includes('*') || clean.includes('→'); + const name = clean.replace(/[*→]/g, '').split(/\s+/)[0]; + if (name) { + gateways.push({ name, active: isActive }); + } + } + return gateways; +} + +// ── CRUD Operations ──────────────────────────────────────────── + +/** List all claws with live status cross-referenced from openshell */ +export function listClaws() { + const claws = loadClaws(); + const liveSandboxes = getLiveSandboxes(); + const liveMap = new Map(liveSandboxes.map(s => [s.name, s])); + + return claws.map(claw => { + const live = liveMap.get(claw.sandboxName); + return { + ...claw, + sandboxStatus: live ? live.status : 'not-found', + status: live + ? mapSandboxStatus(live.status) + : 'stopped', + }; + }); +} + +/** Get a single claw by ID with enriched status */ +export function getClaw(id) { + const claws = loadClaws(); + const claw = claws.find(c => c.id === id); + if (!claw) return null; + + const liveSandboxes = getLiveSandboxes(); + const live = liveSandboxes.find(s => s.name === claw.sandboxName); + const detail = getSandboxDetail(claw.sandboxName); + + return { + ...claw, + sandboxStatus: live ? live.status : 'not-found', + status: live ? mapSandboxStatus(live.status) : 'stopped', + detail: detail.ok ? detail.output : null, + }; +} + +/** Register a new claw instance */ +export function registerClaw({ id, sandboxName, gatewayName, config }) { + const claws = loadClaws(); + + // Don't duplicate + if (claws.find(c => c.id === id)) { + throw new Error(`Claw '${id}' already exists`); + } + + const claw = { + id, + sandboxName: sandboxName || id, + gatewayName: gatewayName || 'nemoclaw', + createdAt: new Date().toISOString(), + lastConnected: null, + config: config || {}, + status: 'creating', + }; + + claws.push(claw); + saveClaws(claws); + return claw; +} + +/** Update an existing claw's metadata or config */ +export function updateClaw(id, updates) { + const claws = loadClaws(); + const idx = claws.findIndex(c => c.id === id); + if (idx === -1) { + throw new Error(`Claw '${id}' not found`); + } + + // Merge updates + if (updates.config) { + claws[idx].config = { ...claws[idx].config, ...updates.config }; + delete updates.config; + } + Object.assign(claws[idx], updates); + saveClaws(claws); + return claws[idx]; +} + +/** Remove a claw from the registry */ +export function removeClaw(id) { + const claws = loadClaws(); + const idx = claws.findIndex(c => c.id === id); + if (idx === -1) { + throw new Error(`Claw '${id}' not found`); + } + const removed = claws.splice(idx, 1)[0]; + saveClaws(claws); + return removed; +} + +/** Mark a claw as recently connected */ +export function touchClaw(id) { + return updateClaw(id, { lastConnected: new Date().toISOString() }); +} + +/** + * Sync the registry with actual OpenShell sandbox list. + * Discovers new sandboxes not in the registry (orphans) and + * marks registry entries whose sandbox no longer exists. + */ +export function syncWithOpenShell() { + const claws = loadClaws(); + const liveSandboxes = getLiveSandboxes(); + const registeredNames = new Set(claws.map(c => c.sandboxName)); + + // Discover orphaned sandboxes (exist in OpenShell but not in claw registry) + const orphans = liveSandboxes + .filter(s => !registeredNames.has(s.name)) + .map(s => ({ + id: s.name, + sandboxName: s.name, + gatewayName: 'nemoclaw', + createdAt: s.created || new Date().toISOString(), + lastConnected: null, + config: {}, + status: mapSandboxStatus(s.status), + discovered: true, + })); + + // Auto-register orphans + if (orphans.length > 0) { + claws.push(...orphans); + saveClaws(claws); + } + + // Return enriched list + const liveMap = new Map(liveSandboxes.map(s => [s.name, s])); + return claws.map(claw => { + const live = liveMap.get(claw.sandboxName); + return { + ...claw, + sandboxStatus: live ? live.status : 'not-found', + status: live ? mapSandboxStatus(live.status) : 'stopped', + }; + }); +} + +/** Get available gateways */ +export function getGateways() { + return listGateways(); +} + +// ── Helpers ──────────────────────────────────────────────────── + +function mapSandboxStatus(sandboxStatus) { + const s = (sandboxStatus || '').toLowerCase(); + if (s === 'ready' || s === 'running') return 'running'; + if (s === 'notready' || s === 'pending' || s === 'creating') return 'creating'; + if (s === 'terminating') return 'stopped'; + if (s === 'error' || s === 'failed' || s === 'crashloopbackoff') return 'error'; + return 'unknown'; +} diff --git a/gui/src/App.tsx b/gui/src/App.tsx index b3e6da2de..1c42beba9 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { BrowserRouter, Routes, Route, NavLink } from 'react-router-dom'; +import { BrowserRouter, Routes, Route, NavLink, useNavigate, useParams } from 'react-router-dom'; import { DashboardPage } from './components/dashboard/DashboardPage'; import { OnboardWizard } from './components/onboard/OnboardWizard'; import { SandboxManager } from './components/sandbox/SandboxManager'; @@ -8,6 +8,24 @@ import { InferenceConfig } from './components/inference/InferenceConfig'; import { PortManager } from './components/ports/PortManager'; import { LogViewer } from './components/logs/LogViewer'; import { ChatInterface } from './components/chat/ChatInterface'; +import { ClawList } from './components/claws/ClawList'; +import { ClawDetail } from './components/claws/ClawDetail'; +import { ClawCreate } from './components/claws/ClawCreate'; + +// Wrapper components to pass navigation to claw pages +function ClawListPage() { + const navigate = useNavigate(); + return navigate(path)} />; +} +function ClawDetailPage() { + const { id } = useParams(); + const navigate = useNavigate(); + return navigate(path)} />; +} +function ClawCreatePage() { + const navigate = useNavigate(); + return navigate(path)} />; +} export default function App() { const [sidebarOpen, setSidebarOpen] = useState(false); @@ -54,6 +72,16 @@ export default function App() { 🚀 Onboard +
Claws
+ `nav-link ${isActive ? 'active' : ''}`} + onClick={closeSidebar}> + 🐾 All Claws + + `nav-link ${isActive ? 'active' : ''}`} + onClick={closeSidebar}> + ✨ New Claw + +
Sandboxes
`nav-link ${isActive ? 'active' : ''}`} onClick={closeSidebar}> @@ -95,6 +123,9 @@ export default function App() { } /> } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/gui/src/api/client.ts b/gui/src/api/client.ts index 9da83f5d8..8099e1637 100644 --- a/gui/src/api/client.ts +++ b/gui/src/api/client.ts @@ -124,8 +124,54 @@ export const api = { method: 'POST', body: JSON.stringify({ endpoint, apiKey }), }), + + // Claws + listClaws: () => request<{ ok: boolean; claws: ClawInstance[] }>('/claws'), + getClaw: (id: string) => request<{ ok: boolean; claw: ClawInstance }>(`/claws/${id}`), + getClawStatus: (id: string) => request<{ ok: boolean; id: string; status: string; sandboxStatus: string; lastConnected: string }>(`/claws/${id}/status`), + reconnectClaw: (id: string) => + request<{ ok: boolean; claw: ClawInstance; connectCmd: string }>(`/claws/${id}/reconnect`, { method: 'POST' }), + updateClawConfig: (id: string, config: Partial) => + request<{ ok: boolean; claw: ClawInstance }>(`/claws/${id}/config`, { + method: 'PUT', + body: JSON.stringify(config), + }), + destroyClaw: (id: string, preserveSandbox = false) => + request<{ ok: boolean; message: string }>(`/claws/${id}?preserveSandbox=${preserveSandbox}`, { method: 'DELETE' }), + syncClaws: () => + request<{ ok: boolean; claws: ClawInstance[] }>('/claws/sync', { method: 'POST' }), + getClawGateways: () => + request<{ ok: boolean; gateways: { name: string; active: boolean }[] }>('/claws/gateways'), }; +export interface ClawInstance { + id: string; + sandboxName: string; + gatewayName: string; + createdAt: string; + lastConnected: string | null; + config: ClawConfig; + status: 'running' | 'stopped' | 'error' | 'creating' | 'unknown'; + sandboxStatus?: string; + detail?: string; + discovered?: boolean; +} + +export interface ClawConfig { + provider?: string; + model?: string; + endpointUrl?: string; +} + +export interface CreateClawRequest { + name: string; + gatewayName?: string; + provider?: string; + model?: string; + apiKey?: string; + endpoint?: string; +} + export interface InferenceConfigData { endpointType?: string; endpointUrl?: string; diff --git a/gui/src/components/claws/ClawCreate.test.tsx b/gui/src/components/claws/ClawCreate.test.tsx new file mode 100644 index 000000000..49dd3e571 --- /dev/null +++ b/gui/src/components/claws/ClawCreate.test.tsx @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ClawCreate } from './ClawCreate'; + +vi.mock('../../api/client', async () => { + const getClawGateways = vi.fn().mockResolvedValue({ ok: true, gateways: [{ name: 'nemoclaw', active: true }] }); + return { api: { getClawGateways } }; +}); + +vi.mock('../../data/providers', () => ({ + PROVIDERS: [ + { key: 'cloud', icon: '☁️', title: 'NVIDIA Cloud API', desc: 'Test', models: [], endpointEditable: false, defaultEndpoint: '', apiKeyEnv: 'NVIDIA_API_KEY', apiKeyPlaceholder: '' }, + { key: 'ollama', icon: '🦙', title: 'Ollama', desc: 'Test', models: [], endpointEditable: true, defaultEndpoint: '', apiKeyEnv: '', apiKeyPlaceholder: '' }, + ], +})); + +vi.mock('../../hooks/useWebSocket', () => ({ + useWebSocket: () => ({ connected: true, sandboxes: [], claws: [], gateway: null, send: vi.fn() }), +})); + +describe('ClawCreate', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('renders the create form header', () => { render(); expect(screen.getAllByText('🐾 New Claw').length).toBeGreaterThan(0); }); + it('shows name input field', () => { render(); expect(screen.getAllByTestId('claw-name-input').length).toBeGreaterThan(0); }); + it('shows provider selection buttons', async () => { render(); await waitFor(() => { expect(screen.getAllByTestId('provider-cloud').length).toBeGreaterThan(0); expect(screen.getAllByTestId('provider-ollama').length).toBeGreaterThan(0); }); }); + it('shows deploy button disabled when name is empty', () => { render(); const btns = screen.getAllByTestId('deploy-claw-btn'); expect(btns[0]).toBeDisabled(); }); + it('enables deploy button when valid name is entered', async () => { const user = userEvent.setup(); render(); const inputs = screen.getAllByTestId('claw-name-input'); await user.type(inputs[0], 'my-claw'); const btns = screen.getAllByTestId('deploy-claw-btn'); expect(btns[0]).not.toBeDisabled(); }); + it('validates name format - rejects invalid chars', async () => { const user = userEvent.setup(); render(); const inputs = screen.getAllByTestId('claw-name-input'); await user.clear(inputs[0]); await user.type(inputs[0], 'MY_CLAW'); const val = (inputs[0] as HTMLInputElement).value; expect(val).not.toContain('_'); expect(val).not.toContain('M'); expect(/^[a-z0-9-]*$/.test(val)).toBe(true); }); + it('shows endpoint field for Ollama provider', async () => { const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getAllByTestId('provider-ollama').length).toBeGreaterThan(0); }); await user.click(screen.getAllByTestId('provider-ollama')[0]); expect(screen.getAllByPlaceholderText(/localhost:11434/).length).toBeGreaterThan(0); }); + it('shows back button', () => { render(); expect(screen.getAllByText('← Back').length).toBeGreaterThan(0); }); +}); diff --git a/gui/src/components/claws/ClawCreate.tsx b/gui/src/components/claws/ClawCreate.tsx new file mode 100644 index 000000000..f5b6a1625 --- /dev/null +++ b/gui/src/components/claws/ClawCreate.tsx @@ -0,0 +1,76 @@ +// ClawCreate — Form to spin up a new claw with gateway & config selection +import { useState, useEffect } from 'react'; +import { api } from '../../api/client'; +import { PROVIDERS as providerDefs } from '../../data/providers'; + +interface Props { onNavigate?: (path: string) => void; } +interface DeployStep { step: string; status: 'pending' | 'running' | 'complete' | 'error'; message: string; } + +export function ClawCreate({ onNavigate }: Props) { + const [name, setName] = useState(''); + const [gateway, setGateway] = useState('nemoclaw'); + const [provider, setProvider] = useState('cloud'); + const [model, setModel] = useState(''); + const [apiKey, setApiKey] = useState(''); + const [endpoint, setEndpoint] = useState(''); + const [gateways, setGateways] = useState<{ name: string; active: boolean }[]>([]); + const [deploying, setDeploying] = useState(false); + const [steps, setSteps] = useState([]); + const [deployResult, setDeployResult] = useState<{ success: boolean; clawId?: string } | null>(null); + const [error, setError] = useState(''); + + useEffect(() => { api.getClawGateways().then(data => { if (data.ok && data.gateways.length > 0) { setGateways(data.gateways); const active = data.gateways.find(g => g.active); if (active) setGateway(active.name); } }).catch(() => {}); }, []); + + const nameValid = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(name) && name.length >= 2; + const providers = providerDefs || []; + + const handleDeploy = () => { + if (!nameValid) { setError('Name must be lowercase, alphanumeric with hyphens, at least 2 chars.'); return; } + setDeploying(true); setSteps([]); setDeployResult(null); setError(''); + const eventSource = new EventSource('/api/claws?' + new URLSearchParams({ _stream: '1' }).toString()); + fetch('/api/claws', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, gatewayName: gateway, provider, model, apiKey, endpoint }) }) + .then(async (res) => { + if (!res.body) { setError('No response body'); setDeploying(false); return; } + const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; + while (true) { + const { done, value } = await reader.read(); if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); buffer = lines.pop() || ''; + for (const line of lines) { + const trimmed = line.trim(); if (!trimmed.startsWith('data: ')) continue; + try { + const data = JSON.parse(trimmed.slice(6)); + if (data.done) { setDeployResult({ success: data.success, clawId: data.clawId }); setDeploying(false); return; } + if (data.step && data.status) { setSteps(prev => { const idx = prev.findIndex(s => s.step === data.step); if (idx >= 0) { const u = [...prev]; u[idx] = { step: data.step, status: data.status, message: data.message || '' }; return u; } return [...prev, { step: data.step, status: data.status, message: data.message || '' }]; }); } + } catch {} + } + } + }).catch(err => { setError(err.message || 'Deployment failed'); setDeploying(false); }); + eventSource.close(); + }; + + const stepIcon = (s: string) => { switch (s) { case 'complete': return '✅'; case 'running': return '⏳'; case 'error': return '❌'; default: return '⬜'; } }; + + return ( +
+

🐾 New Claw

+ {error &&
{error}
} + {deployResult ? ( +
+ {deployResult.success ? (<>
🎉

Claw Created Successfully!

Your claw {deployResult.clawId} is ready.

) : (<>
⚠️

Deployment Failed

Check the steps below for details.

)} +
+ ) : ( +
+
setName(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))} placeholder="e.g. my-assistant" disabled={deploying} />{name && !nameValid &&
Must be 2+ chars, lowercase alphanumeric with hyphens.
}
+
+
{providers.map(p => )}
+
setModel(e.target.value)} placeholder="e.g. nvidia/nemotron-3-super-120b-a12b" disabled={deploying} />
+
setApiKey(e.target.value)} placeholder="Your API key" disabled={deploying} />
+ {['ollama', 'vllm', 'nim-local'].includes(provider) &&
setEndpoint(e.target.value)} placeholder="e.g. http://localhost:11434/v1" disabled={deploying} />
} + +
+ )} + {steps.length > 0 &&

Deployment Progress

{steps.map((step, i) =>
{stepIcon(step.status)}{step.step}{step.message}
)}
} +
+ ); +} diff --git a/gui/src/components/claws/ClawDetail.test.tsx b/gui/src/components/claws/ClawDetail.test.tsx new file mode 100644 index 000000000..94d45a5c8 --- /dev/null +++ b/gui/src/components/claws/ClawDetail.test.tsx @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ClawDetail } from './ClawDetail'; + +vi.mock('../../api/client', async () => { + const getClaw = vi.fn().mockResolvedValue({ ok: true, claw: { id: 'test-claw', sandboxName: 'test-claw', gatewayName: 'nemoclaw', createdAt: '2026-01-01T00:00:00Z', lastConnected: null, config: { provider: 'cloud', model: 'test-model', endpointUrl: '' }, status: 'running', sandboxStatus: 'Ready', detail: 'Some detail' } }); + const getClawStatus = vi.fn().mockResolvedValue({ ok: true, id: 'test-claw', status: 'running', sandboxStatus: 'Ready', lastConnected: '' }); + const reconnectClaw = vi.fn().mockResolvedValue({ ok: true, claw: {}, connectCmd: 'openshell sandbox connect test-claw' }); + const updateClawConfig = vi.fn().mockResolvedValue({ ok: true, claw: {} }); + const streamLogs = vi.fn().mockReturnValue(() => {}); + return { api: { getClaw, getClawStatus, reconnectClaw, updateClawConfig }, streamLogs }; +}); + +vi.mock('../../hooks/useWebSocket', () => ({ + useWebSocket: () => ({ connected: true, sandboxes: [], claws: [], gateway: { healthy: true, ok: true, output: '' }, send: vi.fn() }), +})); + +const { api } = await import('../../api/client'); + +describe('ClawDetail', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('renders claw name and status', async () => { render(); await waitFor(() => { expect(screen.getAllByText(/test-claw/).length).toBeGreaterThan(0); expect(screen.getByText('running')).toBeInTheDocument(); }); }); + it('shows overview tab by default with instance details', async () => { render(); await waitFor(() => { expect(screen.getByText('Instance Details')).toBeInTheDocument(); expect(screen.getByText('nemoclaw')).toBeInTheDocument(); }); }); + it('shows tab navigation', async () => { render(); await waitFor(() => { expect(screen.getAllByTestId('tab-overview').length).toBeGreaterThan(0); expect(screen.getAllByTestId('tab-monitor').length).toBeGreaterThan(0); expect(screen.getAllByTestId('tab-logs').length).toBeGreaterThan(0); expect(screen.getAllByTestId('tab-config').length).toBeGreaterThan(0); expect(screen.getAllByTestId('tab-policy').length).toBeGreaterThan(0); }); }); + it('switches to config tab', async () => { const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getAllByTestId('tab-config').length).toBeGreaterThan(0); }); await user.click(screen.getAllByTestId('tab-config')[0]); expect(screen.getAllByText('⚙️ Inference Configuration').length).toBeGreaterThan(0); }); + it('shows reconnect button', async () => { render(); await waitFor(() => { expect(screen.getAllByText('🔗 Reconnect').length).toBeGreaterThan(0); }); }); + it('shows not found for missing claw', async () => { vi.mocked(api.getClaw).mockResolvedValue({ ok: false, claw: null as any }); render(); await waitFor(() => { expect(screen.getByText('Claw not found.')).toBeInTheDocument(); }); }); +}); diff --git a/gui/src/components/claws/ClawDetail.tsx b/gui/src/components/claws/ClawDetail.tsx new file mode 100644 index 000000000..9a1b74bda --- /dev/null +++ b/gui/src/components/claws/ClawDetail.tsx @@ -0,0 +1,103 @@ +// ClawDetail — Single claw detail view with tabs for overview, monitor, logs, config, and policy +import { useState, useEffect, useCallback } from 'react'; +import { api, streamLogs } from '../../api/client'; +import type { ClawInstance } from '../../api/client'; +import { ClawMonitor } from './ClawMonitor'; + +interface Props { clawId: string; onNavigate?: (path: string) => void; } +type TabId = 'overview' | 'monitor' | 'logs' | 'config' | 'policy'; + +export function ClawDetail({ clawId, onNavigate }: Props) { + const [claw, setClaw] = useState(null); + const [activeTab, setActiveTab] = useState('overview'); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [logLines, setLogLines] = useState([]); + const [logActive, setLogActive] = useState(false); + const [configForm, setConfigForm] = useState({ provider: '', model: '', endpointUrl: '' }); + const [configSaving, setConfigSaving] = useState(false); + const [connectCmd, setConnectCmd] = useState(''); + + const refresh = useCallback(async () => { + setLoading(true); + try { + const data = await api.getClaw(clawId); + if (data.ok && data.claw) { setClaw(data.claw); setConfigForm({ provider: data.claw.config?.provider || '', model: data.claw.config?.model || '', endpointUrl: data.claw.config?.endpointUrl || '' }); } + else { setError('Claw not found'); } + } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load claw'); } + setLoading(false); + }, [clawId]); + + useEffect(() => { refresh(); }, [refresh]); + useEffect(() => { + if (activeTab !== 'logs' || !claw) return; + setLogActive(true); + const stop = streamLogs(claw.sandboxName, (line) => setLogLines(prev => [...prev.slice(-500), line]), () => setLogActive(false)); + return () => { stop(); setLogActive(false); }; + }, [activeTab, claw]); + + const handleReconnect = async () => { try { const data = await api.reconnectClaw(clawId); if (data.ok) { setConnectCmd(data.connectCmd); refresh(); } } catch (err) { setError(err instanceof Error ? err.message : 'Reconnect failed'); } }; + const handleSaveConfig = async () => { setConfigSaving(true); try { await api.updateClawConfig(clawId, configForm); refresh(); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to save config'); } setConfigSaving(false); }; + const statusColor = (s: string) => { if (s === 'running') return 'var(--nc-success, #4caf50)'; if (s === 'error') return 'var(--nc-danger, #f44336)'; if (s === 'creating') return 'var(--nc-warning, #ff9800)'; return 'var(--nc-text-muted, #888)'; }; + + if (loading) return

Loading claw details...

; + if (!claw) return

Claw not found.

; + + const tabs: { id: TabId; label: string; icon: string }[] = [ + { id: 'overview', label: 'Overview', icon: '📋' }, { id: 'monitor', label: 'Monitor', icon: '📊' }, + { id: 'logs', label: 'Logs', icon: '📜' }, { id: 'config', label: 'Config', icon: '⚙️' }, + { id: 'policy', label: 'Policy', icon: '🛡️' }, + ]; + + return ( +
+
+
+ +

🐾 {claw.id}

+ {claw.status} +
+
+ + +
+
+ {error &&
{error}
} + {connectCmd &&
Connect command:{connectCmd}
} +
+ {tabs.map(tab => )} +
+ {activeTab === 'overview' && ( +

Instance Details

+ + + + + + + + +
ID{claw.id}
Sandbox{claw.sandboxName}
Gateway{claw.gatewayName}
Status{claw.status} {claw.sandboxStatus ? `(${claw.sandboxStatus})` : ''}
Provider{claw.config?.provider || '—'}
Model{claw.config?.model || '—'}
Created{new Date(claw.createdAt).toLocaleString()}
Last Connected{claw.lastConnected ? new Date(claw.lastConnected).toLocaleString() : 'Never'}
+ {claw.detail &&

Sandbox Detail

{claw.detail}
} +
+ )} + {activeTab === 'monitor' && } + {activeTab === 'logs' && ( +

📜 Live Logs

{logActive && Streaming}
+
+ {logLines.length === 0 ?
Waiting for log data...
: logLines.map((line, i) =>
{line}
)} +
+ )} + {activeTab === 'config' && ( +

⚙️ Inference Configuration

+
setConfigForm(p => ({ ...p, provider: e.target.value }))} placeholder="e.g. cloud, ollama, openrouter" />
+
setConfigForm(p => ({ ...p, model: e.target.value }))} placeholder="e.g. nvidia/nemotron-3-super-120b-a12b" />
+
setConfigForm(p => ({ ...p, endpointUrl: e.target.value }))} placeholder="e.g. http://localhost:11434/v1" />
+
+ )} + {activeTab === 'policy' && ( +

🛡️ Policy Management

Use the Policy Editor page to manage policies for sandbox {claw.sandboxName}.

+ )} +
+ ); +} diff --git a/gui/src/components/claws/ClawList.test.tsx b/gui/src/components/claws/ClawList.test.tsx new file mode 100644 index 000000000..a118d3d54 --- /dev/null +++ b/gui/src/components/claws/ClawList.test.tsx @@ -0,0 +1,65 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ClawList } from './ClawList'; + +vi.mock('../../api/client', async () => { + const listClaws = vi.fn().mockResolvedValue({ ok: true, claws: [] }); + const syncClaws = vi.fn().mockResolvedValue({ ok: true, claws: [] }); + const destroyClaw = vi.fn().mockResolvedValue({ ok: true, message: 'Destroyed' }); + const reconnectClaw = vi.fn().mockResolvedValue({ ok: true, connectCmd: 'openshell sandbox connect test' }); + return { api: { listClaws, syncClaws, destroyClaw, reconnectClaw } }; +}); + +vi.mock('../../hooks/useWebSocket', () => ({ + useWebSocket: () => ({ + connected: true, sandboxes: [], claws: [], gateway: null, send: vi.fn(), + }), +})); + +const { api } = await import('../../api/client'); + +describe('ClawList', () => { + beforeEach(() => { vi.clearAllMocks(); vi.mocked(api.listClaws).mockResolvedValue({ ok: true, claws: [] }); }); + + it('renders the page header', () => { render(); expect(screen.getAllByText('🐾 Claws').length).toBeGreaterThan(0); }); + it('shows empty state when no claws exist', async () => { render(); await waitFor(() => { expect(screen.getAllByText(/No claws found/).length).toBeGreaterThan(0); }); }); + it('shows filter buttons and action buttons', () => { render(); expect(screen.getAllByText(/All/).length).toBeGreaterThan(0); expect(screen.getAllByText(/Running/).length).toBeGreaterThan(0); expect(screen.getAllByText('+ New Claw').length).toBeGreaterThan(0); }); + it('renders claw cards when claws exist', async () => { + vi.mocked(api.listClaws).mockResolvedValue({ ok: true, claws: [ + { id: 'my-claw', sandboxName: 'my-claw', gatewayName: 'nemoclaw', createdAt: '2026-01-01T00:00:00Z', lastConnected: null, config: { provider: 'cloud', model: 'test-model' }, status: 'running', sandboxStatus: 'Ready' }, + { id: 'test-claw', sandboxName: 'test-claw', gatewayName: 'nemoclaw', createdAt: '2026-01-02T00:00:00Z', lastConnected: null, config: { provider: 'ollama' }, status: 'stopped', sandboxStatus: 'not-found' }, + ] }); + render(); + await waitFor(() => { expect(screen.getAllByText('my-claw').length).toBeGreaterThan(0); expect(screen.getAllByText('test-claw').length).toBeGreaterThan(0); }); + }); + it('shows Live badge when connected', () => { render(); expect(screen.getAllByText('Live').length).toBeGreaterThan(0); }); + it('shows destroy confirmation when clicking destroy button', async () => { + const user = userEvent.setup(); + vi.mocked(api.listClaws).mockResolvedValue({ ok: true, claws: [{ id: 'my-claw', sandboxName: 'my-claw', gatewayName: 'nemoclaw', createdAt: '2026-01-01T00:00:00Z', lastConnected: null, config: { provider: 'cloud' }, status: 'running', sandboxStatus: 'Ready' }] }); + render(); + await waitFor(() => { expect(screen.getAllByText('my-claw').length).toBeGreaterThan(0); }); + await user.click(screen.getAllByTestId('destroy-claw-my-claw')[0]); + expect(screen.getAllByTestId('confirm-destroy-claw').length).toBeGreaterThan(0); + expect(screen.getAllByTestId('cancel-destroy-claw').length).toBeGreaterThan(0); + }); + it('calls destroyClaw API when confirmed', async () => { + const user = userEvent.setup(); + vi.mocked(api.listClaws).mockResolvedValue({ ok: true, claws: [{ id: 'my-claw', sandboxName: 'my-claw', gatewayName: 'nemoclaw', createdAt: '2026-01-01T00:00:00Z', lastConnected: null, config: { provider: 'cloud' }, status: 'running', sandboxStatus: 'Ready' }] }); + render(); + await waitFor(() => { expect(screen.getAllByText('my-claw').length).toBeGreaterThan(0); }); + await user.click(screen.getAllByTestId('destroy-claw-my-claw')[0]); + await user.click(screen.getAllByTestId('confirm-destroy-claw')[0]); + expect(api.destroyClaw).toHaveBeenCalledWith('my-claw'); + }); + it('calls sync when sync button clicked', async () => { + const user = userEvent.setup(); + render(); + const syncBtn = screen.getAllByText(/Sync/).find(el => el.tagName === 'BUTTON'); + if (syncBtn) await user.click(syncBtn); + expect(api.syncClaws).toHaveBeenCalled(); + }); +}); diff --git a/gui/src/components/claws/ClawList.tsx b/gui/src/components/claws/ClawList.tsx new file mode 100644 index 000000000..f18e99ee6 --- /dev/null +++ b/gui/src/components/claws/ClawList.tsx @@ -0,0 +1,80 @@ +// ClawList — Overview of all claw instances with status badges and quick actions +import { useState, useEffect, useCallback } from 'react'; +import { api } from '../../api/client'; +import type { ClawInstance } from '../../api/client'; +import { useWebSocket } from '../../hooks/useWebSocket'; + +export function ClawList({ onNavigate }: { onNavigate?: (path: string) => void }) { + const [claws, setClaws] = useState([]); + const [filter, setFilter] = useState('all'); + const [syncing, setSyncing] = useState(false); + const [confirmDestroy, setConfirmDestroy] = useState(null); + const [error, setError] = useState(''); + const ws = useWebSocket(); + + const refresh = useCallback(async () => { + try { + const data = await api.listClaws(); + if (data.ok) setClaws(data.claws); + } catch { /* best effort */ } + }, []); + + useEffect(() => { refresh(); }, [refresh]); + useEffect(() => { if (ws.claws.length > 0) setClaws(ws.claws); }, [ws.claws]); + + const handleSync = async () => { setSyncing(true); try { const data = await api.syncClaws(); if (data.ok) setClaws(data.claws); } catch {} setSyncing(false); }; + const handleDestroy = async (id: string) => { try { await api.destroyClaw(id); setConfirmDestroy(null); refresh(); } catch (err) { setError(err instanceof Error ? err.message : 'Failed'); } }; + const handleReconnect = async (id: string) => { try { const data = await api.reconnectClaw(id); if (data.ok && data.connectCmd) { setError(''); alert(`Run:\n\n${data.connectCmd}`); } } catch (err) { setError(err instanceof Error ? err.message : 'Failed'); } }; + + const filtered = filter === 'all' ? claws : claws.filter(c => c.status === filter); + const statusColor = (s: string) => { switch (s) { case 'running': return 'var(--nc-success, #4caf50)'; case 'creating': return 'var(--nc-warning, #ff9800)'; case 'error': return 'var(--nc-danger, #f44336)'; default: return 'var(--nc-text-muted, #888)'; } }; + const statusEmoji = (s: string) => { switch (s) { case 'running': return '🟢'; case 'creating': return '🟡'; case 'error': return '🔴'; case 'stopped': return '⚫'; default: return '⚪'; } }; + const counts = { all: claws.length, running: claws.filter(c => c.status === 'running').length, stopped: claws.filter(c => c.status === 'stopped').length, error: claws.filter(c => c.status === 'error').length }; + + return ( +
+
+

🐾 Claws

+
+ {ws.connected && Live} + + + +
+
+ {error &&
{error}
} +
+ {(['all', 'running', 'stopped', 'error'] as const).map(f => )} +
+ {filtered.length === 0 ? ( +
🐾

No claws found{filter !== 'all' ? ` with status "${filter}"` : ''}.

Click "New Claw" to create one, or "Sync" to discover existing sandboxes.

+ ) : ( +
+ {filtered.map(claw => ( +
onNavigate?.(`/claws/${claw.id}`)} data-testid={`claw-card-${claw.id}`}> +
+

{claw.id}

Gateway: {claw.gatewayName}
+ {statusEmoji(claw.status)} {claw.status} +
+
+ {claw.config?.provider &&
🧠 {claw.config.provider}{claw.config.model ? ` / ${claw.config.model}` : ''}
} +
📅 Created: {new Date(claw.createdAt).toLocaleDateString()}
+ {claw.lastConnected &&
🔗 Last connected: {new Date(claw.lastConnected).toLocaleString()}
} + {claw.discovered &&
⚡ Auto-discovered
} +
+
e.stopPropagation()}> + + + {confirmDestroy === claw.id ? ( + <> + ) : ( + + )} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/gui/src/components/claws/ClawMonitor.tsx b/gui/src/components/claws/ClawMonitor.tsx new file mode 100644 index 000000000..e5a013237 --- /dev/null +++ b/gui/src/components/claws/ClawMonitor.tsx @@ -0,0 +1,113 @@ +// ClawMonitor — Per-claw real-time monitoring panel with status, connection, and metrics +import { useState, useEffect, useCallback } from 'react'; +import { api } from '../../api/client'; +import { useWebSocket } from '../../hooks/useWebSocket'; + +interface Props { + clawId: string; +} + +export function ClawMonitor({ clawId }: Props) { + const [status, setStatus] = useState<{ + id: string; + status: string; + sandboxStatus: string; + lastConnected: string; + gatewayName?: string; + } | null>(null); + const [refreshCount, setRefreshCount] = useState(0); + const ws = useWebSocket(); + + const refresh = useCallback(async () => { + try { + const data = await api.getClawStatus(clawId); + if (data.ok) setStatus(data); + } catch { /* best effort */ } + }, [clawId]); + + useEffect(() => { refresh(); }, [refresh, refreshCount]); + + // Auto-refresh every 5 seconds + useEffect(() => { + const timer = setInterval(() => setRefreshCount(c => c + 1), 5000); + return () => clearInterval(timer); + }, []); + + // Also update from WebSocket claw data + useEffect(() => { + const match = ws.claws.find(c => c.id === clawId); + if (match) { + setStatus(prev => ({ + ...prev, + id: match.id, + status: match.status, + sandboxStatus: match.sandboxStatus || '', + lastConnected: match.lastConnected || '', + gatewayName: match.gatewayName, + })); + } + }, [ws.claws, clawId]); + + const statusColor = (s: string) => { + if (s === 'running') return '#4caf50'; + if (s === 'error') return '#f44336'; + if (s === 'creating') return '#ff9800'; + return '#888'; + }; + + const uptime = status?.lastConnected + ? `${Math.round((Date.now() - new Date(status.lastConnected).getTime()) / 60000)} min ago` + : 'N/A'; + + return ( +
+
+
+
Status
+
+ {status?.status === 'running' ? '🟢' : status?.status === 'error' ? '🔴' : '⚪'}{' '} + {status?.status || 'Loading...'} +
+
+
+
Sandbox
+
{status?.sandboxStatus || '—'}
+
+
+
Gateway
+
+ {ws.gateway?.healthy ? '🟢' : '🔴'} {status?.gatewayName || '—'} +
+
+
+
Last Connected
+
{uptime}
+
+
+
+
+

Connection Health

+
+ {ws.connected && WS Connected} + +
+
+
+
🔗 WebSocket: {ws.connected ? 'Connected' : 'Disconnected'}
+
📡 Gateway health: {ws.gateway?.healthy ? 'Healthy' : 'Unhealthy'}
+
⏱️ Auto-refresh: Every 5 seconds
+
+
+
+ ); +} diff --git a/gui/src/hooks/useWebSocket.ts b/gui/src/hooks/useWebSocket.ts index 32f0d46f9..20f5225e3 100644 --- a/gui/src/hooks/useWebSocket.ts +++ b/gui/src/hooks/useWebSocket.ts @@ -1,41 +1,31 @@ // useWebSocket — Auto-reconnecting WebSocket hook for NemoClaw live updates import { useState, useEffect, useRef, useCallback } from 'react'; -import type { Sandbox, GatewayStatus } from '../api/client'; +import type { Sandbox, GatewayStatus, ClawInstance } from '../api/client'; -// ── Message Types ─────────────────────────────────────────────── -export interface WsConnectedMessage { - type: 'connected'; -} - -export interface WsStatusMessage { - type: 'status'; - gateway: { healthy: boolean }; - sandboxes: Sandbox[]; - timestamp: string; -} +// ── Message Types ───────────────────────────────────────────── +export interface WsConnectedMessage { type: 'connected'; } +export interface WsStatusMessage { type: 'status'; gateway: { healthy: boolean }; sandboxes: Sandbox[]; claws?: ClawInstance[]; timestamp: string; } +export interface WsSandboxListMessage { type: 'sandbox:list'; sandboxes: Sandbox[]; } +export interface WsClawListMessage { type: 'claw:list'; claws: ClawInstance[]; } +export type WsMessage = WsConnectedMessage | WsStatusMessage | WsSandboxListMessage | WsClawListMessage; -export interface WsSandboxListMessage { - type: 'sandbox:list'; - sandboxes: Sandbox[]; -} - -export type WsMessage = WsConnectedMessage | WsStatusMessage | WsSandboxListMessage; - -// ── Hook State ────────────────────────────────────────────────── +// ── Hook State ────────────────────────────────────────────── export interface UseWebSocketState { connected: boolean; sandboxes: Sandbox[]; + claws: ClawInstance[]; gateway: GatewayStatus | null; send: (msg: Record) => void; } -// ── Hook ──────────────────────────────────────────────────────── +// ── Hook ────────────────────────────────────────────────── const MIN_RECONNECT_MS = 1000; const MAX_RECONNECT_MS = 30000; export function useWebSocket(): UseWebSocketState { const [connected, setConnected] = useState(false); const [sandboxes, setSandboxes] = useState([]); + const [claws, setClaws] = useState([]); const [gateway, setGateway] = useState(null); const wsRef = useRef(null); @@ -60,7 +50,6 @@ export function useWebSocket(): UseWebSocketState { if (!mountedRef.current) { ws.close(); return; } setConnected(true); backoff.current = MIN_RECONNECT_MS; - // Request immediate state ws.send(JSON.stringify({ type: 'subscribe' })); }; @@ -77,15 +66,16 @@ export function useWebSocket(): UseWebSocketState { output: prev?.output ?? '', })); } - if (data.sandboxes) { - setSandboxes(data.sandboxes); - } + if (data.sandboxes) setSandboxes(data.sandboxes); + if (data.claws) setClaws(data.claws); break; case 'sandbox:list': setSandboxes(data.sandboxes); break; + case 'claw:list': + setClaws(data.claws); + break; case 'connected': - // noop — handled by onopen break; } } catch { /* ignore parse errors */ } @@ -94,16 +84,13 @@ export function useWebSocket(): UseWebSocketState { ws.onclose = () => { if (!mountedRef.current) return; setConnected(false); - // Schedule reconnect with exponential backoff reconnectTimer.current = setTimeout(() => { backoff.current = Math.min(backoff.current * 2, MAX_RECONNECT_MS); connect(); }, backoff.current); }; - ws.onerror = () => { - // onclose will fire after onerror, which handles reconnect - }; + ws.onerror = () => {}; }, []); useEffect(() => { @@ -114,11 +101,11 @@ export function useWebSocket(): UseWebSocketState { mountedRef.current = false; if (reconnectTimer.current) clearTimeout(reconnectTimer.current); if (wsRef.current) { - wsRef.current.onclose = null; // prevent reconnect on intentional close + wsRef.current.onclose = null; wsRef.current.close(); } }; }, [connect]); - return { connected, sandboxes, gateway, send }; + return { connected, sandboxes, claws, gateway, send }; }