diff --git a/db/bootstrap.sql b/db/bootstrap.sql index 4d815aaf..0cac4880 100644 --- a/db/bootstrap.sql +++ b/db/bootstrap.sql @@ -265,6 +265,24 @@ CREATE TABLE IF NOT EXISTS secrets ( CREATE INDEX IF NOT EXISTS idx_secrets_name ON secrets(name); CREATE INDEX IF NOT EXISTS idx_secrets_created_by ON secrets(created_by); +-- ============================================================================ +-- SYSTEM SETTINGS TABLE +-- ============================================================================ +-- Key-value store for runtime configuration. Each setting has a namespace +-- (e.g. 'runtime', 'database', 'security') for grouping, a typed value, +-- and a human-readable description for the UI. + +CREATE TABLE IF NOT EXISTS system_settings ( + key VARCHAR(255) PRIMARY KEY, + namespace VARCHAR(100) NOT NULL, + value JSONB NOT NULL, + description TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_system_settings_namespace + ON system_settings(namespace); + -- ============================================================================ -- SECRET ACCESS LOG -- ============================================================================ diff --git a/src/api/main.py b/src/api/main.py index 550da456..2368ef1f 100644 --- a/src/api/main.py +++ b/src/api/main.py @@ -22,7 +22,7 @@ from fastapi.middleware.cors import CORSMiddleware from libs.db.connection import init_db, close_db, get_db from libs.logging import get_logger from src.api.dependencies import set_controller -from src.api.routers import agents, tasks, plugins, policies, security, logs +from src.api.routers import agents, tasks, plugins, policies, security, logs, settings from src.kernel_runtime.scheduler.execution_controller import ( ExecutionController, ) @@ -102,7 +102,8 @@ app.include_router(tasks.router, prefix=API_PREFIX) app.include_router(plugins.router, prefix=API_PREFIX) app.include_router(policies.router, prefix=API_PREFIX) app.include_router(security.router, prefix=API_PREFIX) -app.include_router(logs.router, prefix=API_PREFIX) +app.include_router(logs.router, prefix=API_PREFIX) +app.include_router(settings.router, prefix=API_PREFIX) # ── Health ──────────────────────────────────────────────────────────────────── diff --git a/src/api/routers/settings.py b/src/api/routers/settings.py new file mode 100644 index 00000000..0f15d5f0 --- /dev/null +++ b/src/api/routers/settings.py @@ -0,0 +1,165 @@ +"""Settings router — read/write system_settings key-value store.""" + +import json +from typing import Any, Dict, List, Optional +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from src.common.utils import generate_correlation_id +from src.api.dependencies import get_db_conn +from libs.db.connection import DatabaseConnection +from libs.logging import get_logger + +log = get_logger("api.settings") +router = APIRouter(prefix="/settings", tags=["settings"]) + + +# ── Models ──────────────────────────────────────────────────────────────────── + +class SettingResponse(BaseModel): + key: str + namespace: str + value: Any + description: Optional[str] + updated_at: str + + +class SettingUpdateRequest(BaseModel): + value: Any + + +class BulkUpdateRequest(BaseModel): + settings: Dict[str, Any] + + +# ── Helper ──────────────────────────────────────────────────────────────────── + +def _row_to_setting(row) -> SettingResponse: + value = row["value"] + if isinstance(value, str): + value = json.loads(value) + return SettingResponse( + key=row["key"], + namespace=row["namespace"], + value=value, + description=row.get("description"), + updated_at=row["updated_at"].isoformat(), + ) + + +# ── Endpoints ───────────────────────────────────────────────────────────────── + +@router.get("", response_model=List[SettingResponse]) +async def list_settings( + db: DatabaseConnection = Depends(get_db_conn), +): + """Return all settings, ordered by namespace then key.""" + correlation_id = generate_correlation_id() + rows = await db.fetch( + "SELECT * FROM system_settings ORDER BY namespace, key" + ) + log.info("api_list_settings", { + "count": len(rows), + "correlation_id": correlation_id, + }) + return [_row_to_setting(r) for r in rows] + + +@router.get("/namespace/{namespace}", + response_model=List[SettingResponse]) +async def list_settings_by_namespace( + namespace: str, + db: DatabaseConnection = Depends(get_db_conn), +): + """Return all settings for a given namespace.""" + correlation_id = generate_correlation_id() + rows = await db.fetch( + "SELECT * FROM system_settings WHERE namespace = $1" + " ORDER BY key", + namespace, + ) + log.info("api_list_settings_namespace", { + "namespace": namespace, + "count": len(rows), + "correlation_id": correlation_id, + }) + return [_row_to_setting(r) for r in rows] + + +@router.get("/{key}", response_model=SettingResponse) +async def get_setting( + key: str, + db: DatabaseConnection = Depends(get_db_conn), +): + """Get a single setting by key.""" + row = await db.fetchrow( + "SELECT * FROM system_settings WHERE key = $1", key + ) + if row is None: + raise HTTPException(status_code=404, detail="setting not found") + return _row_to_setting(row) + + +@router.put("/{key}", response_model=SettingResponse) +async def update_setting( + key: str, + body: SettingUpdateRequest, + db: DatabaseConnection = Depends(get_db_conn), +): + """Update the value of a single setting.""" + correlation_id = generate_correlation_id() + result = await db.execute( + """ + UPDATE system_settings + SET value = $1, updated_at = now() + WHERE key = $2 + """, + json.dumps(body.value), + key, + ) + if result == "UPDATE 0": + raise HTTPException(status_code=404, detail="setting not found") + + row = await db.fetchrow( + "SELECT * FROM system_settings WHERE key = $1", key + ) + log.info("api_update_setting", { + "key": key, + "correlation_id": correlation_id, + }) + return _row_to_setting(row) + + +@router.put("", response_model=List[SettingResponse]) +async def bulk_update_settings( + body: BulkUpdateRequest, + db: DatabaseConnection = Depends(get_db_conn), +): + """Update multiple settings in a single call. + + Body: { "settings": { "runtime.log_level": "DEBUG", ... } } + Unknown keys are silently ignored (no upsert). + """ + correlation_id = generate_correlation_id() + updated = [] + for key, value in body.settings.items(): + result = await db.execute( + """ + UPDATE system_settings + SET value = $1, updated_at = now() + WHERE key = $2 + """, + json.dumps(value), + key, + ) + if result != "UPDATE 0": + updated.append(key) + + rows = await db.fetch( + "SELECT * FROM system_settings ORDER BY namespace, key" + ) + log.info("api_bulk_update_settings", { + "updated_count": len(updated), + "keys": updated, + "correlation_id": correlation_id, + }) + return [_row_to_setting(r) for r in rows] diff --git a/webapp/src/api/client.ts b/webapp/src/api/client.ts index 7c214d2d..9ae88676 100644 --- a/webapp/src/api/client.ts +++ b/webapp/src/api/client.ts @@ -36,10 +36,11 @@ async function request( return json as T } -const get = (path: string) => request('GET', path) -const post = (path: string, body?: unknown) => request('POST', path, body) -const patch = (path: string, body?: unknown) => request('PATCH', path, body) -const del = (path: string) => request('DELETE', path) +const get = (path: string) => request('GET', path) +const post = (path: string, body?: unknown) => request('POST', path, body) +const patch = (path: string, body?: unknown) => request('PATCH', path, body) +const put = (path: string, body?: unknown) => request('PUT', path, body) +const del = (path: string) => request('DELETE', path) // ── Types matching FastAPI response models ───────────────────────────────── @@ -194,6 +195,14 @@ export interface ApiLogEntry { created_at: string } +export interface ApiSetting { + key: string + namespace: string + value: unknown + description: string | null + updated_at: string +} + export interface ApiLogList { items: ApiLogEntry[] total: number @@ -326,3 +335,11 @@ export const logsApi = { }, stats: () => get('/logs/stats'), } + +export const settingsApi = { + list: () => get('/settings'), + namespace: (ns: string) => get(`/settings/namespace/${ns}`), + get: (key: string) => get(`/settings/${key}`), + update: (key: string, value: unknown) => put(`/settings/${key}`, { value }), + bulkUpdate: (settings: Record) => put('/settings', { settings }), +} diff --git a/webapp/src/pages/Settings.tsx b/webapp/src/pages/Settings.tsx index 6eeacb89..c03a419e 100644 --- a/webapp/src/pages/Settings.tsx +++ b/webapp/src/pages/Settings.tsx @@ -1,10 +1,12 @@ -import { useState } from 'react' +import { useState, useEffect } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { Settings as SettingsIcon, Database, Server, Shield, Bell, Code2, Save, RefreshCw, AlertTriangle, - CheckCircle2, ExternalLink, + CheckCircle2, ExternalLink, Loader2, } from 'lucide-react' import { cn } from '@/lib/utils' +import { settingsApi, type ApiSetting } from '@/api/client' // ─── Sub-components ─────────────────────────────────────────────────────────── @@ -42,17 +44,20 @@ function Field({ label, hint, children }: { ) } -function TextInput({ defaultValue, placeholder, mono = true, disabled }: { - defaultValue?: string +function TextInput({ settingKey, value, onChange, placeholder, mono = true, disabled }: { + settingKey?: string + value?: string + onChange?: (key: string, val: string) => void placeholder?: string mono?: boolean disabled?: boolean }) { return ( settingKey && onChange?.(settingKey, e.target.value)} className={cn( 'w-full bg-white/[0.04] border border-white/[0.08] rounded-lg px-3 py-2.5 text-sm text-slate-200', 'focus:outline-none focus:border-[#6470f1]/50 placeholder:text-slate-600 transition-colors', @@ -63,17 +68,30 @@ function TextInput({ defaultValue, placeholder, mono = true, disabled }: { ) } -function SelectInput({ options, defaultValue }: { options: string[]; defaultValue?: string }) { +function SelectInput({ settingKey, value, options, onChange }: { + settingKey?: string + value?: string + options: string[] + onChange?: (key: string, val: string) => void +}) { return ( - ) } -function Toggle({ label, defaultChecked, desc }: { label: string; defaultChecked?: boolean; desc?: string }) { - const [checked, setChecked] = useState(defaultChecked ?? false) +function Toggle({ settingKey, label, checked, onChange, desc }: { + settingKey?: string + label: string + checked?: boolean + onChange?: (key: string, val: boolean) => void + desc?: string +}) { + const isOn = checked ?? false return (
@@ -81,25 +99,29 @@ function Toggle({ label, defaultChecked, desc }: { label: string; defaultChecked {desc &&
{desc}
}
) } -function SaveBar({ onSave }: { onSave: () => void }) { +function SaveBar({ onSave, onReset, saving }: { + onSave: () => void + onReset: () => void + saving?: boolean +}) { return (
- -
@@ -113,20 +135,62 @@ type SettingsTab = 'runtime' | 'database' | 'security' | 'notifications' | 'deve export function Settings() { const [tab, setTab] = useState('runtime') const [saved, setSaved] = useState(false) + const qc = useQueryClient() - const handleSave = () => { - setSaved(true) - setTimeout(() => setSaved(false), 2500) - } + // Local draft — keyed by setting key, value is whatever type the setting is + const [draft, setDraft] = useState>({}) + + const { data: allSettings, isLoading } = useQuery({ + queryKey: ['settings'], + queryFn: () => settingsApi.list(), + }) + + // Build a lookup map for easy access in JSX + const settingMap: Record = {} + for (const s of allSettings ?? []) settingMap[s.key] = s + + // Helpers to read current value (draft takes priority over DB) + const str = (key: string, fallback = '') => + String(draft[key] ?? settingMap[key]?.value ?? fallback) + const bool = (key: string, fallback = false) => + Boolean(draft[key] ?? settingMap[key]?.value ?? fallback) + + // Update draft on field change + const onChange = (key: string, val: unknown) => + setDraft(d => ({ ...d, [key]: val })) + + // Reset draft to current DB values + const handleReset = () => setDraft({}) + + const saveMut = useMutation({ + mutationFn: () => settingsApi.bulkUpdate(draft), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['settings'] }) + setDraft({}) + setSaved(true) + setTimeout(() => setSaved(false), 2500) + }, + }) const TABS: { id: SettingsTab; label: string; icon: React.ElementType }[] = [ - { id: 'runtime', label: 'Runtime', icon: Server }, - { id: 'database', label: 'Database', icon: Database }, - { id: 'security', label: 'Security', icon: Shield }, - { id: 'notifications', label: 'Notifications', icon: Bell }, - { id: 'developer', label: 'Developer', icon: Code2 }, + { id: 'runtime', label: 'Runtime', icon: Server }, + { id: 'database', label: 'Database', icon: Database }, + { id: 'security', label: 'Security', icon: Shield }, + { id: 'notifications', label: 'Notifications', icon: Bell }, + { id: 'developer', label: 'Developer', icon: Code2 }, ] + const hasDraft = Object.keys(draft).length > 0 + + if (isLoading) { + return ( +
+ + Loading settings... +
+ ) + } + return (
@@ -139,12 +203,19 @@ export function Settings() {

Runtime configuration · environment · integrations

- {saved && ( -
- - Changes saved -
- )} +
+ {hasDraft && ( + + Unsaved changes + + )} + {saved && ( +
+ + Changes saved +
+ )} +
@@ -169,44 +240,51 @@ export function Settings() {
{tab === 'runtime' && ( - <> -
- - - - - - - - - - - - - - - -
- - - -
- -
- +
+ + + + + + + + + + + + + + + +
+ + + +
+ saveMut.mutate()} onReset={handleReset} saving={saveMut.isPending} /> +
)} {tab === 'database' && ( <>
- - - - + - +
- + saveMut.mutate()} onReset={handleReset} saving={saveMut.isPending} />
- + - + - + saveMut.mutate()} onReset={handleReset} saving={saveMut.isPending} />
- + - + - + - + saveMut.mutate()} onReset={handleReset} saving={saveMut.isPending} />
)} @@ -245,7 +330,7 @@ export function Settings() {
- + @@ -254,12 +339,20 @@ export function Settings() {
- - - - + + + +
- + saveMut.mutate()} onReset={handleReset} saving={saveMut.isPending} />
@@ -278,18 +371,27 @@ export function Settings() { {tab === 'notifications' && (
- + - +
- - - - + + + +
- + saveMut.mutate()} onReset={handleReset} saving={saveMut.isPending} />
)} @@ -322,9 +424,15 @@ export function Settings() {
- - - + + +
@@ -332,7 +440,7 @@ export function Settings() { Debug options should never be enabled in production environments.

- + saveMut.mutate()} onReset={handleReset} saving={saveMut.isPending} />
)}