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.
This commit is contained in:
Jamie Taylor
2026-02-01 22:08:01 +00:00
parent e0b5ddfec5
commit 4803e12daa
6 changed files with 314 additions and 6 deletions
+4
View File
@@ -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=
+127 -2
View File
@@ -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<string | null>(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({
</div>
</div>
{/* Gateway URL Configuration */}
<div className="panel-retro p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="font-display text-xs text-gray-400 uppercase tracking-wide">
<span className="text-crab-600"></span> Gateway URL <span className="text-crab-600"></span>
</h3>
{!editingUrl && (
<button
onClick={handleStartEdit}
className="p-1.5 hover:bg-shell-800 rounded transition-colors"
title="Edit gateway URL"
>
<Edit2 size={12} className="text-shell-500" />
</button>
)}
</div>
{editingUrl ? (
<div className="space-y-2">
<input
type="text"
value={urlInput ?? ''}
onChange={(e) => 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 && (
<p className="font-console text-[10px] text-crab-400">
{urlError}
</p>
)}
<div className="flex gap-2">
<button
onClick={handleSaveUrl}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5 font-display text-[10px] uppercase tracking-wide bg-neon-mint/20 hover:bg-neon-mint/30 text-neon-mint rounded transition-all"
>
<Check size={12} />
Save
</button>
<button
onClick={handleCancelEdit}
className="flex-1 px-3 py-1.5 font-display text-[10px] uppercase tracking-wide bg-shell-800 hover:bg-shell-700 text-gray-400 rounded transition-all"
>
Cancel
</button>
</div>
{isCustomUrl && (
<button
onClick={handleResetToDefault}
className="w-full flex items-center justify-center gap-1.5 px-3 py-1.5 font-display text-[10px] uppercase tracking-wide bg-shell-800 hover:bg-crab-900/50 text-shell-400 rounded transition-all"
>
<RotateCcw size={12} />
Reset to Default
</button>
)}
</div>
) : (
<div className="font-console text-[10px] space-y-1.5">
{gatewayUrl ? (
<div className="flex items-center gap-2">
<span className="text-crab-600">&gt;</span>
<span className={isCustomUrl ? 'text-neon-cyan' : 'text-shell-500'}>
{gatewayUrl}
</span>
</div>
) : (
<div className="flex items-center gap-2">
<span className="text-crab-600">&gt;</span>
<span className="text-shell-600 animate-pulse">Loading...</span>
</div>
)}
{isCustomUrl && defaultGatewayUrl && (
<div className="text-shell-600">
<span className="text-crab-600">&gt;</span> default: {defaultGatewayUrl}
</div>
)}
</div>
)}
</div>
{/* Info panel */}
<div className="panel-retro p-4 bg-shell-950/50">
<h3 className="font-display text-xs text-gray-400 uppercase tracking-wide mb-3">
@@ -304,7 +429,7 @@ export function SettingsPanel({
<div className="font-console text-[10px] text-shell-500 space-y-1.5">
<div>
<span className="text-crab-600">&gt;</span> endpoint: ws://127.0.0.1:18789
<span className="text-crab-600">&gt;</span> endpoint: {gatewayUrl ?? 'Loading...'}
</div>
<div>
<span className="text-crab-600">&gt;</span> protocol: v3
+48 -2
View File
@@ -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
+25
View File
@@ -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
+42 -1
View File
@@ -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 }) => {
+68 -1
View File
@@ -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<string | null>(null)
const [defaultGatewayUrl, setDefaultGatewayUrl] = useState<string | null>(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}
/>
</div>
</header>