Files
futriix/internal/api/webui.go

1643 lines
56 KiB
Go
Raw Normal View History

2026-04-29 21:43:51 +00:00
/*
* 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 := `<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
<title>Futriis Database Management System</title>
<link rel="stylesheet" href="/static/style.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
</head>
<body>
<div class="dashboard-container">
<nav class="sidebar">
<div class="sidebar-header">
<div class="logo">
<img src="/static/logo.png" alt="Futriis" style="width: 112px; height: 53px; object-fit: contain;">
</div>
<button class="menu-toggle" id="menuToggle">
<i class="fas fa-bars"></i>
</button>
</div>
<ul class="nav-menu">
<li class="nav-item">
<a href="#" class="nav-link" data-section="dashboard">
<i class="fas fa-tachometer-alt"></i>
<span>Панель управления</span>
</a>
</li>
<li class="nav-item has-submenu">
<a href="#" class="nav-link" data-submenu="crud">
<i class="fas fa-table"></i>
<span>Управление СУБД</span>
<i class="fas fa-chevron-down"></i>
</a>
<ul class="submenu">
<li><a href="#" data-action="create-db"><i class="fas fa-plus-circle"></i>Создать БД</a></li>
<li><a href="#" data-action="create-collection"><i class="fas fa-layer-group"></i>Создать коллекцию</a></li>
<li><a href="#" data-action="insert-doc"><i class="fas fa-file-import"></i>Вставить документ</a></li>
<li><a href="#" data-action="find-doc"><i class="fas fa-search"></i>Найти документ</a></li>
<li><a href="#" data-action="update-doc"><i class="fas fa-edit"></i>Обновить документ</a></li>
<li><a href="#" data-action="delete-doc"><i class="fas fa-trash-alt"></i>Удалить документ</a></li>
</ul>
</li>
<li class="nav-item has-submenu">
<a href="#" class="nav-link" data-submenu="acl">
<i class="fas fa-lock"></i>
<span>ACL управление</span>
<i class="fas fa-chevron-down"></i>
</a>
<ul class="submenu">
<li><a href="#" data-section="acl-users"><i class="fas fa-users"></i>Пользователи</a></li>
<li><a href="#" data-section="acl-roles"><i class="fas fa-user-tag"></i>Роли</a></li>
<li><a href="#" data-section="acl-permissions"><i class="fas fa-key"></i>Разрешения</a></li>
<li><a href="#" data-action="acl-create-user"><i class="fas fa-user-plus"></i>Создать пользователя</a></li>
<li><a href="#" data-action="acl-create-role"><i class="fas fa-plus-circle"></i>Создать роль</a></li>
</ul>
</li>
<li class="nav-item has-submenu">
<a href="#" class="nav-link" data-submenu="transactions">
<i class="fas fa-exchange-alt"></i>
<span>Транзакции</span>
<i class="fas fa-chevron-down"></i>
</a>
<ul class="submenu">
<li><a href="#" data-action="tx-start-session"><i class="fas fa-play"></i>Начать сессию</a></li>
<li><a href="#" data-action="tx-start"><i class="fas fa-play-circle"></i>Начать транзакцию</a></li>
<li><a href="#" data-action="tx-commit"><i class="fas fa-check-circle"></i>Зафиксировать</a></li>
<li><a href="#" data-action="tx-abort"><i class="fas fa-times-circle"></i>Отменить</a></li>
<li><a href="#" data-section="tx-list"><i class="fas fa-list"></i>Список транзакций</a></li>
</ul>
</li>
<li class="nav-item has-submenu">
<a href="#" class="nav-link" data-submenu="indexes">
<i class="fas fa-search"></i>
<span>Индексы</span>
<i class="fas fa-chevron-down"></i>
</a>
<ul class="submenu">
<li><a href="#" data-section="indexes-list"><i class="fas fa-list"></i>Список индексов</a></li>
<li><a href="#" data-action="index-create"><i class="fas fa-plus"></i>Создать индекс</a></li>
<li><a href="#" data-action="index-drop"><i class="fas fa-trash"></i>Удалить индекс</a></li>
</ul>
</li>
<li class="nav-item has-submenu">
<a href="#" class="nav-link" data-submenu="triggers">
<i class="fas fa-bolt"></i>
<span>Триггеры</span>
<i class="fas fa-chevron-down"></i>
</a>
<ul class="submenu">
<li><a href="#" data-section="triggers-list"><i class="fas fa-list"></i>Список триггеров</a></li>
<li><a href="#" data-action="trigger-create"><i class="fas fa-plus"></i>Создать триггер</a></li>
<li><a href="#" data-section="trigger-log"><i class="fas fa-history"></i>Лог выполнения</a></li>
</ul>
</li>
<li class="nav-item has-submenu">
<a href="#" class="nav-link" data-submenu="import-export">
<i class="fas fa-database"></i>
<span>Импорт/Экспорт</span>
<i class="fas fa-chevron-down"></i>
</a>
<ul class="submenu">
<li><a href="#" data-section="export-data"><i class="fas fa-upload"></i>Экспорт данных</a></li>
<li><a href="#" data-section="import-data"><i class="fas fa-download"></i>Импорт данных</a></li>
</ul>
</li>
<li class="nav-item">
<a href="#" class="nav-link" data-section="cluster">
<i class="fas fa-network-wired"></i>
<span>Управление кластером</span>
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link" data-section="audit">
<i class="fas fa-history"></i>
<span>Аудит</span>
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link" data-section="settings">
<i class="fas fa-cog"></i>
<span>Настройки</span>
</a>
</li>
</ul>
<div class="sidebar-footer">
<div class="user-info" id="userInfo">
<i class="fas fa-user-circle"></i>
<span>Гость</span>
</div>
<button class="logout-btn" id="logoutBtn">
<i class="fas fa-sign-out-alt"></i>
<span>Выход</span>
</button>
</div>
</nav>
<main class="main-content">
<header class="top-bar">
<h1 id="pageTitle">Панель управления</h1>
<div class="connection-status" id="connectionStatus">
<span>Подключено</span>
</div>
</header>
<div class="content-area" id="contentArea">
<div class="loading-spinner">
<i class="fas fa-spinner fa-pulse"></i>
<p>Загрузка...</p>
</div>
</div>
</main>
</div>
<div id="modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 id="modalTitle">Заголовок</h2>
<button class="modal-close">&times;</button>
</div>
<div class="modal-body" id="modalBody"></div>
<div class="modal-footer">
<button class="btn btn-secondary modal-close">Отмена</button>
<button class="btn btn-primary" id="modalConfirm">Подтвердить</button>
</div>
</div>
</div>
<div id="notificationContainer" class="notification-container"></div>
<script src="/static/app.js"></script>
</body>
</html>`
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,
})
}