Add a complete web management panel for ZeroClaw, served directly from the binary via rust-embed. The dashboard provides real-time monitoring, agent chat, configuration editing, and system diagnostics — all accessible at http://localhost:5555/ after pairing. Backend (Rust): - Add 15+ REST API endpoints under /api/* with bearer token auth - Add WebSocket agent chat at /ws/chat with query param auth - Add SSE event stream at /api/events via BroadcastObserver - Add rust-embed static file serving at /_app/* with SPA fallback - Extend AppState with tools_registry, cost_tracker, event_tx - Extract doctor::diagnose() for structured diagnostic results - Add Serialize derives to IntegrationStatus, CliCategory, DiscoveredCli Frontend (React + Vite + Tailwind CSS): - 10 dashboard pages: Dashboard, AgentChat, Tools, Cron, Integrations, Memory, Config, Cost, Logs, Doctor - WebSocket client with auto-reconnect for agent chat - SSE client (fetch-based, supports auth headers) for live events - Full EN/TR internationalization (~190 translation keys) - Dark theme with responsive layouts - Auth flow via 6-digit pairing code, token stored in localStorage Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
126 lines
4.3 KiB
TypeScript
126 lines
4.3 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import {
|
|
Settings,
|
|
Save,
|
|
CheckCircle,
|
|
AlertTriangle,
|
|
ShieldAlert,
|
|
} from 'lucide-react';
|
|
import { getConfig, putConfig } from '@/lib/api';
|
|
|
|
export default function Config() {
|
|
const [config, setConfig] = useState('');
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [success, setSuccess] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
getConfig()
|
|
.then((data) => {
|
|
// The API may return either a raw string or a JSON string
|
|
setConfig(typeof data === 'string' ? data : JSON.stringify(data, null, 2));
|
|
})
|
|
.catch((err) => setError(err.message))
|
|
.finally(() => setLoading(false));
|
|
}, []);
|
|
|
|
const handleSave = async () => {
|
|
setSaving(true);
|
|
setError(null);
|
|
setSuccess(null);
|
|
try {
|
|
await putConfig(config);
|
|
setSuccess('Configuration saved successfully.');
|
|
} catch (err: unknown) {
|
|
setError(err instanceof Error ? err.message : 'Failed to save configuration');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
// Auto-dismiss success after 4 seconds
|
|
useEffect(() => {
|
|
if (!success) return;
|
|
const timer = setTimeout(() => setSuccess(null), 4000);
|
|
return () => clearTimeout(timer);
|
|
}, [success]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-64">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-2 border-blue-500 border-t-transparent" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="p-6 space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Settings className="h-5 w-5 text-blue-400" />
|
|
<h2 className="text-base font-semibold text-white">Configuration</h2>
|
|
</div>
|
|
<button
|
|
onClick={handleSave}
|
|
disabled={saving}
|
|
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors disabled:opacity-50"
|
|
>
|
|
<Save className="h-4 w-4" />
|
|
{saving ? 'Saving...' : 'Save'}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Sensitive fields note */}
|
|
<div className="flex items-start gap-3 bg-yellow-900/20 border border-yellow-700/40 rounded-lg p-4">
|
|
<ShieldAlert className="h-5 w-5 text-yellow-400 flex-shrink-0 mt-0.5" />
|
|
<div>
|
|
<p className="text-sm text-yellow-300 font-medium">
|
|
Sensitive fields are masked
|
|
</p>
|
|
<p className="text-sm text-yellow-400/70 mt-0.5">
|
|
API keys, tokens, and passwords are hidden for security. To update a
|
|
masked field, replace the entire masked value with your new value.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Success message */}
|
|
{success && (
|
|
<div className="flex items-center gap-2 bg-green-900/30 border border-green-700 rounded-lg p-3">
|
|
<CheckCircle className="h-4 w-4 text-green-400 flex-shrink-0" />
|
|
<span className="text-sm text-green-300">{success}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Error message */}
|
|
{error && (
|
|
<div className="flex items-center gap-2 bg-red-900/30 border border-red-700 rounded-lg p-3">
|
|
<AlertTriangle className="h-4 w-4 text-red-400 flex-shrink-0" />
|
|
<span className="text-sm text-red-300">{error}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Config Editor */}
|
|
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
|
|
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-800 bg-gray-800/50">
|
|
<span className="text-xs text-gray-400 font-medium uppercase tracking-wider">
|
|
TOML Configuration
|
|
</span>
|
|
<span className="text-xs text-gray-500">
|
|
{config.split('\n').length} lines
|
|
</span>
|
|
</div>
|
|
<textarea
|
|
value={config}
|
|
onChange={(e) => setConfig(e.target.value)}
|
|
spellCheck={false}
|
|
className="w-full min-h-[500px] bg-gray-950 text-gray-200 font-mono text-sm p-4 resize-y focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-inset"
|
|
style={{ tabSize: 4 }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|