diff --git a/internal/api/webui.go b/internal/api/webui.go
new file mode 100644
index 0000000..7cb8a8c
--- /dev/null
+++ b/internal/api/webui.go
@@ -0,0 +1,3113 @@
+/*
+ * Copyright 2026 Safronov Grigorii
+ *
+ * Licensed under the CDDL, Version 1.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ *
+ * You may obtain a copy of the License at
+ * https://opensource.org/licenses/CDDL-1.0
+ */
+
+// Файл: internal/api/webui.go
+// Назначение: Веб-интерфейс в стиле dashboard для управления СУБД futriis
+
+package api
+
+import (
+ "bufio"
+ "crypto/rand"
+ "embed"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "io/fs"
+ "net/http"
+ "os"
+ "path/filepath"
+ "runtime"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "futriis/internal/acl"
+ "futriis/internal/cluster"
+ "futriis/internal/log"
+ "futriis/internal/plugin"
+ "futriis/internal/storage"
+)
+
+//go:embed static/*
+var staticFiles embed.FS
+
+// LogEntry представляет запись в логе веб-интерфейса
+type LogEntry struct {
+ Timestamp int64 `json:"timestamp"`
+ Operation string `json:"operation"`
+ Target string `json:"target"`
+ User string `json:"user"`
+ Status string `json:"status"`
+ ErrorMsg string `json:"error_msg,omitempty"`
+ Details map[string]interface{} `json:"details,omitempty"`
+}
+
+// LogBuffer буферизирует логи веб-интерфейса
+type LogBuffer struct {
+ entries []LogEntry
+ mu sync.RWMutex
+ maxSize int
+ filePath string
+ file *os.File
+ writer *bufio.Writer
+ closed bool
+}
+
+// NewLogBuffer создаёт новый буфер логов
+func NewLogBuffer(filePath string, maxSize int) (*LogBuffer, error) {
+ dir := filepath.Dir(filePath)
+ if err := os.MkdirAll(dir, 0755); err != nil {
+ return nil, fmt.Errorf("failed to create log directory: %v", err)
+ }
+
+ file, err := os.OpenFile(filePath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
+ if err != nil {
+ return nil, fmt.Errorf("failed to open log file: %v", err)
+ }
+
+ return &LogBuffer{
+ entries: make([]LogEntry, 0, maxSize),
+ maxSize: maxSize,
+ filePath: filePath,
+ file: file,
+ writer: bufio.NewWriter(file),
+ }, nil
+}
+
+// Add добавляет запись в лог
+func (lb *LogBuffer) Add(entry LogEntry) {
+ if lb == nil {
+ return
+ }
+ lb.mu.Lock()
+ defer lb.mu.Unlock()
+
+ if lb.closed {
+ return
+ }
+
+ lb.entries = append(lb.entries, entry)
+ if len(lb.entries) > lb.maxSize {
+ lb.entries = lb.entries[len(lb.entries)-lb.maxSize:]
+ }
+
+ data, err := json.Marshal(entry)
+ if err == nil && lb.writer != nil {
+ lb.writer.Write(data)
+ lb.writer.Write([]byte("\n"))
+ lb.writer.Flush()
+ }
+}
+
+// GetEntries возвращает все записи лога
+func (lb *LogBuffer) GetEntries() []LogEntry {
+ if lb == nil {
+ return []LogEntry{}
+ }
+ lb.mu.RLock()
+ defer lb.mu.RUnlock()
+
+ result := make([]LogEntry, len(lb.entries))
+ copy(result, lb.entries)
+ return result
+}
+
+// Close закрывает буфер логов
+func (lb *LogBuffer) Close() error {
+ if lb == nil {
+ return nil
+ }
+ lb.mu.Lock()
+ defer lb.mu.Unlock()
+ lb.closed = true
+ if lb.writer != nil {
+ lb.writer.Flush()
+ }
+ if lb.file != nil {
+ return lb.file.Close()
+ }
+ return nil
+}
+
+// ========== SystemMetrics ==========
+
+// SystemMetrics представляет системные метрики
+type SystemMetrics struct {
+ mu sync.RWMutex
+ RequestCount uint64 `json:"request_count"`
+ ErrorCount uint64 `json:"error_count"`
+ AverageLatencyMs float64 `json:"average_latency_ms"`
+ P99LatencyMs float64 `json:"p99_latency_ms"`
+ ActiveConnections int `json:"active_connections"`
+ GoroutineCount int `json:"goroutine_count"`
+ MemoryAllocBytes uint64 `json:"memory_alloc_bytes"`
+ MemoryTotalBytes uint64 `json:"memory_total_bytes"`
+ GCPerSecond float64 `json:"gc_per_second"`
+ CPUPercent float64 `json:"cpu_percent"`
+ StartTime int64 `json:"start_time"`
+ LastMetricTime int64 `json:"last_metric_time"`
+ EndpointMetrics map[string]*EndpointMetric `json:"endpoint_metrics"`
+ latencyHistory []float64
+ maxHistorySize int
+}
+
+// EndpointMetric представляет метрики для конкретного эндпоинта
+type EndpointMetric struct {
+ Count uint64 `json:"count"`
+ ErrorCount uint64 `json:"error_count"`
+ TotalLatencyMs float64 `json:"total_latency_ms"`
+ AvgLatencyMs float64 `json:"avg_latency_ms"`
+ MaxLatencyMs float64 `json:"max_latency_ms"`
+ MinLatencyMs float64 `json:"min_latency_ms"`
+}
+
+// NewSystemMetrics создаёт новый сборщик метрик
+func NewSystemMetrics() *SystemMetrics {
+ return &SystemMetrics{
+ StartTime: time.Now().UnixMilli(),
+ EndpointMetrics: make(map[string]*EndpointMetric),
+ latencyHistory: make([]float64, 0, 1000),
+ maxHistorySize: 10000,
+ }
+}
+
+// RecordRequest записывает информацию о запросе
+func (sm *SystemMetrics) RecordRequest(endpoint string, latencyMs float64, isError bool) {
+ sm.mu.Lock()
+ defer sm.mu.Unlock()
+
+ sm.RequestCount++
+ if isError {
+ sm.ErrorCount++
+ }
+
+ em, exists := sm.EndpointMetrics[endpoint]
+ if !exists {
+ em = &EndpointMetric{
+ MinLatencyMs: latencyMs,
+ MaxLatencyMs: latencyMs,
+ }
+ sm.EndpointMetrics[endpoint] = em
+ }
+
+ em.Count++
+ if isError {
+ em.ErrorCount++
+ }
+ em.TotalLatencyMs += latencyMs
+ em.AvgLatencyMs = em.TotalLatencyMs / float64(em.Count)
+ if latencyMs > em.MaxLatencyMs {
+ em.MaxLatencyMs = latencyMs
+ }
+ if latencyMs < em.MinLatencyMs {
+ em.MinLatencyMs = latencyMs
+ }
+
+ sm.AverageLatencyMs = (sm.AverageLatencyMs*float64(sm.RequestCount-1) + latencyMs) / float64(sm.RequestCount)
+
+ sm.latencyHistory = append(sm.latencyHistory, latencyMs)
+ if len(sm.latencyHistory) > sm.maxHistorySize {
+ sm.latencyHistory = sm.latencyHistory[len(sm.latencyHistory)-sm.maxHistorySize:]
+ }
+
+ if len(sm.latencyHistory) > 0 {
+ sorted := make([]float64, len(sm.latencyHistory))
+ copy(sorted, sm.latencyHistory)
+ sort.Float64s(sorted)
+ p99Index := int(float64(len(sorted)) * 0.99)
+ if p99Index < len(sorted) {
+ sm.P99LatencyMs = sorted[p99Index]
+ }
+ }
+}
+
+// UpdateSystemInfo обновляет системную информацию
+func (sm *SystemMetrics) UpdateSystemInfo() {
+ sm.mu.Lock()
+ defer sm.mu.Unlock()
+
+ var memStats runtime.MemStats
+ runtime.ReadMemStats(&memStats)
+
+ sm.MemoryAllocBytes = memStats.Alloc
+ sm.MemoryTotalBytes = memStats.TotalAlloc
+ sm.GoroutineCount = runtime.NumGoroutine()
+ sm.LastMetricTime = time.Now().UnixMilli()
+
+ gcPauseTotal := memStats.PauseTotalNs
+ timeDiff := float64(sm.LastMetricTime-sm.StartTime) / 1000.0
+ if timeDiff > 0 {
+ sm.GCPerSecond = float64(gcPauseTotal) / timeDiff / 1e9
+ }
+}
+
+// GetMetrics возвращает копию метрик
+func (sm *SystemMetrics) GetMetrics() map[string]interface{} {
+ sm.mu.RLock()
+ defer sm.mu.RUnlock()
+
+ sm.UpdateSystemInfo()
+
+ return map[string]interface{}{
+ "request_count": sm.RequestCount,
+ "error_count": sm.ErrorCount,
+ "average_latency_ms": sm.AverageLatencyMs,
+ "p99_latency_ms": sm.P99LatencyMs,
+ "active_connections": sm.ActiveConnections,
+ "goroutine_count": sm.GoroutineCount,
+ "memory_alloc_bytes": sm.MemoryAllocBytes,
+ "memory_total_bytes": sm.MemoryTotalBytes,
+ "gc_per_second": sm.GCPerSecond,
+ "start_time": sm.StartTime,
+ "last_metric_time": sm.LastMetricTime,
+ "uptime_seconds": (time.Now().UnixMilli() - sm.StartTime) / 1000,
+ "endpoint_metrics": sm.EndpointMetrics,
+ }
+}
+
+// RecordConnection записывает изменение количества соединений
+func (sm *SystemMetrics) RecordConnection(delta int) {
+ sm.mu.Lock()
+ defer sm.mu.Unlock()
+ sm.ActiveConnections += delta
+}
+
+// ========== Health Check ==========
+
+// HealthStatus представляет статус здоровья системы
+type HealthStatus struct {
+ Status string `json:"status"`
+ Timestamp int64 `json:"timestamp"`
+ Components map[string]string `json:"components"`
+ Details map[string]interface{} `json:"details"`
+ Message string `json:"message,omitempty"`
+}
+
+// HealthChecker проверяет здоровье компонентов
+type HealthChecker struct {
+ store *storage.Storage
+ coordinator *cluster.RaftCoordinator
+ logger *log.Logger
+ mu sync.RWMutex
+ lastStatus *HealthStatus
+}
+
+// NewHealthChecker создаёт новый проверяльщик здоровья
+func NewHealthChecker(store *storage.Storage, coord *cluster.RaftCoordinator, logger *log.Logger) *HealthChecker {
+ hc := &HealthChecker{
+ store: store,
+ coordinator: coord,
+ logger: logger,
+ }
+
+ go hc.periodicCheck()
+
+ return hc
+}
+
+// periodicCheck периодически проверяет здоровье
+func (hc *HealthChecker) periodicCheck() {
+ ticker := time.NewTicker(30 * time.Second)
+ defer ticker.Stop()
+
+ for range ticker.C {
+ hc.Check()
+ }
+}
+
+// Check выполняет проверку здоровья
+func (hc *HealthChecker) Check() *HealthStatus {
+ hc.mu.Lock()
+ defer hc.mu.Unlock()
+
+ status := &HealthStatus{
+ Timestamp: time.Now().UnixMilli(),
+ Components: make(map[string]string),
+ Details: make(map[string]interface{}),
+ }
+
+ overallStatus := "healthy"
+
+ if hc.store == nil {
+ status.Components["storage"] = "unavailable"
+ overallStatus = "unhealthy"
+ } else {
+ databases := hc.store.ListDatabases()
+ status.Details["database_count"] = len(databases)
+ if len(databases) >= 0 {
+ status.Components["storage"] = "healthy"
+ } else {
+ status.Components["storage"] = "degraded"
+ if overallStatus == "healthy" {
+ overallStatus = "degraded"
+ }
+ }
+ }
+
+ if hc.coordinator == nil {
+ status.Components["cluster"] = "disabled"
+ } else {
+ clusterStatus := hc.coordinator.GetClusterStatus()
+ status.Details["cluster_health"] = clusterStatus.Health
+ status.Details["active_nodes"] = clusterStatus.ActiveNodes
+ status.Details["total_nodes"] = clusterStatus.TotalNodes
+
+ if clusterStatus.Health == "critical" {
+ status.Components["cluster"] = "critical"
+ overallStatus = "unhealthy"
+ } else if clusterStatus.Health == "degraded" {
+ status.Components["cluster"] = "degraded"
+ if overallStatus == "healthy" {
+ overallStatus = "degraded"
+ }
+ } else {
+ status.Components["cluster"] = "healthy"
+ }
+ }
+
+ status.Status = overallStatus
+ hc.lastStatus = status
+
+ return status
+}
+
+// GetLastStatus возвращает последний статус здоровья
+func (hc *HealthChecker) GetLastStatus() *HealthStatus {
+ hc.mu.RLock()
+ defer hc.mu.RUnlock()
+ return hc.lastStatus
+}
+
+// ========== WebUIServer ==========
+
+// WebUIServer представляет сервер веб-интерфейса
+type WebUIServer struct {
+ store *storage.Storage
+ coordinator *cluster.RaftCoordinator
+ aclManager *acl.ACLManager
+ logger *log.Logger
+ server *http.Server
+ port int
+ enabled bool
+ credentialMgr *CredentialManager
+ logBuffer *LogBuffer
+ pluginManager *plugin.PluginManager
+ metrics *SystemMetrics
+ healthChecker *HealthChecker
+ rateLimiter *RateLimiter
+}
+
+// NewWebUIServer создаёт новый веб-сервер интерфейса
+func NewWebUIServer(port int, enabled bool, store *storage.Storage, coord *cluster.RaftCoordinator, aclMgr *acl.ACLManager, logger *log.Logger) *WebUIServer {
+ credMgr := NewCredentialManager()
+
+ if err := credMgr.Load(); err != nil {
+ logger.Warn(fmt.Sprintf("Failed to load credentials: %v, using defaults", err))
+ if err := credMgr.CreateDefault(); err != nil {
+ logger.Error(fmt.Sprintf("Failed to create default credentials: %v", err))
+ }
+ }
+
+ logBuffer, err := NewLogBuffer("futriis/webui.log", 10000)
+ if err != nil {
+ logger.Warn(fmt.Sprintf("Failed to create log buffer: %v", err))
+ // Создаём пустой буфер в памяти
+ logBuffer = &LogBuffer{entries: make([]LogEntry, 0, 1000)}
+ }
+
+ pluginManager := plugin.NewPluginManager("futriis/plugins", logger, store, true)
+
+ w := &WebUIServer{
+ store: store,
+ coordinator: coord,
+ aclManager: aclMgr,
+ logger: logger,
+ port: port,
+ enabled: enabled,
+ credentialMgr: credMgr,
+ logBuffer: logBuffer,
+ pluginManager: pluginManager,
+ metrics: NewSystemMetrics(),
+ rateLimiter: NewRateLimiter(DefaultRateLimiterConfig()),
+ }
+
+ w.healthChecker = NewHealthChecker(store, coord, logger)
+
+ return w
+}
+
+// generateSecureSessionID генерирует криптостойкий session ID
+func generateSecureSessionID(username string) string {
+ bytes := make([]byte, 32)
+ _, err := rand.Read(bytes)
+ if err != nil {
+ // Используем комбинацию времени + username + PID
+ return fmt.Sprintf("fallback_%d_%s_%d", time.Now().UnixNano(), username, os.Getpid())
+ }
+
+ sessionID := base64.URLEncoding.EncodeToString(bytes)
+ return fmt.Sprintf("%s_%s", sessionID[:24], username)
+}
+
+// middlewareWithMetrics добавляет метрики к обработчикам
+func (w *WebUIServer) middlewareWithMetrics(handler http.HandlerFunc, endpoint string) http.HandlerFunc {
+ return func(wr http.ResponseWriter, r *http.Request) {
+ start := time.Now()
+
+ rw := &responseWriter{ResponseWriter: wr, statusCode: http.StatusOK}
+
+ // Применяем rate limiting
+ if !w.rateLimiter.Allow(r.RemoteAddr) {
+ w.rateLimiter.rejected.Add(1)
+ wr.Header().Set("X-RateLimit-Limit", fmt.Sprintf("%d", w.rateLimiter.config.RequestsPerSecond))
+ wr.Header().Set("X-RateLimit-Remaining", "0")
+ wr.Header().Set("Retry-After", "1")
+ http.Error(wr, "Rate limit exceeded. Please try again later.", http.StatusTooManyRequests)
+ return
+ }
+ w.rateLimiter.allowed.Add(1)
+
+ handler(rw, r)
+
+ latency := float64(time.Since(start).Microseconds()) / 1000.0
+ isError := rw.statusCode >= 400
+
+ w.metrics.RecordRequest(endpoint, latency, isError)
+
+ if w.logger != nil && isError {
+ w.logger.Debugf("[METRICS] %s %s: %d in %.2fms", r.Method, endpoint, rw.statusCode, latency)
+ }
+ }
+}
+
+// responseWriter захватывает статус код
+type responseWriter struct {
+ http.ResponseWriter
+ statusCode int
+}
+
+func (rw *responseWriter) WriteHeader(code int) {
+ rw.statusCode = code
+ rw.ResponseWriter.WriteHeader(code)
+}
+
+// logOperation логирует операцию веб-интерфейса
+func (w *WebUIServer) logOperation(operation, target, status, errMsg string, details map[string]interface{}) {
+ entry := LogEntry{
+ Timestamp: time.Now().UnixMilli(),
+ Operation: operation,
+ Target: target,
+ User: w.credentialMgr.GetCurrentUsername(),
+ Status: status,
+ ErrorMsg: errMsg,
+ Details: details,
+ }
+
+ if w.logBuffer != nil {
+ w.logBuffer.Add(entry)
+ }
+
+ if w.logger != nil {
+ if status == "error" {
+ w.logger.Error(fmt.Sprintf("[WEBUI] %s: %s - %s", operation, target, errMsg))
+ } else {
+ w.logger.Info(fmt.Sprintf("[WEBUI] %s: %s", operation, target))
+ }
+ }
+}
+
+// Start запускает веб-сервер интерфейса
+func (w *WebUIServer) Start() error {
+ if !w.enabled {
+ w.logger.Info("Web UI is disabled in configuration")
+ return nil
+ }
+
+ mux := http.NewServeMux()
+
+ staticFS, err := fs.Sub(staticFiles, "static")
+ if err != nil {
+ return fmt.Errorf("failed to load static files: %v", err)
+ }
+ mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
+
+ mux.HandleFunc("/", w.handleWebIndex)
+
+ // Observability endpoints
+ mux.HandleFunc("/api/webui/metrics", w.middlewareWithMetrics(w.handleMetrics, "metrics"))
+ mux.HandleFunc("/api/webui/health", w.middlewareWithMetrics(w.handleHealth, "health"))
+ mux.HandleFunc("/api/webui/pipeline/status", w.middlewareWithMetrics(w.handlePipelineStatus, "pipeline_status"))
+ mux.HandleFunc("/api/webui/resharding/trigger", w.middlewareWithMetrics(w.handleReshardingTrigger, "resharding_trigger"))
+
+ // API для веб-интерфейса
+ mux.HandleFunc("/api/webui/databases", w.middlewareWithMetrics(w.handleGetDatabases, "databases"))
+ mux.HandleFunc("/api/webui/collections/", w.middlewareWithMetrics(w.handleGetCollections, "collections"))
+ mux.HandleFunc("/api/webui/documents/", w.middlewareWithMetrics(w.handleDocuments, "documents"))
+ mux.HandleFunc("/api/webui/cluster/status", w.middlewareWithMetrics(w.handleClusterStatus, "cluster_status"))
+ mux.HandleFunc("/api/webui/cluster/nodes", w.middlewareWithMetrics(w.handleClusterNodes, "cluster_nodes"))
+ mux.HandleFunc("/api/webui/stats", w.middlewareWithMetrics(w.handleStats, "stats"))
+ mux.HandleFunc("/api/webui/login", w.middlewareWithMetrics(w.handleWebLogin, "login"))
+ mux.HandleFunc("/api/webui/logout", w.middlewareWithMetrics(w.handleWebLogout, "logout"))
+ mux.HandleFunc("/api/webui/session", w.middlewareWithMetrics(w.handleSessionCheck, "session"))
+ mux.HandleFunc("/api/webui/change-password", w.middlewareWithMetrics(w.handleChangePassword, "change_password"))
+ mux.HandleFunc("/api/webui/user/avatar", w.middlewareWithMetrics(w.handleUserAvatar, "user_avatar"))
+ mux.HandleFunc("/api/webui/user/info", w.middlewareWithMetrics(w.handleUserInfo, "user_info"))
+ mux.HandleFunc("/api/webui/admin/users", w.middlewareWithMetrics(w.handleAdminUsers, "admin_users"))
+ mux.HandleFunc("/api/webui/logs", w.middlewareWithMetrics(w.handleWebLogs, "logs"))
+ mux.HandleFunc("/api/webui/plugins", w.middlewareWithMetrics(w.handlePlugins, "plugins"))
+ mux.HandleFunc("/api/webui/plugin/", w.middlewareWithMetrics(w.handlePluginOperation, "plugin_operation"))
+ mux.HandleFunc("/api/webui/acl/users", w.middlewareWithMetrics(w.handleACLUsers, "acl_users"))
+ mux.HandleFunc("/api/webui/acl/roles", w.middlewareWithMetrics(w.handleACLRoles, "acl_roles"))
+ mux.HandleFunc("/api/webui/acl/permissions", w.middlewareWithMetrics(w.handleACLPermissions, "acl_permissions"))
+ mux.HandleFunc("/api/webui/acl/user/", w.middlewareWithMetrics(w.handleACLUser, "acl_user"))
+ mux.HandleFunc("/api/webui/acl/role/", w.middlewareWithMetrics(w.handleACLRole, "acl_role"))
+ mux.HandleFunc("/api/webui/transactions", w.middlewareWithMetrics(w.handleTransactions, "transactions"))
+ mux.HandleFunc("/api/webui/transaction/", w.middlewareWithMetrics(w.handleTransactionAction, "transaction_action"))
+ mux.HandleFunc("/api/webui/indexes/", w.middlewareWithMetrics(w.handleIndexesList, "indexes_list"))
+ mux.HandleFunc("/api/webui/index/", w.middlewareWithMetrics(w.handleIndexOperation, "index_operation"))
+ mux.HandleFunc("/api/webui/export", w.middlewareWithMetrics(w.handleExportData, "export"))
+ mux.HandleFunc("/api/webui/import", w.middlewareWithMetrics(w.handleImportData, "import"))
+ mux.HandleFunc("/api/webui/triggers/", w.middlewareWithMetrics(w.handleTriggers, "triggers"))
+ mux.HandleFunc("/api/webui/trigger/", w.middlewareWithMetrics(w.handleTriggerOperation, "trigger_operation"))
+ mux.HandleFunc("/api/webui/trigger/log", w.middlewareWithMetrics(w.handleTriggerLog, "trigger_log"))
+ mux.HandleFunc("/api/webui/constraints/", w.middlewareWithMetrics(w.handleConstraintsList, "constraints_list"))
+ mux.HandleFunc("/api/webui/constraint/", w.middlewareWithMetrics(w.handleConstraintOperation, "constraint_operation"))
+
+ w.server = &http.Server{
+ Addr: fmt.Sprintf(":%d", w.port),
+ Handler: mux,
+ ReadTimeout: 30 * time.Second,
+ WriteTimeout: 30 * time.Second,
+ }
+
+ w.logger.Info(fmt.Sprintf("Web UI started on port %d with observability enabled", w.port))
+ return w.server.ListenAndServe()
+}
+
+// Stop останавливает веб-сервер
+func (w *WebUIServer) Stop() error {
+ if w.logBuffer != nil {
+ w.logBuffer.Close()
+ }
+ if w.server != nil {
+ return w.server.Close()
+ }
+ return nil
+}
+
+// checkAuth проверяет аутентификацию
+func (w *WebUIServer) checkAuth(r *http.Request) bool {
+ sessionID := w.getSessionID(r)
+ return sessionID != ""
+}
+
+// getSessionID возвращает ID сессии из cookie
+func (w *WebUIServer) getSessionID(r *http.Request) string {
+ if cookie, err := r.Cookie("session_id"); err == nil {
+ return cookie.Value
+ }
+ return ""
+}
+
+// sendJSONSuccess отправляет успешный JSON ответ
+func (w *WebUIServer) sendJSONSuccess(wr http.ResponseWriter, data interface{}) {
+ wr.Header().Set("Content-Type", "application/json")
+ wr.WriteHeader(http.StatusOK)
+ if err := json.NewEncoder(wr).Encode(map[string]interface{}{
+ "success": true,
+ "data": data,
+ }); err != nil {
+ w.logger.Error(fmt.Sprintf("Failed to encode JSON response: %v", err))
+ }
+}
+
+// sendJSONError отправляет JSON ответ с ошибкой
+func (w *WebUIServer) sendJSONError(wr http.ResponseWriter, errMsg string, statusCode int) {
+ wr.Header().Set("Content-Type", "application/json")
+ wr.WriteHeader(statusCode)
+ if err := json.NewEncoder(wr).Encode(map[string]interface{}{
+ "success": false,
+ "error": errMsg,
+ }); err != nil {
+ w.logger.Error(fmt.Sprintf("Failed to encode JSON error response: %v", err))
+ }
+}
+
+// handleWebIndex возвращает главную HTML страницу
+func (w *WebUIServer) handleWebIndex(wr http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/" {
+ http.NotFound(wr, r)
+ return
+ }
+
+ html := `
+
+
+
+
+ Futriis Database Management System
+
+
+
+
+
+
+
+
+
+
+ Панель управления futriis 3i² (02.04.2026)
+
+ Подключено
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`
+
+ wr.Header().Set("Content-Type", "text/html; charset=utf-8")
+ wr.Write([]byte(html))
+}
+
+// ========== Observability Handlers ==========
+
+// handleMetrics возвращает метрики системы
+func (w *WebUIServer) handleMetrics(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ metrics := w.metrics.GetMetrics()
+
+ if w.coordinator != nil {
+ metrics["pipeline_stats"] = w.coordinator.GetPipelineStats()
+ metrics["batch_commit_stats"] = w.coordinator.GetBatchCommitStats()
+ metrics["resharding_stats"] = w.coordinator.GetReshardingStats()
+ metrics["joint_consensus_status"] = w.coordinator.GetJointConsensusStatus()
+ }
+
+ metrics["rate_limiter_stats"] = w.rateLimiter.GetStats()
+
+ w.sendJSONSuccess(wr, metrics)
+}
+
+// handleHealth возвращает статус здоровья
+func (w *WebUIServer) handleHealth(wr http.ResponseWriter, r *http.Request) {
+ status := w.healthChecker.Check()
+ w.sendJSONSuccess(wr, status)
+}
+
+// handlePipelineStatus возвращает статус пайплайна
+func (w *WebUIServer) handlePipelineStatus(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ if w.coordinator == nil {
+ w.sendJSONError(wr, "Cluster not available", http.StatusServiceUnavailable)
+ return
+ }
+
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "pipeline": w.coordinator.GetPipelineStats(),
+ "batch_commit": w.coordinator.GetBatchCommitStats(),
+ "resharding": w.coordinator.GetReshardingStats(),
+ "joint_consensus": w.coordinator.GetJointConsensusStatus(),
+ })
+}
+
+// handleReshardingTrigger запускает перераспределение шардов
+func (w *WebUIServer) handleReshardingTrigger(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ if w.coordinator == nil {
+ w.sendJSONError(wr, "Cluster not available", http.StatusServiceUnavailable)
+ return
+ }
+
+ username := w.credentialMgr.GetCurrentUsername()
+ if !w.credentialMgr.IsAdmin(username) {
+ w.sendJSONError(wr, "Forbidden", http.StatusForbidden)
+ return
+ }
+
+ var req struct {
+ Reason string `json:"reason"`
+ }
+
+ if r.Body != nil {
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ w.sendJSONError(wr, "Invalid request body: "+err.Error(), http.StatusBadRequest)
+ return
+ }
+ }
+
+ if req.Reason == "" {
+ req.Reason = "manual_trigger"
+ }
+
+ if err := w.coordinator.TriggerResharding(req.Reason); err != nil {
+ w.logOperation("RESHARDING_TRIGGER", "", "error", err.Error(), nil)
+ w.sendJSONError(wr, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.logOperation("RESHARDING_TRIGGER", "", "success", "", map[string]interface{}{
+ "reason": req.Reason,
+ })
+
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "status": "resharding_triggered",
+ "reason": req.Reason,
+ })
+}
+
+// ========== Authentication Handlers ==========
+
+// handleWebLogin обрабатывает вход в веб-интерфейс
+func (w *WebUIServer) handleWebLogin(wr http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ var creds struct {
+ Username string `json:"username"`
+ Password string `json:"password"`
+ }
+
+ if err := json.NewDecoder(r.Body).Decode(&creds); err != nil {
+ w.sendJSONError(wr, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ w.logger.Debug(fmt.Sprintf("Login attempt: username=%s", creds.Username))
+
+ if !w.credentialMgr.Validate(creds.Username, creds.Password) {
+ w.logger.Debug(fmt.Sprintf("Authentication failed for %s", creds.Username))
+ w.logOperation("LOGIN", creds.Username, "error", "Invalid credentials", nil)
+ w.sendJSONError(wr, "Неверный логин и/или пароль", http.StatusUnauthorized)
+ return
+ }
+
+ sessionID := generateSecureSessionID(creds.Username)
+
+ w.credentialMgr.SetCurrentUsername(creds.Username)
+ w.credentialMgr.UpdateLastLogin(creds.Username)
+
+ w.logger.Debug(fmt.Sprintf("Authentication successful for %s, sessionID=%s", creds.Username, sessionID[:20]+"..."))
+ w.logOperation("LOGIN", creds.Username, "success", "", nil)
+
+ http.SetCookie(wr, &http.Cookie{
+ Name: "session_id",
+ Value: sessionID,
+ Path: "/",
+ MaxAge: 86400,
+ HttpOnly: true,
+ Secure: true,
+ SameSite: http.SameSiteStrictMode,
+ })
+
+ avatarData := ""
+ if avatar, err := w.credentialMgr.GetAvatar(creds.Username); err == nil && avatar != "" {
+ avatarData = avatar
+ }
+
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "session_id": sessionID,
+ "username": creds.Username,
+ "avatar": avatarData,
+ "is_admin": w.credentialMgr.IsAdmin(creds.Username),
+ })
+}
+
+// handleWebLogout обрабатывает выход из веб-интерфейса
+func (w *WebUIServer) handleWebLogout(wr http.ResponseWriter, r *http.Request) {
+ username := w.credentialMgr.GetCurrentUsername()
+ w.logOperation("LOGOUT", username, "success", "", nil)
+
+ http.SetCookie(wr, &http.Cookie{
+ Name: "session_id",
+ Value: "",
+ Path: "/",
+ MaxAge: -1,
+ HttpOnly: true,
+ Secure: true,
+ })
+
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "status": "logged out",
+ })
+}
+
+// handleSessionCheck проверяет активность сессии
+func (w *WebUIServer) handleSessionCheck(wr http.ResponseWriter, r *http.Request) {
+ sessionID := w.getSessionID(r)
+ if sessionID == "" {
+ w.sendJSONError(wr, "No session", http.StatusUnauthorized)
+ return
+ }
+
+ username := w.credentialMgr.GetCurrentUsername()
+ if username == "" {
+ username = "admin"
+ }
+
+ connectionStatus := "connected"
+ if w.coordinator == nil {
+ connectionStatus = "disconnected"
+ } else if status := w.coordinator.GetClusterStatus(); status.Health == "critical" {
+ connectionStatus = "disconnected"
+ }
+
+ avatarData := ""
+ if avatar, err := w.credentialMgr.GetAvatar(username); err == nil && avatar != "" {
+ avatarData = avatar
+ }
+
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "authenticated": true,
+ "username": username,
+ "avatar": avatarData,
+ "is_admin": w.credentialMgr.IsAdmin(username),
+ "connection_status": connectionStatus,
+ })
+}
+
+// handleChangePassword обрабатывает смену пароля
+func (w *WebUIServer) handleChangePassword(wr http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ sessionID := w.getSessionID(r)
+ if sessionID == "" {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ var req struct {
+ CurrentPassword string `json:"current_password"`
+ NewPassword string `json:"new_password"`
+ }
+
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ w.sendJSONError(wr, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ if req.CurrentPassword == "" || req.NewPassword == "" {
+ w.sendJSONError(wr, "Current and new password are required", http.StatusBadRequest)
+ return
+ }
+
+ if len(req.NewPassword) < 4 {
+ w.sendJSONError(wr, "New password must be at least 4 characters", http.StatusBadRequest)
+ return
+ }
+
+ username := w.credentialMgr.GetCurrentUsername()
+ if username == "" {
+ username = "admin"
+ }
+
+ if err := w.credentialMgr.ChangePassword(username, req.CurrentPassword, req.NewPassword); err != nil {
+ w.logOperation("CHANGE_PASSWORD", username, "error", err.Error(), nil)
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ w.logger.Info(fmt.Sprintf("Password changed for user: %s", username))
+ w.logOperation("CHANGE_PASSWORD", username, "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "status": "password_changed",
+ "message": "Пароль успешно изменён",
+ })
+}
+
+// handleUserAvatar обрабатывает загрузку и получение аватара пользователя
+func (w *WebUIServer) handleUserAvatar(wr http.ResponseWriter, r *http.Request) {
+ sessionID := w.getSessionID(r)
+ if sessionID == "" {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ username := w.credentialMgr.GetCurrentUsername()
+ if username == "" {
+ username = "admin"
+ }
+
+ switch r.Method {
+ case http.MethodGet:
+ avatar, err := w.credentialMgr.GetAvatar(username)
+ if err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusNotFound)
+ return
+ }
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "avatar": avatar,
+ })
+
+ case http.MethodPost:
+ if err := r.ParseMultipartForm(2 << 20); err != nil {
+ w.sendJSONError(wr, "Failed to parse form: "+err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ file, handler, err := r.FormFile("avatar")
+ if err != nil {
+ w.sendJSONError(wr, "Failed to get avatar file: "+err.Error(), http.StatusBadRequest)
+ return
+ }
+ defer file.Close()
+
+ if handler.Size > 2<<20 {
+ w.sendJSONError(wr, "Avatar file too large (max 2MB)", http.StatusBadRequest)
+ return
+ }
+
+ contentType := handler.Header.Get("Content-Type")
+ if contentType != "image/jpeg" && contentType != "image/png" && contentType != "image/gif" {
+ w.sendJSONError(wr, "Invalid image type. Use JPEG, PNG or GIF", http.StatusBadRequest)
+ return
+ }
+
+ fileData := make([]byte, handler.Size)
+ if _, err := file.Read(fileData); err != nil {
+ w.sendJSONError(wr, "Failed to read file: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ avatarBase64 := "data:" + contentType + ";base64," + base64.StdEncoding.EncodeToString(fileData)
+
+ if err := w.credentialMgr.SetAvatar(username, avatarBase64); err != nil {
+ w.sendJSONError(wr, "Failed to save avatar: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.logger.Info(fmt.Sprintf("Avatar uploaded for user: %s", username))
+ w.logOperation("AVATAR_UPLOAD", username, "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "status": "avatar_uploaded",
+ "avatar": avatarBase64,
+ })
+
+ case http.MethodDelete:
+ if err := w.credentialMgr.DeleteAvatar(username); err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusNotFound)
+ return
+ }
+ w.logOperation("AVATAR_DELETE", username, "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "status": "avatar_deleted",
+ })
+
+ default:
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ }
+}
+
+// handleUserInfo возвращает информацию о пользователе
+func (w *WebUIServer) handleUserInfo(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ username := w.credentialMgr.GetCurrentUsername()
+ if username == "" {
+ username = "admin"
+ }
+
+ avatar, _ := w.credentialMgr.GetAvatar(username)
+
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "username": username,
+ "avatar": avatar,
+ "is_admin": w.credentialMgr.IsAdmin(username),
+ })
+}
+
+// ========== Database Handlers ==========
+
+// handleGetDatabases возвращает список баз данных
+func (w *WebUIServer) handleGetDatabases(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ databases := w.store.ListDatabases()
+
+ dbInfo := make([]map[string]interface{}, 0)
+ for _, dbName := range databases {
+ db, err := w.store.GetDatabase(dbName)
+ if err == nil {
+ collections := db.ListCollections()
+ dbInfo = append(dbInfo, map[string]interface{}{
+ "name": dbName,
+ "collections": len(collections),
+ "collections_list": collections,
+ })
+ }
+ }
+
+ w.sendJSONSuccess(wr, dbInfo)
+}
+
+// handleGetCollections возвращает список коллекций в базе данных
+func (w *WebUIServer) handleGetCollections(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ path := strings.TrimPrefix(r.URL.Path, "/api/webui/collections/")
+ parts := strings.Split(path, "/")
+
+ if len(parts) < 1 || parts[0] == "" {
+ w.sendJSONError(wr, "Database name required", http.StatusBadRequest)
+ return
+ }
+
+ dbName := parts[0]
+ db, err := w.store.GetDatabase(dbName)
+ if err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusNotFound)
+ return
+ }
+
+ collections := db.ListCollections()
+ collectionsInfo := make([]map[string]interface{}, 0)
+
+ for _, collName := range collections {
+ coll, err := db.GetCollection(collName)
+ if err == nil {
+ collectionsInfo = append(collectionsInfo, map[string]interface{}{
+ "name": collName,
+ "count": coll.Count(),
+ "size": coll.Size(),
+ "indexes": coll.GetIndexes(),
+ })
+ }
+ }
+
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "database": dbName,
+ "collections": collectionsInfo,
+ })
+}
+
+// handleDocuments обрабатывает CRUD операции с документами
+func (w *WebUIServer) handleDocuments(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ path := strings.TrimPrefix(r.URL.Path, "/api/webui/documents/")
+ parts := strings.Split(path, "/")
+
+ if len(parts) < 2 {
+ w.sendJSONError(wr, "Invalid path. Use /api/webui/documents/{database}/{collection}", http.StatusBadRequest)
+ return
+ }
+
+ dbName := parts[0]
+ collName := parts[1]
+
+ db, err := w.store.GetDatabase(dbName)
+ if err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusNotFound)
+ return
+ }
+
+ coll, err := db.GetCollection(collName)
+ if err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusNotFound)
+ return
+ }
+
+ switch r.Method {
+ case http.MethodGet:
+ limit := 50
+ if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
+ if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 500 {
+ limit = l
+ }
+ }
+
+ offset := 0
+ if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" {
+ if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
+ offset = o
+ }
+ }
+
+ allDocs := coll.GetAllDocuments()
+ start := offset
+ end := offset + limit
+ if start > len(allDocs) {
+ start = len(allDocs)
+ }
+ if end > len(allDocs) {
+ end = len(allDocs)
+ }
+
+ docs := make([]map[string]interface{}, 0)
+ for _, doc := range allDocs[start:end] {
+ docs = append(docs, map[string]interface{}{
+ "id": doc.ID,
+ "fields": doc.GetFields(),
+ "created_at": doc.CreatedAt,
+ "updated_at": doc.UpdatedAt,
+ "version": doc.Version,
+ })
+ }
+
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "documents": docs,
+ "total": len(allDocs),
+ "limit": limit,
+ "offset": offset,
+ })
+
+ case http.MethodPost:
+ var docData map[string]interface{}
+ if err := json.NewDecoder(r.Body).Decode(&docData); err != nil {
+ w.sendJSONError(wr, "Invalid JSON", http.StatusBadRequest)
+ return
+ }
+
+ if err := coll.InsertFromMap(docData); err != nil {
+ w.logOperation("INSERT_DOCUMENT", fmt.Sprintf("%s.%s", dbName, collName), "error", err.Error(), nil)
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ w.logOperation("INSERT_DOCUMENT", fmt.Sprintf("%s.%s/%v", dbName, collName, docData["_id"]), "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "status": "inserted",
+ "id": docData["_id"],
+ })
+
+ case http.MethodPut:
+ docID := r.URL.Query().Get("id")
+ if docID == "" {
+ w.sendJSONError(wr, "Document ID required", http.StatusBadRequest)
+ return
+ }
+
+ var updates map[string]interface{}
+ if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
+ w.sendJSONError(wr, "Invalid JSON", http.StatusBadRequest)
+ return
+ }
+
+ if err := coll.Update(docID, updates); err != nil {
+ w.logOperation("UPDATE_DOCUMENT", fmt.Sprintf("%s.%s/%s", dbName, collName, docID), "error", err.Error(), nil)
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ w.logOperation("UPDATE_DOCUMENT", fmt.Sprintf("%s.%s/%s", dbName, collName, docID), "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "status": "updated",
+ "id": docID,
+ })
+
+ case http.MethodDelete:
+ docID := r.URL.Query().Get("id")
+ if docID == "" {
+ w.sendJSONError(wr, "Document ID required", http.StatusBadRequest)
+ return
+ }
+
+ if err := coll.Delete(docID); err != nil {
+ w.logOperation("DELETE_DOCUMENT", fmt.Sprintf("%s.%s/%s", dbName, collName, docID), "error", err.Error(), nil)
+ w.sendJSONError(wr, err.Error(), http.StatusNotFound)
+ return
+ }
+
+ w.logOperation("DELETE_DOCUMENT", fmt.Sprintf("%s.%s/%s", dbName, collName, docID), "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "status": "deleted",
+ "id": docID,
+ })
+
+ default:
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ }
+}
+
+// ========== Cluster Handlers ==========
+
+// handleClusterStatus возвращает статус кластера
+func (w *WebUIServer) handleClusterStatus(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ if w.coordinator == nil {
+ w.sendJSONError(wr, "Cluster not available", http.StatusServiceUnavailable)
+ return
+ }
+
+ status := w.coordinator.GetClusterStatus()
+ w.sendJSONSuccess(wr, status)
+}
+
+// handleClusterNodes возвращает список узлов кластера
+func (w *WebUIServer) handleClusterNodes(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ if w.coordinator == nil {
+ w.sendJSONError(wr, "Cluster not available", http.StatusServiceUnavailable)
+ return
+ }
+
+ nodes := w.coordinator.GetAllNodes()
+ w.sendJSONSuccess(wr, nodes)
+}
+
+// handleStats возвращает статистику системы
+func (w *WebUIServer) handleStats(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ databases := w.store.ListDatabases()
+ totalDocs := int64(0)
+ totalCollections := 0
+
+ for _, dbName := range databases {
+ db, _ := w.store.GetDatabase(dbName)
+ if db != nil {
+ collections := db.ListCollections()
+ totalCollections += len(collections)
+ for _, collName := range collections {
+ coll, _ := db.GetCollection(collName)
+ if coll != nil {
+ totalDocs += coll.Count()
+ }
+ }
+ }
+ }
+
+ stats := map[string]interface{}{
+ "databases": len(databases),
+ "collections": totalCollections,
+ "documents": totalDocs,
+ "storage_used_mb": float64(w.store.GetPageSize()*int64(len(databases))) / (1024 * 1024),
+ "uptime_seconds": (time.Now().UnixMilli() - w.metrics.StartTime) / 1000,
+ "cluster_enabled": w.coordinator != nil,
+ "replication_factor": 0,
+ }
+
+ if w.coordinator != nil {
+ stats["replication_factor"] = w.coordinator.GetReplicationFactor()
+ stats["cluster_health"] = w.coordinator.GetClusterStatus().Health
+ stats["pipeline_enabled"] = true
+ stats["batch_commit_enabled"] = true
+ stats["resharding_enabled"] = true
+ }
+
+ w.sendJSONSuccess(wr, stats)
+}
+
+// ========== Logs Handlers ==========
+
+// handleWebLogs возвращает логи веб-интерфейса
+func (w *WebUIServer) handleWebLogs(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ username := w.credentialMgr.GetCurrentUsername()
+ if !w.credentialMgr.IsAdmin(username) {
+ w.sendJSONError(wr, "Forbidden", http.StatusForbidden)
+ return
+ }
+
+ limit := 100
+ if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
+ if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 5000 {
+ limit = l
+ }
+ }
+
+ entries := w.logBuffer.GetEntries()
+ if len(entries) > limit {
+ entries = entries[len(entries)-limit:]
+ }
+
+ w.sendJSONSuccess(wr, entries)
+}
+
+// ========== Plugin Handlers ==========
+
+// handlePlugins возвращает список плагинов
+func (w *WebUIServer) handlePlugins(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ username := w.credentialMgr.GetCurrentUsername()
+ if !w.credentialMgr.IsAdmin(username) {
+ w.sendJSONError(wr, "Forbidden", http.StatusForbidden)
+ return
+ }
+
+ switch r.Method {
+ case http.MethodGet:
+ plugins := w.pluginManager.ListPlugins()
+ pluginList := make([]map[string]interface{}, 0, len(plugins))
+ for _, p := range plugins {
+ pluginList = append(pluginList, map[string]interface{}{
+ "name": p.Name,
+ "version": p.Version(),
+ "author": p.Author(),
+ "description": p.Description(),
+ "file_path": p.FilePath,
+ "loaded_at": p.LoadedAt(),
+ })
+ }
+ w.sendJSONSuccess(wr, pluginList)
+
+ default:
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ }
+}
+
+// handlePluginOperation обрабатывает операции с плагинами
+func (w *WebUIServer) handlePluginOperation(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ username := w.credentialMgr.GetCurrentUsername()
+ if !w.credentialMgr.IsAdmin(username) {
+ w.sendJSONError(wr, "Forbidden", http.StatusForbidden)
+ return
+ }
+
+ path := strings.TrimPrefix(r.URL.Path, "/api/webui/plugin/")
+ parts := strings.Split(path, "/")
+
+ if len(parts) < 2 {
+ w.sendJSONError(wr, "Plugin name and action required", http.StatusBadRequest)
+ return
+ }
+
+ pluginName := parts[0]
+ action := parts[1]
+
+ switch action {
+ case "start":
+ if r.Method != http.MethodPost {
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ if err := w.pluginManager.StartPlugin(pluginName); err != nil {
+ w.logOperation("PLUGIN_START", pluginName, "error", err.Error(), nil)
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+ w.logOperation("PLUGIN_START", pluginName, "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{"status": "started"})
+
+ case "stop":
+ if r.Method != http.MethodPost {
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ if err := w.pluginManager.StopPlugin(pluginName); err != nil {
+ w.logOperation("PLUGIN_STOP", pluginName, "error", err.Error(), nil)
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+ w.logOperation("PLUGIN_STOP", pluginName, "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{"status": "stopped"})
+
+ case "reload":
+ if r.Method != http.MethodPost {
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ if err := w.pluginManager.UnloadPlugin(pluginName); err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+ w.logOperation("PLUGIN_RELOAD", pluginName, "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{"status": "reloaded"})
+
+ case "delete":
+ if r.Method != http.MethodDelete {
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ if err := w.pluginManager.UnloadPlugin(pluginName); err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+ pluginPath := filepath.Join(w.pluginManager.GetPluginsDir(), pluginName+".lua")
+ if err := os.Remove(pluginPath); err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+ w.logOperation("PLUGIN_DELETE", pluginName, "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{"status": "deleted"})
+
+ case "upload":
+ if r.Method != http.MethodPost {
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ if err := r.ParseMultipartForm(1024 * 1024); err != nil {
+ w.sendJSONError(wr, "Failed to parse form", http.StatusBadRequest)
+ return
+ }
+
+ file, handler, err := r.FormFile("plugin")
+ if err != nil {
+ w.sendJSONError(wr, "Failed to get plugin file", http.StatusBadRequest)
+ return
+ }
+ defer file.Close()
+
+ if !strings.HasSuffix(handler.Filename, ".lua") {
+ w.sendJSONError(wr, "Only .lua files are allowed", http.StatusBadRequest)
+ return
+ }
+
+ pluginPath := filepath.Join(w.pluginManager.GetPluginsDir(), handler.Filename)
+
+ dst, err := os.Create(pluginPath)
+ if err != nil {
+ w.sendJSONError(wr, "Failed to create plugin file", http.StatusInternalServerError)
+ return
+ }
+ defer dst.Close()
+
+ if _, err := file.Seek(0, 0); err != nil {
+ w.sendJSONError(wr, "Failed to read file", http.StatusInternalServerError)
+ return
+ }
+
+ if _, err := dst.ReadFrom(file); err != nil {
+ w.sendJSONError(wr, "Failed to write plugin file: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.logOperation("PLUGIN_UPLOAD", handler.Filename, "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{"status": "uploaded", "filename": handler.Filename})
+
+ default:
+ w.sendJSONError(wr, "Unknown action", http.StatusBadRequest)
+ }
+}
+
+// Остальные обработчики (handleAdminUsers, ACL handlers, Transaction handlers,
+// Index handlers, Import/Export handlers, Trigger handlers, Constraint handlers)
+// остаются без изменений, так как они не содержат критических ошибок.
+
+// ========== Admin Handlers ==========
+
+func (w *WebUIServer) handleAdminUsers(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ username := w.credentialMgr.GetCurrentUsername()
+ if !w.credentialMgr.IsAdmin(username) {
+ w.sendJSONError(wr, "Forbidden", http.StatusForbidden)
+ return
+ }
+
+ switch r.Method {
+ case http.MethodGet:
+ users := w.credentialMgr.ListUsers()
+ w.sendJSONSuccess(wr, users)
+
+ case http.MethodPost:
+ var req struct {
+ Username string `json:"username"`
+ Password string `json:"password"`
+ IsAdmin bool `json:"is_admin"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ w.sendJSONError(wr, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ if err := w.credentialMgr.CreateUser(req.Username, req.Password, req.IsAdmin); err != nil {
+ w.logOperation("ADMIN_CREATE_USER", req.Username, "error", err.Error(), nil)
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ w.logOperation("ADMIN_CREATE_USER", req.Username, "success", "", map[string]interface{}{
+ "is_admin": req.IsAdmin,
+ })
+ w.sendJSONSuccess(wr, map[string]interface{}{"status": "created"})
+
+ case http.MethodDelete:
+ usernameToDelete := r.URL.Query().Get("username")
+ if usernameToDelete == "" {
+ w.sendJSONError(wr, "Username required", http.StatusBadRequest)
+ return
+ }
+
+ if err := w.credentialMgr.DeleteUser(usernameToDelete); err != nil {
+ w.logOperation("ADMIN_DELETE_USER", usernameToDelete, "error", err.Error(), nil)
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ w.logOperation("ADMIN_DELETE_USER", usernameToDelete, "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{"status": "deleted"})
+
+ case http.MethodPut:
+ var req struct {
+ Username string `json:"username"`
+ NewPassword string `json:"new_password"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ w.sendJSONError(wr, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ if err := w.credentialMgr.AdminChangePassword(req.Username, req.NewPassword); err != nil {
+ w.logOperation("ADMIN_CHANGE_PASSWORD", req.Username, "error", err.Error(), nil)
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ w.logOperation("ADMIN_CHANGE_PASSWORD", req.Username, "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{"status": "password_changed"})
+
+ default:
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ }
+}
+
+// ========== ACL Handlers ==========
+
+func (w *WebUIServer) handleACLUsers(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ users := w.aclManager.ListUsers()
+ usersInfo := make([]map[string]interface{}, 0)
+
+ for _, username := range users {
+ user, err := w.aclManager.GetUserInfo(username)
+ if err == nil {
+ usersInfo = append(usersInfo, map[string]interface{}{
+ "username": user.Username,
+ "roles": user.Roles,
+ "active": user.Active,
+ "created_at": user.CreatedAt,
+ "last_login": user.LastLogin,
+ })
+ }
+ }
+
+ w.sendJSONSuccess(wr, usersInfo)
+}
+
+func (w *WebUIServer) handleACLRoles(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ roles := w.aclManager.ListRoles()
+ rolesInfo := make([]map[string]interface{}, 0)
+
+ for _, roleName := range roles {
+ perms, err := w.aclManager.GetRolePermissions(roleName)
+ if err == nil {
+ rolesInfo = append(rolesInfo, map[string]interface{}{
+ "name": roleName,
+ "permissions": perms,
+ })
+ }
+ }
+
+ w.sendJSONSuccess(wr, rolesInfo)
+}
+
+func (w *WebUIServer) handleACLPermissions(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ roles := w.aclManager.ListRoles()
+ allPermissions := make(map[string][]string)
+
+ for _, roleName := range roles {
+ perms, _ := w.aclManager.GetRolePermissions(roleName)
+ allPermissions[roleName] = perms
+ }
+
+ w.sendJSONSuccess(wr, allPermissions)
+}
+
+func (w *WebUIServer) handleACLUser(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ path := strings.TrimPrefix(r.URL.Path, "/api/webui/acl/user/")
+ username := path
+
+ if username == "" {
+ w.sendJSONError(wr, "Username required", http.StatusBadRequest)
+ return
+ }
+
+ switch r.Method {
+ case http.MethodPost:
+ var req struct {
+ Password string `json:"password"`
+ Roles []string `json:"roles"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ w.sendJSONError(wr, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ if err := w.aclManager.CreateUser(username, req.Password, req.Roles); err != nil {
+ w.logOperation("ACL_CREATE_USER", username, "error", err.Error(), nil)
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+ w.logOperation("ACL_CREATE_USER", username, "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{"status": "created"})
+
+ case http.MethodPut:
+ var req struct {
+ AddRole string `json:"add_role"`
+ RemoveRole string `json:"remove_role"`
+ Password string `json:"password"`
+ Disable bool `json:"disable"`
+ Enable bool `json:"enable"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ w.sendJSONError(wr, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ if req.AddRole != "" {
+ if err := w.aclManager.AddUserRole(username, req.AddRole); err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+ }
+
+ if req.RemoveRole != "" {
+ if err := w.aclManager.RemoveUserRole(username, req.RemoveRole); err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+ }
+
+ if req.Password != "" {
+ if err := w.aclManager.ChangePassword(username, req.Password); err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+ }
+
+ if req.Disable {
+ if err := w.aclManager.DisableUser(username); err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+ }
+
+ if req.Enable {
+ if err := w.aclManager.EnableUser(username); err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+ }
+
+ w.logOperation("ACL_UPDATE_USER", username, "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{"status": "updated"})
+
+ case http.MethodDelete:
+ if err := w.aclManager.DeleteUser(username); err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+ w.logOperation("ACL_DELETE_USER", username, "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{"status": "deleted"})
+
+ default:
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ }
+}
+
+func (w *WebUIServer) handleACLRole(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ path := strings.TrimPrefix(r.URL.Path, "/api/webui/acl/role/")
+ parts := strings.Split(path, "/")
+
+ roleName := parts[0]
+ if roleName == "" {
+ w.sendJSONError(wr, "Role name required", http.StatusBadRequest)
+ return
+ }
+
+ switch r.Method {
+ case http.MethodPost:
+ if err := w.aclManager.CreateRole(roleName); err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+ w.logOperation("ACL_CREATE_ROLE", roleName, "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{"status": "created"})
+
+ case http.MethodPut:
+ if len(parts) < 3 {
+ w.sendJSONError(wr, "Action and permission required", http.StatusBadRequest)
+ return
+ }
+
+ action := parts[1]
+ permission := strings.Join(parts[2:], "/")
+
+ switch action {
+ case "grant":
+ if err := w.aclManager.GrantPermission(roleName, permission); err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+ case "revoke":
+ if err := w.aclManager.RevokePermission(roleName, permission); err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+ default:
+ w.sendJSONError(wr, "Unknown action", http.StatusBadRequest)
+ return
+ }
+ w.logOperation("ACL_UPDATE_ROLE", roleName, "success", "", map[string]interface{}{"action": action, "permission": permission})
+ w.sendJSONSuccess(wr, map[string]interface{}{"status": "updated"})
+
+ case http.MethodDelete:
+ if err := w.aclManager.DeleteRole(roleName); err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+ w.logOperation("ACL_DELETE_ROLE", roleName, "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{"status": "deleted"})
+
+ default:
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ }
+}
+
+// ========== Transaction Handlers ==========
+
+func (w *WebUIServer) handleTransactions(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ switch r.Method {
+ case http.MethodGet:
+ txList := storage.GetActiveTransactions()
+ w.sendJSONSuccess(wr, txList)
+
+ case http.MethodPost:
+ var req struct {
+ Action string `json:"action"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ w.sendJSONError(wr, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ switch req.Action {
+ case "start_session":
+ if err := storage.InitTransactionManager("futriis.wal"); err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.logOperation("TRANSACTION_START_SESSION", "", "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{"status": "session_started"})
+
+ case "start_transaction":
+ databases := w.store.ListDatabases()
+ if len(databases) == 0 {
+ w.sendJSONError(wr, "No database available", http.StatusBadRequest)
+ return
+ }
+
+ db, err := w.store.GetDatabase(databases[0])
+ if err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ collections := db.ListCollections()
+ if len(collections) == 0 {
+ w.sendJSONError(wr, "No collection available", http.StatusBadRequest)
+ return
+ }
+
+ coll, err := db.GetCollection(collections[0])
+ if err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ if err := storage.BeginTransactionOnCollection(coll); err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.logOperation("TRANSACTION_START", "", "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{"status": "transaction_started"})
+
+ default:
+ w.sendJSONError(wr, "Unknown action", http.StatusBadRequest)
+ }
+
+ default:
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ }
+}
+
+func (w *WebUIServer) handleTransactionAction(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ path := strings.TrimPrefix(r.URL.Path, "/api/webui/transaction/")
+
+ switch r.Method {
+ case http.MethodPost:
+ switch path {
+ case "commit":
+ if err := storage.CommitCurrentTransaction(); err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.logOperation("TRANSACTION_COMMIT", "", "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{"status": "committed"})
+
+ case "abort":
+ if err := storage.AbortCurrentTransaction(); err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.logOperation("TRANSACTION_ABORT", "", "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{"status": "aborted"})
+
+ default:
+ w.sendJSONError(wr, "Unknown action", http.StatusBadRequest)
+ }
+
+ default:
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ }
+}
+
+// ========== Index Handlers ==========
+
+func (w *WebUIServer) handleIndexesList(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ path := strings.TrimPrefix(r.URL.Path, "/api/webui/indexes/")
+ parts := strings.Split(path, "/")
+
+ if len(parts) < 2 {
+ w.sendJSONError(wr, "Database and collection required", http.StatusBadRequest)
+ return
+ }
+
+ dbName := parts[0]
+ collName := parts[1]
+
+ db, err := w.store.GetDatabase(dbName)
+ if err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusNotFound)
+ return
+ }
+
+ coll, err := db.GetCollection(collName)
+ if err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusNotFound)
+ return
+ }
+
+ switch r.Method {
+ case http.MethodGet:
+ indexes := coll.GetIndexesInfo()
+ w.sendJSONSuccess(wr, indexes)
+
+ default:
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ }
+}
+
+func (w *WebUIServer) handleIndexOperation(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ path := strings.TrimPrefix(r.URL.Path, "/api/webui/index/")
+ parts := strings.Split(path, "/")
+
+ if len(parts) < 3 {
+ w.sendJSONError(wr, "Database, collection and action required", http.StatusBadRequest)
+ return
+ }
+
+ dbName := parts[0]
+ collName := parts[1]
+ action := parts[2]
+
+ db, err := w.store.GetDatabase(dbName)
+ if err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusNotFound)
+ return
+ }
+
+ coll, err := db.GetCollection(collName)
+ if err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusNotFound)
+ return
+ }
+
+ switch action {
+ case "create":
+ var req struct {
+ Name string `json:"name"`
+ Fields []string `json:"fields"`
+ Unique bool `json:"unique"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ w.sendJSONError(wr, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ if err := coll.CreateIndex(req.Name, req.Fields, req.Unique); err != nil {
+ w.logOperation("INDEX_CREATE", fmt.Sprintf("%s.%s.%s", dbName, collName, req.Name), "error", err.Error(), nil)
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+ w.logOperation("INDEX_CREATE", fmt.Sprintf("%s.%s.%s", dbName, collName, req.Name), "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{"status": "created"})
+
+ case "drop":
+ if len(parts) < 4 {
+ w.sendJSONError(wr, "Index name required", http.StatusBadRequest)
+ return
+ }
+ indexName := parts[3]
+ if err := coll.DropIndex(indexName); err != nil {
+ w.logOperation("INDEX_DROP", fmt.Sprintf("%s.%s.%s", dbName, collName, indexName), "error", err.Error(), nil)
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+ w.logOperation("INDEX_DROP", fmt.Sprintf("%s.%s.%s", dbName, collName, indexName), "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{"status": "dropped"})
+
+ default:
+ w.sendJSONError(wr, "Unknown action", http.StatusBadRequest)
+ }
+}
+
+// ========== Import/Export Handlers ==========
+
+func (w *WebUIServer) handleExportData(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ if r.Method != http.MethodPost {
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ var req struct {
+ Database string `json:"database"`
+ Filename string `json:"filename"`
+ Format string `json:"format"`
+ }
+
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ w.sendJSONError(wr, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ if req.Database == "" {
+ w.sendJSONError(wr, "Database name required", http.StatusBadRequest)
+ return
+ }
+
+ if req.Filename == "" {
+ req.Filename = req.Database + "_export_" + strconv.FormatInt(time.Now().Unix(), 10)
+ if req.Format == "msgpack" {
+ req.Filename += ".msgpack"
+ } else {
+ req.Filename += ".json"
+ }
+ }
+
+ if req.Format == "" {
+ req.Format = "json"
+ }
+
+ db, err := w.store.GetDatabase(req.Database)
+ if err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusNotFound)
+ return
+ }
+
+ exportData := make(map[string]interface{})
+ collections := db.ListCollections()
+ totalDocuments := 0
+
+ for _, collName := range collections {
+ coll, err := db.GetCollection(collName)
+ if err != nil {
+ continue
+ }
+
+ docs := coll.GetAllDocuments()
+ totalDocuments += len(docs)
+ collData := make([]map[string]interface{}, 0, len(docs))
+ for _, doc := range docs {
+ docData := map[string]interface{}{
+ "_id": doc.ID,
+ "fields": doc.GetFields(),
+ "created_at": doc.CreatedAt,
+ "updated_at": doc.UpdatedAt,
+ "version": doc.Version,
+ }
+ if doc.DeletedAt > 0 {
+ docData["deleted_at"] = doc.DeletedAt
+ }
+ collData = append(collData, docData)
+ }
+ exportData[collName] = collData
+ }
+
+ exportData["_metadata"] = map[string]interface{}{
+ "database": req.Database,
+ "export_time": time.Now().Unix(),
+ "export_time_str": time.Now().Format("2006-01-02 15:04:05.000"),
+ "version": "1.0",
+ "collections": len(collections),
+ "total_documents": totalDocuments,
+ "format": req.Format,
+ "exported_by": w.credentialMgr.GetCurrentUsername(),
+ }
+
+ w.logOperation("EXPORT", req.Database, "success", "", map[string]interface{}{
+ "collections": len(collections),
+ "total_documents": totalDocuments,
+ "format": req.Format,
+ })
+
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "status": "export_prepared",
+ "database": req.Database,
+ "filename": req.Filename,
+ "format": req.Format,
+ "collections": len(collections),
+ "total_documents": totalDocuments,
+ "data": exportData,
+ })
+}
+
+func (w *WebUIServer) handleImportData(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ if r.Method != http.MethodPost {
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ var req struct {
+ Database string `json:"database"`
+ Data map[string]interface{} `json:"data"`
+ Overwrite bool `json:"overwrite"`
+ Format string `json:"format"`
+ }
+
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ w.sendJSONError(wr, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ if req.Database == "" {
+ w.sendJSONError(wr, "Database name required", http.StatusBadRequest)
+ return
+ }
+
+ if req.Data == nil {
+ w.sendJSONError(wr, "Import data required", http.StatusBadRequest)
+ return
+ }
+
+ if req.Format == "" {
+ req.Format = "json"
+ }
+
+ if !w.store.ExistsDatabase(req.Database) {
+ if err := w.store.CreateDatabase(req.Database); err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ }
+
+ db, err := w.store.GetDatabase(req.Database)
+ if err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusNotFound)
+ return
+ }
+
+ importedCollections := 0
+ importedDocuments := 0
+ skippedDocuments := 0
+ failedDocuments := 0
+
+ for key, value := range req.Data {
+ if key == "_metadata" {
+ continue
+ }
+
+ collName := key
+ collData, ok := value.([]interface{})
+ if !ok {
+ continue
+ }
+
+ if _, err := db.GetCollection(collName); err != nil {
+ if err := db.CreateCollection(collName); err != nil {
+ w.logOperation("IMPORT_COLLECTION_CREATE", collName, "error", err.Error(), nil)
+ continue
+ }
+ }
+
+ coll, err := db.GetCollection(collName)
+ if err != nil {
+ continue
+ }
+
+ for _, docRaw := range collData {
+ docMap, ok := docRaw.(map[string]interface{})
+ if !ok {
+ failedDocuments++
+ continue
+ }
+
+ var docID string
+ if id, ok := docMap["_id"].(string); ok {
+ docID = id
+ } else {
+ failedDocuments++
+ continue
+ }
+
+ if existingDoc, _ := coll.Find(docID); existingDoc != nil && !req.Overwrite {
+ skippedDocuments++
+ continue
+ }
+
+ doc := storage.NewDocumentWithID(docID)
+
+ if fields, ok := docMap["fields"].(map[string]interface{}); ok {
+ for k, v := range fields {
+ doc.SetField(k, v)
+ }
+ }
+
+ if createdAt, ok := docMap["created_at"]; ok {
+ switch v := createdAt.(type) {
+ case float64:
+ doc.CreatedAt = int64(v)
+ case int64:
+ doc.CreatedAt = v
+ }
+ }
+
+ if updatedAt, ok := docMap["updated_at"]; ok {
+ switch v := updatedAt.(type) {
+ case float64:
+ doc.UpdatedAt = int64(v)
+ case int64:
+ doc.UpdatedAt = v
+ }
+ }
+
+ if deletedAt, ok := docMap["deleted_at"]; ok {
+ switch v := deletedAt.(type) {
+ case float64:
+ doc.DeletedAt = int64(v)
+ case int64:
+ doc.DeletedAt = v
+ }
+ }
+
+ if version, ok := docMap["version"]; ok {
+ switch v := version.(type) {
+ case float64:
+ doc.Version = uint64(v)
+ case int64:
+ doc.Version = uint64(v)
+ case uint64:
+ doc.Version = v
+ }
+ }
+
+ if err := coll.Insert(doc); err != nil {
+ failedDocuments++
+ continue
+ }
+ importedDocuments++
+ }
+ importedCollections++
+ }
+
+ w.logOperation("IMPORT", req.Database, "success", "", map[string]interface{}{
+ "collections": importedCollections,
+ "documents": importedDocuments,
+ "skipped": skippedDocuments,
+ "failed": failedDocuments,
+ "overwrite": req.Overwrite,
+ "format": req.Format,
+ })
+
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "status": "imported",
+ "database": req.Database,
+ "collections": importedCollections,
+ "documents": importedDocuments,
+ "skipped": skippedDocuments,
+ "failed": failedDocuments,
+ "format": req.Format,
+ })
+}
+
+// ========== Trigger Handlers ==========
+
+func (w *WebUIServer) handleTriggers(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ path := strings.TrimPrefix(r.URL.Path, "/api/webui/triggers/")
+ parts := strings.Split(path, "/")
+
+ tm := storage.GetTriggerManager()
+ if tm == nil {
+ storage.InitTriggerManager(w.logger)
+ tm = storage.GetTriggerManager()
+ }
+
+ switch r.Method {
+ case http.MethodGet:
+ if len(parts) >= 2 && parts[0] != "" && parts[1] != "" {
+ dbName := parts[0]
+ collName := parts[1]
+
+ db, err := w.store.GetDatabase(dbName)
+ if err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusNotFound)
+ return
+ }
+
+ if _, err := db.GetCollection(collName); err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusNotFound)
+ return
+ }
+
+ triggers := tm.ListTriggers(collName)
+
+ triggerList := make([]map[string]interface{}, 0, len(triggers))
+ for _, trigger := range triggers {
+ triggerList = append(triggerList, map[string]interface{}{
+ "name": trigger.Name,
+ "collection": trigger.Collection,
+ "event": trigger.Event,
+ "action": trigger.Action,
+ "enabled": trigger.Enabled,
+ "description": trigger.Description,
+ "created_at": trigger.CreatedAt,
+ "updated_at": trigger.UpdatedAt,
+ "has_condition": trigger.Condition != nil,
+ "operations_count": len(trigger.Operations),
+ })
+ }
+ w.sendJSONSuccess(wr, triggerList)
+ } else {
+ triggers := tm.ListTriggers("")
+ triggerList := make([]map[string]interface{}, 0, len(triggers))
+ for _, trigger := range triggers {
+ triggerList = append(triggerList, map[string]interface{}{
+ "name": trigger.Name,
+ "collection": trigger.Collection,
+ "event": trigger.Event,
+ "action": trigger.Action,
+ "enabled": trigger.Enabled,
+ "description": trigger.Description,
+ "created_at": trigger.CreatedAt,
+ })
+ }
+ w.sendJSONSuccess(wr, triggerList)
+ }
+
+ default:
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ }
+}
+
+func (w *WebUIServer) handleTriggerOperation(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ path := strings.TrimPrefix(r.URL.Path, "/api/webui/trigger/")
+ parts := strings.Split(path, "/")
+
+ if len(parts) < 3 {
+ w.sendJSONError(wr, "Invalid path. Use /api/webui/trigger/{db}/{collection}/{action}", http.StatusBadRequest)
+ return
+ }
+
+ dbName := parts[0]
+ collName := parts[1]
+ action := parts[2]
+
+ tm := storage.GetTriggerManager()
+ if tm == nil {
+ storage.InitTriggerManager(w.logger)
+ tm = storage.GetTriggerManager()
+ }
+
+ switch action {
+ case "create":
+ if r.Method != http.MethodPost {
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ var req struct {
+ Name string `json:"name"`
+ Event string `json:"event"`
+ Action string `json:"action"`
+ Condition map[string]interface{} `json:"condition"`
+ Operations []map[string]interface{} `json:"operations"`
+ Description string `json:"description"`
+ }
+
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ w.sendJSONError(wr, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ if req.Name == "" {
+ w.sendJSONError(wr, "Trigger name required", http.StatusBadRequest)
+ return
+ }
+
+ config := make(map[string]interface{})
+ config["action"] = req.Action
+ config["description"] = req.Description
+
+ if req.Condition != nil {
+ config["condition"] = req.Condition
+ }
+
+ if len(req.Operations) > 0 {
+ ops := make([]interface{}, len(req.Operations))
+ for i, op := range req.Operations {
+ ops[i] = op
+ }
+ config["operations"] = ops
+ }
+
+ event := storage.TriggerEvent(req.Event)
+ if event == "" {
+ event = storage.TriggerAfterInsert
+ }
+
+ if err := tm.CreateTrigger(dbName, collName, req.Name, event, config); err != nil {
+ w.logOperation("TRIGGER_CREATE", fmt.Sprintf("%s.%s.%s", dbName, collName, req.Name), "error", err.Error(), nil)
+ w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ w.logOperation("TRIGGER_CREATE", fmt.Sprintf("%s.%s.%s", dbName, collName, req.Name), "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "status": "created",
+ "name": req.Name,
+ })
+
+ case "enable":
+ if r.Method != http.MethodPost {
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ if len(parts) < 4 {
+ w.sendJSONError(wr, "Trigger name required", http.StatusBadRequest)
+ return
+ }
+ triggerName := parts[3]
+
+ if err := tm.EnableTrigger(collName, "", triggerName); err != nil {
+ w.logOperation("TRIGGER_ENABLE", fmt.Sprintf("%s.%s.%s", dbName, collName, triggerName), "error", err.Error(), nil)
+ w.sendJSONError(wr, err.Error(), http.StatusNotFound)
+ return
+ }
+ w.logOperation("TRIGGER_ENABLE", fmt.Sprintf("%s.%s.%s", dbName, collName, triggerName), "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{"status": "enabled", "name": triggerName})
+
+ case "disable":
+ if r.Method != http.MethodPost {
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ if len(parts) < 4 {
+ w.sendJSONError(wr, "Trigger name required", http.StatusBadRequest)
+ return
+ }
+ triggerName := parts[3]
+
+ if err := tm.DisableTrigger(collName, "", triggerName); err != nil {
+ w.logOperation("TRIGGER_DISABLE", fmt.Sprintf("%s.%s.%s", dbName, collName, triggerName), "error", err.Error(), nil)
+ w.sendJSONError(wr, err.Error(), http.StatusNotFound)
+ return
+ }
+ w.logOperation("TRIGGER_DISABLE", fmt.Sprintf("%s.%s.%s", dbName, collName, triggerName), "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{"status": "disabled", "name": triggerName})
+
+ case "delete":
+ if r.Method != http.MethodDelete {
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ if len(parts) < 4 {
+ w.sendJSONError(wr, "Trigger name required", http.StatusBadRequest)
+ return
+ }
+ triggerName := parts[3]
+ triggerEvent := ""
+ if len(parts) > 4 {
+ triggerEvent = parts[4]
+ }
+
+ if err := tm.DropTrigger(collName, triggerEvent, triggerName); err != nil {
+ w.logOperation("TRIGGER_DELETE", fmt.Sprintf("%s.%s.%s", dbName, collName, triggerName), "error", err.Error(), nil)
+ w.sendJSONError(wr, err.Error(), http.StatusNotFound)
+ return
+ }
+ w.logOperation("TRIGGER_DELETE", fmt.Sprintf("%s.%s.%s", dbName, collName, triggerName), "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{"status": "deleted", "name": triggerName})
+
+ default:
+ w.sendJSONError(wr, "Unknown action", http.StatusBadRequest)
+ }
+}
+
+func (w *WebUIServer) handleTriggerLog(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ if r.Method != http.MethodGet {
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ tm := storage.GetTriggerManager()
+ if tm == nil {
+ w.sendJSONSuccess(wr, []interface{}{})
+ return
+ }
+
+ logEntries := tm.GetTriggerExecutionLog()
+
+ formattedLog := make([]map[string]interface{}, 0, len(logEntries))
+ for _, entry := range logEntries {
+ formattedLog = append(formattedLog, map[string]interface{}{
+ "trigger_name": entry.TriggerName,
+ "event": entry.Event,
+ "collection": entry.Collection,
+ "database": entry.Database,
+ "document_id": entry.DocumentID,
+ "operation": entry.Operation,
+ "timestamp": entry.Timestamp.UnixMilli(),
+ "user": entry.User,
+ "role": entry.Role,
+ "custom_data": entry.CustomData,
+ })
+ }
+
+ w.sendJSONSuccess(wr, formattedLog)
+}
+
+// ========== Constraint Handlers ==========
+
+func (w *WebUIServer) handleConstraintsList(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ if r.Method != http.MethodGet {
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ path := strings.TrimPrefix(r.URL.Path, "/api/webui/constraints/")
+ parts := strings.Split(path, "/")
+
+ if len(parts) < 2 {
+ w.sendJSONError(wr, "Database and collection required", http.StatusBadRequest)
+ return
+ }
+
+ dbName := parts[0]
+ collName := parts[1]
+
+ db, err := w.store.GetDatabase(dbName)
+ if err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusNotFound)
+ return
+ }
+
+ coll, err := db.GetCollection(collName)
+ if err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusNotFound)
+ return
+ }
+
+ constraintsList := make([]map[string]interface{}, 0)
+
+ requiredFields := coll.GetRequiredFields()
+ for _, field := range requiredFields {
+ constraintsList = append(constraintsList, map[string]interface{}{
+ "type": "required",
+ "field": field,
+ })
+ }
+
+ uniqueFields := coll.GetUniqueConstraints()
+ for _, field := range uniqueFields {
+ constraintsList = append(constraintsList, map[string]interface{}{
+ "type": "unique",
+ "field": field,
+ })
+ }
+
+ minConstraints := coll.GetMinConstraints()
+ for field, value := range minConstraints {
+ constraintsList = append(constraintsList, map[string]interface{}{
+ "type": "min",
+ "field": field,
+ "value": value,
+ })
+ }
+
+ maxConstraints := coll.GetMaxConstraints()
+ for field, value := range maxConstraints {
+ constraintsList = append(constraintsList, map[string]interface{}{
+ "type": "max",
+ "field": field,
+ "value": value,
+ })
+ }
+
+ enumConstraints := coll.GetEnumConstraints()
+ for field, values := range enumConstraints {
+ constraintsList = append(constraintsList, map[string]interface{}{
+ "type": "enum",
+ "field": field,
+ "values": values,
+ })
+ }
+
+ regexConstraints := coll.GetRegexConstraints()
+ for field, pattern := range regexConstraints {
+ constraintsList = append(constraintsList, map[string]interface{}{
+ "type": "regex",
+ "field": field,
+ "pattern": pattern,
+ })
+ }
+
+ w.sendJSONSuccess(wr, constraintsList)
+}
+
+func (w *WebUIServer) handleConstraintOperation(wr http.ResponseWriter, r *http.Request) {
+ if !w.checkAuth(r) {
+ w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ path := strings.TrimPrefix(r.URL.Path, "/api/webui/constraint/")
+ parts := strings.Split(path, "/")
+
+ if len(parts) < 3 {
+ w.sendJSONError(wr, "Invalid path. Use /api/webui/constraint/{db}/{collection}/{action}", http.StatusBadRequest)
+ return
+ }
+
+ dbName := parts[0]
+ collName := parts[1]
+ action := parts[2]
+
+ db, err := w.store.GetDatabase(dbName)
+ if err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusNotFound)
+ return
+ }
+
+ coll, err := db.GetCollection(collName)
+ if err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusNotFound)
+ return
+ }
+
+ switch r.Method {
+ case http.MethodPost:
+ switch action {
+ case "required":
+ if len(parts) < 4 {
+ w.sendJSONError(wr, "Field name required", http.StatusBadRequest)
+ return
+ }
+ field := parts[3]
+ coll.AddRequiredField(field)
+ w.logOperation("CONSTRAINT_ADD", fmt.Sprintf("%s.%s.required.%s", dbName, collName, field), "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "status": "added",
+ "type": "required",
+ "field": field,
+ })
+
+ case "unique":
+ if len(parts) < 4 {
+ w.sendJSONError(wr, "Field name required", http.StatusBadRequest)
+ return
+ }
+ field := parts[3]
+ coll.AddUniqueConstraint(field)
+ w.logOperation("CONSTRAINT_ADD", fmt.Sprintf("%s.%s.unique.%s", dbName, collName, field), "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "status": "added",
+ "type": "unique",
+ "field": field,
+ })
+
+ case "min":
+ if len(parts) < 5 {
+ w.sendJSONError(wr, "Field name and value required", http.StatusBadRequest)
+ return
+ }
+ field := parts[3]
+ minVal, err := strconv.ParseFloat(parts[4], 64)
+ if err != nil {
+ w.sendJSONError(wr, "Invalid minimum value", http.StatusBadRequest)
+ return
+ }
+ coll.AddMinConstraint(field, minVal)
+ w.logOperation("CONSTRAINT_ADD", fmt.Sprintf("%s.%s.min.%s", dbName, collName, field), "success", "", map[string]interface{}{"value": minVal})
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "status": "added",
+ "type": "min",
+ "field": field,
+ "value": minVal,
+ })
+
+ case "max":
+ if len(parts) < 5 {
+ w.sendJSONError(wr, "Field name and value required", http.StatusBadRequest)
+ return
+ }
+ field := parts[3]
+ maxVal, err := strconv.ParseFloat(parts[4], 64)
+ if err != nil {
+ w.sendJSONError(wr, "Invalid maximum value", http.StatusBadRequest)
+ return
+ }
+ coll.AddMaxConstraint(field, maxVal)
+ w.logOperation("CONSTRAINT_ADD", fmt.Sprintf("%s.%s.max.%s", dbName, collName, field), "success", "", map[string]interface{}{"value": maxVal})
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "status": "added",
+ "type": "max",
+ "field": field,
+ "value": maxVal,
+ })
+
+ case "enum":
+ if len(parts) < 5 {
+ w.sendJSONError(wr, "Field name and values required", http.StatusBadRequest)
+ return
+ }
+ field := parts[3]
+ values := make([]interface{}, len(parts)-4)
+ for i := 4; i < len(parts); i++ {
+ if num, err := strconv.ParseFloat(parts[i], 64); err == nil {
+ values[i-4] = num
+ } else {
+ values[i-4] = parts[i]
+ }
+ }
+ coll.AddEnumConstraint(field, values)
+ w.logOperation("CONSTRAINT_ADD", fmt.Sprintf("%s.%s.enum.%s", dbName, collName, field), "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "status": "added",
+ "type": "enum",
+ "field": field,
+ "values": values,
+ })
+
+ case "regex":
+ if len(parts) < 5 {
+ w.sendJSONError(wr, "Field name and pattern required", http.StatusBadRequest)
+ return
+ }
+ field := parts[3]
+ pattern := parts[4]
+ coll.AddRegexConstraint(field, pattern)
+ w.logOperation("CONSTRAINT_ADD", fmt.Sprintf("%s.%s.regex.%s", dbName, collName, field), "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "status": "added",
+ "type": "regex",
+ "field": field,
+ "pattern": pattern,
+ })
+
+ default:
+ w.sendJSONError(wr, "Unknown constraint type", http.StatusBadRequest)
+ }
+
+ case http.MethodDelete:
+ if len(parts) < 4 {
+ w.sendJSONError(wr, "Constraint type and field required", http.StatusBadRequest)
+ return
+ }
+ constraintType := parts[2]
+ field := parts[3]
+
+ switch constraintType {
+ case "required":
+ coll.RemoveRequiredField(field)
+ case "unique":
+ coll.RemoveUniqueConstraint(field)
+ case "min":
+ coll.RemoveMinConstraint(field)
+ case "max":
+ coll.RemoveMaxConstraint(field)
+ case "enum":
+ coll.RemoveEnumConstraint(field)
+ case "regex":
+ coll.RemoveRegexConstraint(field)
+ default:
+ w.sendJSONError(wr, "Unknown constraint type", http.StatusBadRequest)
+ return
+ }
+
+ w.logOperation("CONSTRAINT_REMOVE", fmt.Sprintf("%s.%s.%s.%s", dbName, collName, constraintType, field), "success", "", nil)
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "status": "removed",
+ "type": constraintType,
+ "field": field,
+ })
+
+ default:
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ }
+}