diff --git a/internal/api/webui.go b/internal/api/webui.go
new file mode 100644
index 0000000..537910a
--- /dev/null
+++ b/internal/api/webui.go
@@ -0,0 +1,1642 @@
+/*
+ * 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 (
+ "embed"
+ "encoding/json"
+ "fmt"
+ "io/fs"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "futriis/internal/acl"
+ "futriis/internal/cluster"
+ "futriis/internal/log"
+ "futriis/internal/storage"
+)
+
+//go:embed static/*
+var staticFiles embed.FS
+
+// WebUIServer представляет сервер веб-интерфейса
+type WebUIServer struct {
+ store *storage.Storage
+ coordinator *cluster.RaftCoordinator
+ aclManager *acl.ACLManager
+ logger *log.Logger
+ server *http.Server
+ port int
+ enabled bool
+}
+
+// NewWebUIServer создаёт новый веб-сервер интерфейса
+func NewWebUIServer(port int, enabled bool, store *storage.Storage, coord *cluster.RaftCoordinator, aclMgr *acl.ACLManager, logger *log.Logger) *WebUIServer {
+ return &WebUIServer{
+ store: store,
+ coordinator: coord,
+ aclManager: aclMgr,
+ logger: logger,
+ port: port,
+ enabled: enabled,
+ }
+}
+
+// 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)
+
+ // API для веб-интерфейса
+ mux.HandleFunc("/api/webui/databases", w.handleGetDatabases)
+ mux.HandleFunc("/api/webui/collections/", w.handleGetCollections)
+ mux.HandleFunc("/api/webui/documents/", w.handleDocuments)
+ mux.HandleFunc("/api/webui/cluster/status", w.handleClusterStatus)
+ mux.HandleFunc("/api/webui/cluster/nodes", w.handleClusterNodes)
+ mux.HandleFunc("/api/webui/stats", w.handleStats)
+ mux.HandleFunc("/api/webui/login", w.handleWebLogin)
+ mux.HandleFunc("/api/webui/logout", w.handleWebLogout)
+ mux.HandleFunc("/api/webui/session", w.handleSessionCheck)
+
+ // API для ACL
+ mux.HandleFunc("/api/webui/acl/users", w.handleACLUsers)
+ mux.HandleFunc("/api/webui/acl/roles", w.handleACLRoles)
+ mux.HandleFunc("/api/webui/acl/permissions", w.handleACLPermissions)
+ mux.HandleFunc("/api/webui/acl/user/", w.handleACLUser)
+ mux.HandleFunc("/api/webui/acl/role/", w.handleACLRole)
+
+ // API для транзакций
+ mux.HandleFunc("/api/webui/transactions", w.handleTransactions)
+ mux.HandleFunc("/api/webui/transaction/", w.handleTransactionAction)
+
+ // API для индексов
+ mux.HandleFunc("/api/webui/indexes/", w.handleIndexesList)
+ mux.HandleFunc("/api/webui/index/", w.handleIndexOperation)
+
+ // API для импорта/экспорта
+ mux.HandleFunc("/api/webui/export", w.handleExportData)
+ mux.HandleFunc("/api/webui/import", w.handleImportData)
+
+ // API для триггеров
+ mux.HandleFunc("/api/webui/triggers/", w.handleTriggers)
+ mux.HandleFunc("/api/webui/trigger/", w.handleTriggerOperation)
+ mux.HandleFunc("/api/webui/trigger/log", w.handleTriggerLog)
+
+ 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", w.port))
+ return w.server.ListenAndServe()
+}
+
+// Stop останавливает веб-сервер
+func (w *WebUIServer) Stop() error {
+ if w.server != nil {
+ return w.server.Close()
+ }
+ return nil
+}
+
+// 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
+
+
+
+
+
+
+
+
+
+
+ Панель управления
+
+ Подключено
+
+
+
+
+
+
+
+
+
+
+
+
+
+`
+
+ wr.Header().Set("Content-Type", "text/html; charset=utf-8")
+ wr.Write([]byte(html))
+}
+
+// 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.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ 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.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ 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.sendJSONError(wr, err.Error(), http.StatusNotFound)
+ return
+ }
+
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "status": "deleted",
+ "id": docID,
+ })
+
+ default:
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ }
+}
+
+// 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().Unix(),
+ "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
+ }
+
+ w.sendJSONSuccess(wr, stats)
+}
+
+// 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))
+
+ sessionID, err := w.aclManager.Authenticate(creds.Username, creds.Password)
+ if err != nil {
+ w.logger.Debug(fmt.Sprintf("Authentication failed for %s: %v", creds.Username, err))
+ w.sendJSONError(wr, "Неверный логин и/или пароль", http.StatusUnauthorized)
+ return
+ }
+
+ w.logger.Debug(fmt.Sprintf("Authentication successful for %s, sessionID=%s", creds.Username, sessionID))
+
+ http.SetCookie(wr, &http.Cookie{
+ Name: "session_id",
+ Value: sessionID,
+ Path: "/",
+ MaxAge: 86400,
+ HttpOnly: true,
+ SameSite: http.SameSiteLaxMode,
+ })
+
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "session_id": sessionID,
+ "username": creds.Username,
+ })
+}
+
+// handleWebLogout обрабатывает выход из веб-интерфейса
+func (w *WebUIServer) handleWebLogout(wr http.ResponseWriter, r *http.Request) {
+ if cookie, err := r.Cookie("session_id"); err == nil {
+ w.aclManager.Logout(cookie.Value)
+ }
+
+ http.SetCookie(wr, &http.Cookie{
+ Name: "session_id",
+ Value: "",
+ Path: "/",
+ MaxAge: -1,
+ HttpOnly: 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
+ }
+
+ if !w.aclManager.CheckSession(sessionID) {
+ w.sendJSONError(wr, "Invalid session", http.StatusUnauthorized)
+ return
+ }
+
+ username := w.aclManager.GetUsername(sessionID)
+
+ connectionStatus := "connected"
+ if w.coordinator == nil {
+ connectionStatus = "disconnected"
+ } else if status := w.coordinator.GetClusterStatus(); status.Health == "critical" {
+ connectionStatus = "disconnected"
+ }
+
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "authenticated": true,
+ "username": username,
+ "connection_status": connectionStatus,
+ })
+}
+
+// ==================== ACL Handlers ====================
+
+// handleACLUsers возвращает список пользователей
+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)
+}
+
+// handleACLRoles возвращает список ролей
+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)
+}
+
+// handleACLPermissions возвращает все разрешения
+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)
+}
+
+// handleACLUser обрабатывает операции с пользователем
+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.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+ 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.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.sendJSONSuccess(wr, map[string]interface{}{"status": "deleted"})
+
+ default:
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ }
+}
+
+// handleACLRole обрабатывает операции с ролью
+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.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.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.sendJSONSuccess(wr, map[string]interface{}{"status": "deleted"})
+
+ default:
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ }
+}
+
+// ==================== Transaction Handlers ====================
+
+// handleTransactions обрабатывает операции с транзакциями
+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.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.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)
+ }
+}
+
+// handleTransactionAction обрабатывает операции с конкретной транзакцией
+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/")
+ action := path
+
+ switch r.Method {
+ case http.MethodPost:
+ switch action {
+ case "commit":
+ if err := storage.CommitCurrentTransaction(); err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ 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.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 ====================
+
+// handleIndexesList обрабатывает получение списка индексов коллекции
+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)
+ }
+}
+
+// handleIndexOperation обрабатывает операции с индексом
+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.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+ 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.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+ w.sendJSONSuccess(wr, map[string]interface{}{"status": "dropped"})
+
+ default:
+ w.sendJSONError(wr, "Unknown action", http.StatusBadRequest)
+ }
+}
+
+// ==================== Import/Export Handlers ====================
+
+// handleExportData обрабатывает экспорт данных
+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"`
+ }
+
+ 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) + ".msgpack"
+ }
+
+ 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()
+
+ for _, collName := range collections {
+ coll, err := db.GetCollection(collName)
+ if err != nil {
+ continue
+ }
+
+ docs := coll.GetAllDocuments()
+ 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,
+ }
+ collData = append(collData, docData)
+ }
+ exportData[collName] = collData
+ }
+
+ exportData["_metadata"] = map[string]interface{}{
+ "database": req.Database,
+ "export_time": time.Now().Unix(),
+ "version": "1.0",
+ "collections": len(collections),
+ }
+
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "status": "export_prepared",
+ "database": req.Database,
+ "filename": req.Filename,
+ "collections": len(collections),
+ "data": exportData,
+ })
+}
+
+// handleImportData обрабатывает импорт данных
+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"`
+ }
+
+ 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 !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
+
+ 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 {
+ continue
+ }
+ }
+
+ coll, err := db.GetCollection(collName)
+ if err != nil {
+ continue
+ }
+
+ for _, docRaw := range collData {
+ docMap, ok := docRaw.(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ var docID string
+ if id, ok := docMap["_id"].(string); ok {
+ docID = id
+ } else {
+ continue
+ }
+
+ if existingDoc, _ := coll.Find(docID); existingDoc != nil && !req.Overwrite {
+ 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 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 {
+ continue
+ }
+ importedDocuments++
+ }
+ importedCollections++
+ }
+
+ w.sendJSONSuccess(wr, map[string]interface{}{
+ "status": "imported",
+ "database": req.Database,
+ "collections": importedCollections,
+ "documents": importedDocuments,
+ })
+}
+
+// ==================== Trigger Handlers ====================
+
+// handleTriggers обрабатывает операции со списком триггеров
+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 {
+ 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,
+ })
+ }
+ w.sendJSONSuccess(wr, triggerList)
+ }
+
+ default:
+ w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
+ }
+}
+
+// handleTriggerOperation обрабатывает операции с конкретным триггером
+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.sendJSONError(wr, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ 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.sendJSONError(wr, err.Error(), http.StatusNotFound)
+ return
+ }
+ 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.sendJSONError(wr, err.Error(), http.StatusNotFound)
+ return
+ }
+ 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 and event required", http.StatusBadRequest)
+ return
+ }
+ triggerName := parts[3]
+ triggerEvent := parts[4]
+
+ if err := tm.DropTrigger(collName, triggerEvent, triggerName); err != nil {
+ w.sendJSONError(wr, err.Error(), http.StatusNotFound)
+ return
+ }
+ w.sendJSONSuccess(wr, map[string]interface{}{"status": "deleted", "name": triggerName})
+
+ default:
+ w.sendJSONError(wr, "Unknown action", http.StatusBadRequest)
+ }
+}
+
+// handleTriggerLog возвращает лог выполнения триггеров
+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
+ }
+
+ log := tm.GetTriggerExecutionLog()
+
+ // Форматируем лог для отправки
+ formattedLog := make([]map[string]interface{}, 0, len(log))
+ for _, entry := range log {
+ 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)
+}
+
+// checkAuth проверяет аутентификацию
+func (w *WebUIServer) checkAuth(r *http.Request) bool {
+ sessionID := w.getSessionID(r)
+ if sessionID == "" {
+ return false
+ }
+ return w.aclManager.CheckSession(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)
+ json.NewEncoder(wr).Encode(map[string]interface{}{
+ "success": true,
+ "data": data,
+ })
+}
+
+// sendJSONError отправляет JSON ответ с ошибкой
+func (w *WebUIServer) sendJSONError(wr http.ResponseWriter, errMsg string, statusCode int) {
+ wr.Header().Set("Content-Type", "application/json")
+ wr.WriteHeader(statusCode)
+ json.NewEncoder(wr).Encode(map[string]interface{}{
+ "success": false,
+ "error": errMsg,
+ })
+}