From 4803e12daacc3b216ed6b8ca67eaec9dbc60cf83 Mon Sep 17 00:00:00 2001 From: Jamie Taylor Date: Sun, 1 Feb 2026 22:08:01 +0000 Subject: [PATCH] feat(monitor): add configurable gateway URL in settings panel Add runtime configuration for Clawdbot gateway WebSocket URL through the settings UI. Changes include server-side tRPC mutations for URL management, client-side localStorage persistence for user preferences, shared URL validation utilities, and UI controls for editing, saving, and resetting to defaults. Reconnects are handled automatically when switching URLs. --- .env.example | 4 + src/components/monitor/SettingsPanel.tsx | 129 ++++++++++++++++++++++- src/integrations/clawdbot/client.ts | 50 ++++++++- src/integrations/clawdbot/index.ts | 25 +++++ src/integrations/trpc/router.ts | 43 +++++++- src/routes/monitor/index.tsx | 69 +++++++++++- 6 files changed, 314 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index 00e9e9f..4efcea9 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,10 @@ # Add your environment variables here # Copy this file to .env.local and fill in your values +# Clawdbot gateway WebSocket URL (optional, defaults to ws://127.0.0.1:18789) +# This can be overridden at runtime via the Settings panel +# CLAWDBOT_URL=ws://127.0.0.1:18789 + # Clawdbot gateway auth token CLAWDBOT_API_TOKEN= diff --git a/src/components/monitor/SettingsPanel.tsx b/src/components/monitor/SettingsPanel.tsx index fee7808..9a2e059 100644 --- a/src/components/monitor/SettingsPanel.tsx +++ b/src/components/monitor/SettingsPanel.tsx @@ -1,6 +1,8 @@ import { motion, AnimatePresence } from 'framer-motion' -import { Settings, X, Wifi, WifiOff, RefreshCw, Terminal, Download, Trash2, Database, HardDrive, Play, Square, CloudDownload } from 'lucide-react' +import { Settings, X, Wifi, WifiOff, RefreshCw, Terminal, Download, Trash2, Database, HardDrive, Play, Square, CloudDownload, Edit2, Check, RotateCcw } from 'lucide-react' +import { useState } from 'react' import { version } from '../../../package.json' +import { validateGatewayUrl } from '~/integrations/clawdbot' interface SettingsPanelProps { connected: boolean @@ -12,6 +14,8 @@ interface SettingsPanelProps { persistenceStartedAt: number | null persistenceSessionCount: number persistenceActionCount: number + gatewayUrl: string | null + defaultGatewayUrl: string | null open: boolean onOpenChange: (open: boolean) => void onHistoricalModeChange: (enabled: boolean) => void @@ -25,6 +29,7 @@ interface SettingsPanelProps { onPersistenceStart: () => void onPersistenceStop: () => void onPersistenceClear: () => void + onGatewayUrlChange: (url: string) => void } export function SettingsPanel({ @@ -37,6 +42,8 @@ export function SettingsPanel({ persistenceStartedAt, persistenceSessionCount, persistenceActionCount, + gatewayUrl, + defaultGatewayUrl, open, onOpenChange, onHistoricalModeChange, @@ -50,7 +57,44 @@ export function SettingsPanel({ onPersistenceStart, onPersistenceStop, onPersistenceClear, + onGatewayUrlChange, }: SettingsPanelProps) { + const [editingUrl, setEditingUrl] = useState(false) + const [urlInput, setUrlInput] = useState(gatewayUrl ?? '') + const [urlError, setUrlError] = useState(null) + + const isCustomUrl = gatewayUrl !== null && defaultGatewayUrl !== null && gatewayUrl !== defaultGatewayUrl + + const handleStartEdit = () => { + setUrlInput(gatewayUrl ?? '') + setUrlError(null) + setEditingUrl(true) + } + + const handleCancelEdit = () => { + setEditingUrl(false) + setUrlError(null) + } + + const handleSaveUrl = () => { + // Validate URL format using shared utility + const result = validateGatewayUrl(urlInput) + if (!result.valid) { + setUrlError(result.error || 'Invalid URL format') + return + } + onGatewayUrlChange(urlInput) + setEditingUrl(false) + setUrlError(null) + } + + const handleResetToDefault = () => { + if (defaultGatewayUrl) { + onGatewayUrlChange(defaultGatewayUrl) + } + setEditingUrl(false) + setUrlError(null) + } return ( <> @@ -296,6 +340,87 @@ export function SettingsPanel({ + {/* Gateway URL Configuration */} +
+
+

+ Gateway URL +

