diff --git a/dashboard/backend/main.go b/dashboard/backend/main.go index 9abd7384..c0e027c9 100644 --- a/dashboard/backend/main.go +++ b/dashboard/backend/main.go @@ -59,6 +59,83 @@ func configHandler(configPath string) http.HandlerFunc { } } +// updateConfigHandler updates the config.yaml file +func updateConfigHandler(configPath string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Only allow POST/PUT requests + if r.Method != http.MethodPost && r.Method != http.MethodPut { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Read the request body + var configData map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&configData); err != nil { + log.Printf("Error decoding request body: %v", err) + http.Error(w, fmt.Sprintf("Invalid request body: %v", err), http.StatusBadRequest) + return + } + + // Convert to YAML + yamlData, err := yaml.Marshal(configData) + if err != nil { + log.Printf("Error marshaling config to YAML: %v", err) + http.Error(w, fmt.Sprintf("Failed to convert config to YAML: %v", err), http.StatusInternalServerError) + return + } + + // Write to file + if err := os.WriteFile(configPath, yamlData, 0644); err != nil { + log.Printf("Error writing config file: %v", err) + http.Error(w, fmt.Sprintf("Failed to write config file: %v", err), http.StatusInternalServerError) + return + } + + log.Printf("Configuration updated successfully") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"status": "success", "message": "Configuration updated successfully"}) + } +} + +// toolsDBHandler reads and serves the tools_db.json file +func toolsDBHandler(configDir string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Only allow GET requests + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Construct the tools_db.json path + toolsDBPath := filepath.Join(configDir, "tools_db.json") + + // Read the tools database file + data, err := os.ReadFile(toolsDBPath) + if err != nil { + log.Printf("Error reading tools_db.json: %v", err) + http.Error(w, fmt.Sprintf("Failed to read tools database: %v", err), http.StatusInternalServerError) + return + } + + // Parse JSON to validate it + var tools interface{} + if err := json.Unmarshal(data, &tools); err != nil { + log.Printf("Error parsing tools_db.json: %v", err) + http.Error(w, fmt.Sprintf("Failed to parse tools database: %v", err), http.StatusInternalServerError) + return + } + + // Send response + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(tools); err != nil { + log.Printf("Error encoding tools to JSON: %v", err) + http.Error(w, fmt.Sprintf("Failed to encode tools: %v", err), http.StatusInternalServerError) + return + } + } +} + // newReverseProxy creates a reverse proxy to targetBase and strips the given prefix from the incoming path func newReverseProxy(targetBase, stripPrefix string, forwardAuth bool) (*httputil.ReverseProxy, error) { targetURL, err := url.Parse(targetBase) @@ -213,6 +290,15 @@ func main() { mux.HandleFunc("/api/router/config/all", configHandler(absConfigPath)) log.Printf("Config API endpoint registered: /api/router/config/all") + // Config update endpoint - update the config.yaml file + mux.HandleFunc("/api/router/config/update", updateConfigHandler(absConfigPath)) + log.Printf("Config update API endpoint registered: /api/router/config/update") + + // Tools DB endpoint - serve the tools_db.json + configDir := filepath.Dir(absConfigPath) + mux.HandleFunc("/api/tools-db", toolsDBHandler(configDir)) + log.Printf("Tools DB API endpoint registered: /api/tools-db") + // Router API proxy (forward Authorization) - MUST be registered before Grafana var routerAPIProxy *httputil.ReverseProxy if *routerAPI != "" { diff --git a/dashboard/frontend/src/components/ConfigNav.tsx b/dashboard/frontend/src/components/ConfigNav.tsx index 5144b372..28099fa6 100644 --- a/dashboard/frontend/src/components/ConfigNav.tsx +++ b/dashboard/frontend/src/components/ConfigNav.tsx @@ -8,6 +8,7 @@ export type ConfigSection = | 'intelligent-routing' | 'tools-selection' | 'observability' + | 'classification-api' interface ConfigNavProps { activeSection: ConfigSection @@ -18,39 +19,45 @@ const ConfigNav: React.FC = ({ activeSection, onSectionChange }) const sections = [ { id: 'models' as ConfigSection, - icon: '🔌', - title: 'Models & Endpoints', - description: 'Model configurations and backend endpoints' + icon: '🤖', + title: 'Models', + description: 'User defined models and endpoints' }, { id: 'prompt-guard' as ConfigSection, icon: '🛡️', title: 'Prompt Guard', - description: 'PII and jailbreak detection' + description: 'PII and jailbreak ModernBERT detection' }, { id: 'similarity-cache' as ConfigSection, icon: '⚡', title: 'Similarity Cache', - description: 'Semantic caching configuration' + description: 'Similarity BERT configuration' }, { id: 'intelligent-routing' as ConfigSection, - icon: '📊', + icon: '🧠', title: 'Intelligent Routing', - description: 'Categories and reasoning configuration' + description: 'Classify BERT, categories & reasoning' }, { id: 'tools-selection' as ConfigSection, icon: '🔧', title: 'Tools Selection', - description: 'Tool auto-selection settings' + description: 'Tools configuration and database' }, { id: 'observability' as ConfigSection, - icon: '📈', + icon: '📊', title: 'Observability', - description: 'Metrics and monitoring' + description: 'Tracing and metrics' + }, + { + id: 'classification-api' as ConfigSection, + icon: '🔌', + title: 'Classification API', + description: 'Batch classification settings' } ] diff --git a/dashboard/frontend/src/components/EditModal.module.css b/dashboard/frontend/src/components/EditModal.module.css new file mode 100644 index 00000000..77b0e876 --- /dev/null +++ b/dashboard/frontend/src/components/EditModal.module.css @@ -0,0 +1,246 @@ +.overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + backdrop-filter: blur(4px); +} + +.modal { + background-color: var(--color-bg); + border-radius: var(--radius-lg); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + max-width: 600px; + width: 90%; + max-height: 90vh; + display: flex; + flex-direction: column; + border: 1px solid var(--color-border); +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.5rem; + border-bottom: 1px solid var(--color-border); +} + +.title { + font-size: 1.25rem; + font-weight: 700; + color: var(--color-text); + margin: 0; +} + +.closeButton { + background: none; + border: none; + font-size: 1.5rem; + color: var(--color-text-secondary); + cursor: pointer; + padding: 0.25rem 0.5rem; + border-radius: var(--radius-sm); + transition: all var(--transition-fast); +} + +.closeButton:hover { + background-color: rgba(239, 68, 68, 0.1); + color: #ef4444; +} + +.form { + display: flex; + flex-direction: column; + flex: 1; + overflow: hidden; +} + +.error { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem 1.5rem; + background-color: rgba(239, 68, 68, 0.1); + border-bottom: 1px solid rgba(239, 68, 68, 0.3); + color: #ef4444; + font-size: 0.9rem; +} + +.errorIcon { + font-size: 1.25rem; +} + +.fields { + flex: 1; + overflow-y: auto; + padding: 1.5rem; +} + +.field { + margin-bottom: 1.5rem; +} + +.field:last-child { + margin-bottom: 0; +} + +.label { + display: block; + font-size: 0.875rem; + font-weight: 600; + color: var(--color-text); + margin-bottom: 0.5rem; +} + +.required { + color: #ef4444; + margin-left: 0.25rem; +} + +.description { + font-size: 0.8rem; + color: var(--color-text-secondary); + margin: 0.25rem 0 0.5rem 0; + font-style: italic; +} + +.input, +.select, +.textarea { + width: 100%; + padding: 0.75rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background-color: var(--color-bg); + color: var(--color-text); + font-size: 0.9rem; + font-family: inherit; + transition: border-color var(--transition-fast); +} + +.input:focus, +.select:focus, +.textarea:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); +} + +.textarea { + resize: vertical; + font-family: 'Courier New', monospace; + font-size: 0.85rem; +} + +.multiselect { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 1rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background-color: var(--color-bg); + max-height: 300px; + overflow-y: auto; +} + +.multiselectOption { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem; + border-radius: var(--radius-sm); + cursor: pointer; + transition: background-color var(--transition-fast); +} + +.multiselectOption:hover { + background-color: rgba(99, 102, 241, 0.05); +} + +.multiselectOption input[type="checkbox"] { + width: 1.125rem; + height: 1.125rem; + cursor: pointer; +} + +.multiselectOption span { + font-size: 0.9rem; + color: var(--color-text); +} + +.checkbox { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + user-select: none; +} + +.checkbox input[type="checkbox"] { + width: 1.25rem; + height: 1.25rem; + cursor: pointer; +} + +.checkbox span { + font-size: 0.9rem; + color: var(--color-text); +} + +.actions { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + padding: 1.5rem; + border-top: 1px solid var(--color-border); + background-color: rgba(99, 102, 241, 0.02); +} + +.cancelButton, +.saveButton { + padding: 0.75rem 1.5rem; + border-radius: var(--radius-md); + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + transition: all var(--transition-fast); + border: none; +} + +.cancelButton { + background-color: transparent; + color: var(--color-text-secondary); + border: 1px solid var(--color-border); +} + +.cancelButton:hover:not(:disabled) { + background-color: rgba(0, 0, 0, 0.05); + border-color: var(--color-text-secondary); +} + +.saveButton { + background-color: var(--color-primary); + color: white; + box-shadow: 0 2px 8px rgba(99, 102, 241, 0.3); +} + +.saveButton:hover:not(:disabled) { + background-color: var(--color-primary-dark); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4); +} + +.cancelButton:disabled, +.saveButton:disabled { + opacity: 0.5; + cursor: not-allowed; +} + diff --git a/dashboard/frontend/src/components/EditModal.tsx b/dashboard/frontend/src/components/EditModal.tsx new file mode 100644 index 00000000..c9fb89f5 --- /dev/null +++ b/dashboard/frontend/src/components/EditModal.tsx @@ -0,0 +1,225 @@ +import React, { useState, useEffect } from 'react' +import styles from './EditModal.module.css' + +interface EditModalProps { + isOpen: boolean + onClose: () => void + onSave: (data: any) => Promise + title: string + data: any + fields: FieldConfig[] + mode?: 'edit' | 'add' +} + +export interface FieldConfig { + name: string + label: string + type: 'text' | 'number' | 'boolean' | 'select' | 'multiselect' | 'textarea' | 'json' + required?: boolean + options?: string[] + placeholder?: string + description?: string +} + +const EditModal: React.FC = ({ + isOpen, + onClose, + onSave, + title, + data, + fields, + mode = 'edit' +}) => { + const [formData, setFormData] = useState({}) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + if (isOpen) { + setFormData(data || {}) + setError(null) + } + }, [isOpen, data]) + + const handleChange = (fieldName: string, value: any) => { + setFormData((prev: any) => ({ + ...prev, + [fieldName]: value + })) + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setSaving(true) + setError(null) + + try { + await onSave(formData) + onClose() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to save') + } finally { + setSaving(false) + } + } + + if (!isOpen) return null + + return ( +
+
e.stopPropagation()}> +
+

{title}

+ +
+ +
+ {error && ( +
+ ⚠️ + {error} +
+ )} + +
+ {fields.map((field) => ( +
+ + {field.description && ( +

{field.description}

+ )} + + {field.type === 'text' && ( + handleChange(field.name, e.target.value)} + placeholder={field.placeholder} + required={field.required} + /> + )} + + {field.type === 'number' && ( + handleChange(field.name, parseFloat(e.target.value))} + placeholder={field.placeholder} + required={field.required} + /> + )} + + {field.type === 'boolean' && ( + + )} + + {field.type === 'select' && ( + + )} + + {field.type === 'multiselect' && ( +
+ {field.options?.map((option) => ( + + ))} +
+ )} + + {field.type === 'textarea' && ( +