mission-control/src/components/panels/settings-panel.tsx

359 lines
13 KiB
TypeScript

'use client'
import { useState, useEffect, useCallback } from 'react'
import { useMissionControl } from '@/store'
import { useNavigateToPanel } from '@/lib/navigation'
interface Setting {
key: string
value: string
description: string
category: string
updated_by: string | null
updated_at: number | null
is_default: boolean
}
const categoryLabels: Record<string, { label: string; icon: string; description: string }> = {
general: { label: 'General', icon: '⚙', description: 'Core Mission Control settings' },
retention: { label: 'Data Retention', icon: '🗄', description: 'How long data is kept before cleanup' },
gateway: { label: 'Gateway', icon: '🔌', description: 'OpenClaw gateway connection settings' },
custom: { label: 'Custom', icon: '🔧', description: 'User-defined settings' },
}
const categoryOrder = ['general', 'retention', 'gateway', 'custom']
export function SettingsPanel() {
const { currentUser } = useMissionControl()
const navigateToPanel = useNavigateToPanel()
const [settings, setSettings] = useState<Setting[]>([])
const [grouped, setGrouped] = useState<Record<string, Setting[]>>({})
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [feedback, setFeedback] = useState<{ ok: boolean; text: string } | null>(null)
// Track edited values (key -> new value)
const [edits, setEdits] = useState<Record<string, string>>({})
const [activeCategory, setActiveCategory] = useState('general')
const showFeedback = (ok: boolean, text: string) => {
setFeedback({ ok, text })
setTimeout(() => setFeedback(null), 3000)
}
const fetchSettings = useCallback(async () => {
try {
const res = await fetch('/api/settings')
if (res.status === 401) {
window.location.assign('/login?next=%2Fsettings')
return
}
if (res.status === 403) {
setError('Admin access required')
return
}
if (!res.ok) {
const data = await res.json().catch(() => ({}))
setError(data.error || 'Failed to load settings')
return
}
const data = await res.json()
setSettings(data.settings || [])
setGrouped(data.grouped || {})
} catch {
setError('Failed to load settings')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { fetchSettings() }, [fetchSettings])
const handleEdit = (key: string, value: string) => {
setEdits(prev => ({ ...prev, [key]: value }))
}
const hasChanges = Object.keys(edits).some(key => {
const setting = settings.find(s => s.key === key)
return setting && edits[key] !== setting.value
})
const handleSave = async () => {
// Filter only actual changes
const changes: Record<string, string> = {}
for (const [key, value] of Object.entries(edits)) {
const setting = settings.find(s => s.key === key)
if (setting && value !== setting.value) {
changes[key] = value
}
}
if (Object.keys(changes).length === 0) return
setSaving(true)
try {
const res = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ settings: changes }),
})
const data = await res.json()
if (res.ok) {
showFeedback(true, `Saved ${data.count} setting${data.count === 1 ? '' : 's'}`)
setEdits({})
fetchSettings()
} else {
showFeedback(false, data.error || 'Failed to save')
}
} catch {
showFeedback(false, 'Network error')
} finally {
setSaving(false)
}
}
const handleReset = async (key: string) => {
try {
const res = await fetch(`/api/settings?key=${encodeURIComponent(key)}`, { method: 'DELETE' })
const data = await res.json()
if (res.ok) {
showFeedback(true, `Reset "${key}" to default`)
setEdits(prev => {
const next = { ...prev }
delete next[key]
return next
})
fetchSettings()
} else {
showFeedback(false, data.error || 'Failed to reset')
}
} catch {
showFeedback(false, 'Network error')
}
}
const handleDiscard = () => {
setEdits({})
}
if (loading) {
return (
<div className="p-6 flex items-center gap-2">
<div className="w-4 h-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
<span className="text-sm text-muted-foreground">Loading settings...</span>
</div>
)
}
if (error) {
return (
<div className="p-6">
<div className="bg-destructive/10 text-destructive rounded-lg p-4 text-sm">{error}</div>
</div>
)
}
const categories = categoryOrder.filter(c => grouped[c]?.length > 0)
return (
<div className="p-4 md:p-6 max-w-4xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-foreground">Settings</h2>
<p className="text-xs text-muted-foreground mt-0.5">Configure Mission Control behavior and retention policies</p>
</div>
<div className="flex items-center gap-2">
{hasChanges && (
<button
onClick={handleDiscard}
className="px-3 py-1.5 text-xs rounded-md border border-border text-muted-foreground hover:text-foreground transition-colors"
>
Discard
</button>
)}
<button
onClick={handleSave}
disabled={!hasChanges || saving}
className={`px-4 py-1.5 text-xs rounded-md font-medium transition-colors ${
hasChanges
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: 'bg-muted text-muted-foreground cursor-not-allowed'
}`}
>
{saving ? 'Saving...' : 'Save Changes'}
</button>
</div>
</div>
{/* Workspace Info */}
{currentUser?.role === 'admin' && (
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-3 text-xs text-blue-300">
<strong className="text-blue-200">Workspace Management:</strong>{' '}
To create or manage workspaces (tenant instances), go to the{' '}
<button
onClick={() => navigateToPanel('super-admin')}
className="text-blue-400 underline hover:text-blue-300 cursor-pointer"
>
Super Admin
</button>{' '}
panel under Admin &gt; Super Admin in the sidebar. From there you can create new client instances, manage tenants, and monitor provisioning jobs.
</div>
)}
{/* Feedback */}
{feedback && (
<div className={`rounded-lg p-3 text-xs font-medium ${
feedback.ok ? 'bg-green-500/10 text-green-400' : 'bg-destructive/10 text-destructive'
}`}>
{feedback.text}
</div>
)}
{/* Category tabs */}
<div className="flex gap-1 border-b border-border pb-px">
{categories.map(cat => {
const meta = categoryLabels[cat] || { label: cat, icon: '📋', description: '' }
const changedCount = (grouped[cat] || []).filter(s => edits[s.key] !== undefined && edits[s.key] !== s.value).length
return (
<button
key={cat}
onClick={() => setActiveCategory(cat)}
className={`px-3 py-2 text-xs font-medium rounded-t-md transition-colors relative ${
activeCategory === cat
? 'bg-card text-foreground border border-border border-b-card -mb-px'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{meta.label}
{changedCount > 0 && (
<span className="ml-1.5 inline-flex items-center justify-center w-4 h-4 text-2xs rounded-full bg-primary text-primary-foreground">
{changedCount}
</span>
)}
</button>
)
})}
</div>
{/* Settings list for active category */}
<div className="space-y-3">
{(grouped[activeCategory] || []).map(setting => {
const currentValue = edits[setting.key] ?? setting.value
const isChanged = edits[setting.key] !== undefined && edits[setting.key] !== setting.value
const isBooleanish = setting.value === 'true' || setting.value === 'false'
const isNumeric = /^\d+$/.test(setting.value)
const shortKey = setting.key.split('.').pop() || setting.key
return (
<div
key={setting.key}
className={`bg-card border rounded-lg p-4 transition-colors ${
isChanged ? 'border-primary/50' : 'border-border'
}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">{formatLabel(shortKey)}</span>
{setting.is_default && (
<span className="text-2xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">default</span>
)}
{isChanged && (
<span className="text-2xs px-1.5 py-0.5 rounded bg-primary/15 text-primary">modified</span>
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5">{setting.description}</p>
<p className="text-2xs text-muted-foreground/60 mt-1 font-mono">{setting.key}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
{isBooleanish ? (
<button
onClick={() => handleEdit(setting.key, currentValue === 'true' ? 'false' : 'true')}
className={`w-10 h-5 rounded-full relative transition-colors ${
currentValue === 'true' ? 'bg-primary' : 'bg-muted'
}`}
>
<span className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${
currentValue === 'true' ? 'left-5' : 'left-0.5'
}`} />
</button>
) : isNumeric ? (
<input
type="number"
value={currentValue}
onChange={e => handleEdit(setting.key, e.target.value)}
className="w-24 px-2 py-1 text-sm text-right bg-background border border-border rounded-md focus:border-primary focus:outline-none font-mono"
/>
) : (
<input
type="text"
value={currentValue}
onChange={e => handleEdit(setting.key, e.target.value)}
className="w-48 px-2 py-1 text-sm bg-background border border-border rounded-md focus:border-primary focus:outline-none"
/>
)}
{!setting.is_default && (
<button
onClick={() => handleReset(setting.key)}
title="Reset to default"
className="text-muted-foreground hover:text-foreground transition-colors p-1"
>
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M2 8a6 6 0 1111.3-2.8" strokeLinecap="round" />
<path d="M14 2v3.5h-3.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
)}
</div>
</div>
{setting.updated_by && setting.updated_at && (
<div className="text-2xs text-muted-foreground/50 mt-2">
Last updated by {setting.updated_by} on {new Date(setting.updated_at * 1000).toLocaleDateString()}
</div>
)}
</div>
)
})}
</div>
{/* Unsaved changes bar */}
{hasChanges && (
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 bg-card border border-border rounded-lg shadow-lg px-4 py-2.5 flex items-center gap-3 z-40">
<div className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" />
<span className="text-xs text-foreground">
{Object.keys(edits).filter(k => {
const s = settings.find(s => s.key === k)
return s && edits[k] !== s.value
}).length} unsaved change(s)
</span>
<button
onClick={handleDiscard}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Discard
</button>
<button
onClick={handleSave}
disabled={saving}
className="px-3 py-1 text-xs rounded-md bg-primary text-primary-foreground hover:bg-primary/90 font-medium"
>
{saving ? 'Saving...' : 'Save'}
</button>
</div>
)}
</div>
)
}
/** Convert snake_case key to Title Case label */
function formatLabel(key: string): string {
return key
.replace(/_/g, ' ')
.replace(/\b\w/g, c => c.toUpperCase())
}