+ {!editingUrl && ( + + )} +
+ + {editingUrl ? ( +
+ setUrlInput(e.target.value)} + placeholder="ws://127.0.0.1:18789" + className="w-full px-3 py-2 bg-shell-950 border border-shell-700 rounded font-console text-[11px] text-gray-300 focus:outline-hidden focus:border-crab-600" + autoFocus + /> + {urlError && ( +

+ {urlError} +

+ )} +
+ + +
+ {isCustomUrl && ( + + )} +
+ ) : ( +
+ {gatewayUrl ? ( +
+ > + + {gatewayUrl} + +
+ ) : ( +
+ > + Loading... +
+ )} + {isCustomUrl && defaultGatewayUrl && ( +
+ > default: {defaultGatewayUrl} +
+ )} +
+ )} +
+ {/* Info panel */}

@@ -304,7 +429,7 @@ export function SettingsPanel({
- > endpoint: ws://127.0.0.1:18789 + > endpoint: {gatewayUrl ?? 'Loading...'}
> protocol: v3 diff --git a/src/integrations/clawdbot/client.ts b/src/integrations/clawdbot/client.ts index e56cdd8..777c657 100644 --- a/src/integrations/clawdbot/client.ts +++ b/src/integrations/clawdbot/client.ts @@ -32,10 +32,23 @@ export class ClawdbotClient { private _connecting = false constructor( - private url: string = 'ws://127.0.0.1:18789', + private _url: string = 'ws://127.0.0.1:18789', private token?: string ) {} + get url(): string { + return this._url + } + + setUrl(url: string): void { + // Disconnect if connected before changing URL + if (this._connected || this._connecting) { + this.disconnect() + this._connecting = false + } + this._url = url + } + get connected() { return this._connected } @@ -236,18 +249,51 @@ export class ClawdbotClient { } } +// Default gateway URL (openclaw default) +const DEFAULT_GATEWAY_URL = 'ws://127.0.0.1:18789' + // Singleton instance for server use let clientInstance: ClawdbotClient | null = null +export function getDefaultGatewayUrl(): string { + return process.env.CLAWDBOT_URL || DEFAULT_GATEWAY_URL +} + export function getClawdbotClient(): ClawdbotClient { if (!clientInstance) { - const url = process.env.CLAWDBOT_URL || 'ws://127.0.0.1:18789' + const url = getDefaultGatewayUrl() const token = process.env.CLAWDBOT_API_TOKEN clientInstance = new ClawdbotClient(url, token) } return clientInstance } +export function resetClawdbotClient(): void { + if (clientInstance) { + clientInstance.disconnect() + clientInstance = null + } +} + +export function setClawdbotClientUrl(url: string): void { + // If client exists and is connected, disconnect and reset to ensure clean state + if (clientInstance) { + if (clientInstance.connected) { + clientInstance.disconnect() + } + // Reset the singleton so a new client is created with the new URL + // This ensures no stale state from previous connections + clientInstance = null + } + // Create new client with the new URL + const token = process.env.CLAWDBOT_API_TOKEN + clientInstance = new ClawdbotClient(url, token) +} + +export function getClawdbotClientUrl(): string { + return getClawdbotClient().url +} + // Parsed event helpers export function isChatEvent( event: EventFrame diff --git a/src/integrations/clawdbot/index.ts b/src/integrations/clawdbot/index.ts index dff7f69..1c16528 100644 --- a/src/integrations/clawdbot/index.ts +++ b/src/integrations/clawdbot/index.ts @@ -3,4 +3,29 @@ export * from './protocol' export * from './parser' export * from './collections' +// Shared validation utilities +export interface UrlValidationResult { + valid: boolean + error?: string +} + +/** + * Validates a WebSocket URL format. + * Safe for both client and server use. + */ +export function validateGatewayUrl(url: string): UrlValidationResult { + try { + const parsed = new URL(url) + if (parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') { + return { valid: false, error: 'Must use ws:// or wss:// protocol' } + } + if (!parsed.hostname) { + return { valid: false, error: 'Hostname is required' } + } + return { valid: true } + } catch { + return { valid: false, error: 'Invalid URL format' } + } +} + // Server-only exports are in ./client.ts - import directly from there diff --git a/src/integrations/trpc/router.ts b/src/integrations/trpc/router.ts index 9accd83..8caec62 100644 --- a/src/integrations/trpc/router.ts +++ b/src/integrations/trpc/router.ts @@ -2,7 +2,13 @@ import { initTRPC } from '@trpc/server' import { observable } from '@trpc/server/observable' import superjson from 'superjson' import { z } from 'zod' -import { getClawdbotClient } from '~/integrations/clawdbot/client' +import { + getClawdbotClient, + getClawdbotClientUrl, + getDefaultGatewayUrl, + setClawdbotClientUrl, +} from '~/integrations/clawdbot/client' +import { validateGatewayUrl } from '~/integrations/clawdbot' import { getPersistenceService } from '~/integrations/clawdbot/persistence' import { parseEventFrame, @@ -60,6 +66,41 @@ const clawdbotRouter = router({ return { connected: client.connected } }), + getGatewayUrl: publicProcedure.query(() => { + return { url: getClawdbotClientUrl() } + }), + + getDefaultGatewayUrl: publicProcedure.query(() => { + return { url: getDefaultGatewayUrl() } + }), + + setGatewayUrl: publicProcedure + .input(z.object({ url: z.string() })) + .mutation(({ input }) => { + // Validate URL format using shared utility + const validation = validateGatewayUrl(input.url) + if (!validation.valid) { + return { + success: false, + error: validation.error || 'Invalid WebSocket URL format.', + } + } + + const wasConnected = getClawdbotClient().connected + + // Set the new URL (this will disconnect if connected) + setClawdbotClientUrl(input.url) + + return { + success: true, + url: input.url, + wasConnected, + message: wasConnected + ? 'Disconnected from previous gateway. Click Connect to connect to the new URL.' + : 'Gateway URL updated. Click Connect to connect.', + } + }), + setDebugMode: publicProcedure .input(z.object({ enabled: z.boolean() })) .mutation(({ input }) => { diff --git a/src/routes/monitor/index.tsx b/src/routes/monitor/index.tsx index 88cb487..e10189d 100644 --- a/src/routes/monitor/index.tsx +++ b/src/routes/monitor/index.tsx @@ -15,6 +15,7 @@ import { clearCollections, hydrateFromServer, clearCompletedExecs, + validateGatewayUrl as validateGatewayUrlUtil, } from '~/integrations/clawdbot' import { ActionGraph, @@ -77,6 +78,10 @@ function MonitorPage() { const [persistenceSessionCount, setPersistenceSessionCount] = useState(0) const [persistenceActionCount, setPersistenceActionCount] = useState(0) + // Gateway URL state - initialized as null, loaded from server + const [gatewayUrl, setGatewayUrl] = useState(null) + const [defaultGatewayUrl, setDefaultGatewayUrl] = useState(null) + // Sidebar collapse state - default to collapsed const [sidebarCollapsed, setSidebarCollapsed] = useState(true) @@ -104,12 +109,52 @@ function MonitorPage() { }, []) - // Check connection status and persistence on mount + // Load gateway URL from localStorage and server on mount useEffect(() => { + loadGatewayUrl() checkStatus() checkPersistenceStatus() }, []) + const loadGatewayUrl = async () => { + try { + // Get default URL from server (env var or fallback) + const defaultResult = await trpc.clawdbot.getDefaultGatewayUrl.query() + setDefaultGatewayUrl(defaultResult.url) + + // Check localStorage for saved URL + const savedUrl = localStorage.getItem('clawdbot_gateway_url') + if (savedUrl) { + // Validate the saved URL format (no side effects) + const isValid = validateGatewayUrl(savedUrl) + if (isValid) { + // Update state first to ensure UI consistency + setGatewayUrl(savedUrl) + // Then apply the saved URL on the server + await trpc.clawdbot.setGatewayUrl.mutate({ url: savedUrl }) + } else { + // If invalid, clear localStorage and use default + console.warn('[monitor] Saved gateway URL invalid, using default') + localStorage.removeItem('clawdbot_gateway_url') + setGatewayUrl(defaultResult.url) + } + } else { + // Use server default URL + setGatewayUrl(defaultResult.url) + } + } catch (e) { + console.error('Failed to load gateway URL:', e) + // Set fallback values on error so UI isn't stuck in loading state + setGatewayUrl('ws://127.0.0.1:18789') + setDefaultGatewayUrl('ws://127.0.0.1:18789') + } + } + + // Client-side URL validation helper using shared utility + const validateGatewayUrl = (url: string): boolean => { + return validateGatewayUrlUtil(url).valid + } + const checkPersistenceStatus = async () => { try { const status = await trpc.clawdbot.persistenceStatus.query() @@ -287,6 +332,25 @@ function MonitorPage() { } } + const handleGatewayUrlChange = async (url: string) => { + try { + const result = await trpc.clawdbot.setGatewayUrl.mutate({ url }) + if (result.success) { + setGatewayUrl(url) + localStorage.setItem('clawdbot_gateway_url', url) + // If we were connected, we're now disconnected + if (result.wasConnected) { + setConnected(false) + clearCollections() + } + } else { + console.error('Failed to set gateway URL:', result.error) + } + } catch (e) { + console.error('Failed to set gateway URL:', e) + } + } + // Poll log count while collecting useEffect(() => { if (!logCollection) return @@ -463,6 +527,8 @@ function MonitorPage() { persistenceStartedAt={persistenceStartedAt} persistenceSessionCount={persistenceSessionCount} persistenceActionCount={persistenceActionCount} + gatewayUrl={gatewayUrl} + defaultGatewayUrl={defaultGatewayUrl} open={settingsOpen} onOpenChange={setSettingsOpen} onHistoricalModeChange={handleHistoricalModeChange} @@ -476,6 +542,7 @@ function MonitorPage() { onPersistenceStart={handlePersistenceStart} onPersistenceStop={handlePersistenceStop} onPersistenceClear={handlePersistenceClear} + onGatewayUrlChange={handleGatewayUrlChange} />