diff --git a/internal/api/webui.go b/internal/api/webui.go
new file mode 100644
index 0000000..ab75e53
--- /dev/null
+++ b/internal/api/webui.go
@@ -0,0 +1,640 @@
+/*
+ * 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.handleIndex)
+
+ // 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)
+
+ 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
+}
+
+// handleIndex возвращает главную HTML страницу
+func (w *WebUIServer) handleIndex(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))
+
+ // Пытаемся аутентифицировать через ACL менеджер
+ 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))
+
+ // Устанавливаем cookie сессии
+ 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,
+ })
+}
+
+// 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,
+ })
+}