Files
futriix/internal/api/webui.go

641 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 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 := `<!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">
<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))
// Пытаемся аутентифицировать через 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,
})
}