mirror of
https://github.com/crabwise-ai/crabwalk.git
synced 2026-08-14 00:57:52 +00:00
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:
@@ -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=
|
||||
|
||||
|
||||
@@ -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">></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">></span>
|
||||
<span className="text-shell-600 animate-pulse">Loading...</span>
|
||||
</div>
|
||||
)}
|
||||
{isCustomUrl && defaultGatewayUrl && (
|
||||
<div className="text-shell-600">
|
||||
<span className="text-crab-600">></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">></span> endpoint: ws://127.0.0.1:18789
|
||||
<span className="text-crab-600">></span> endpoint: {gatewayUrl ?? 'Loading...'}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-crab-600">></span> protocol: v3
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user