first commit
This commit is contained in:
672
internal/api/http.go
Normal file
672
internal/api/http.go
Normal file
@@ -0,0 +1,672 @@
|
||||
/*
|
||||
* 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/http.go
|
||||
// Назначение: HTTP RESTful API для взаимодействия с СУБД через curl.
|
||||
// Поддерживает CRUD операции, управление индексами, ACL и ограничениями.
|
||||
// Реализован с минимальными блокировками, использует wait-free структуры.
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"futriis/internal/acl"
|
||||
"futriis/internal/cluster"
|
||||
"futriis/internal/log"
|
||||
"futriis/internal/storage"
|
||||
)
|
||||
|
||||
type HTTPServer struct {
|
||||
store *storage.Storage
|
||||
coordinator *cluster.RaftCoordinator
|
||||
aclManager *acl.ACLManager
|
||||
logger *log.Logger
|
||||
server *http.Server
|
||||
port int
|
||||
}
|
||||
|
||||
type APIResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// NewHTTPServer создаёт новый HTTP сервер (добавлен CORS middleware)
|
||||
func NewHTTPServer(port int, store *storage.Storage, coord *cluster.RaftCoordinator, aclMgr *acl.ACLManager, logger *log.Logger) *HTTPServer {
|
||||
s := &HTTPServer{
|
||||
store: store,
|
||||
coordinator: coord,
|
||||
aclManager: aclMgr,
|
||||
logger: logger,
|
||||
port: port,
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// CORS middleware wrapper
|
||||
corsHandler := func(handler http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-Session-ID")
|
||||
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
handler(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware для аутентификации
|
||||
mux.HandleFunc("/api/auth/login", corsHandler(s.handleLogin))
|
||||
mux.HandleFunc("/api/auth/logout", corsHandler(s.handleLogout))
|
||||
|
||||
// CRUD операции
|
||||
mux.HandleFunc("/api/db/", corsHandler(s.handleDatabaseRequest))
|
||||
|
||||
// Индексы
|
||||
mux.HandleFunc("/api/index/", corsHandler(s.handleIndexRequest))
|
||||
|
||||
// ACL
|
||||
mux.HandleFunc("/api/acl/", corsHandler(s.handleACLRequest))
|
||||
|
||||
// Constraints
|
||||
mux.HandleFunc("/api/constraint/", corsHandler(s.handleConstraintRequest))
|
||||
|
||||
// Cluster
|
||||
mux.HandleFunc("/api/cluster/", corsHandler(s.handleClusterRequest))
|
||||
|
||||
s.server = &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", port),
|
||||
Handler: mux,
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// Start запускает HTTP сервер
|
||||
func (s *HTTPServer) Start() error {
|
||||
s.logger.Info("Starting HTTP API server on port " + strconv.Itoa(s.port))
|
||||
return s.server.ListenAndServe()
|
||||
}
|
||||
|
||||
// Stop останавливает HTTP сервер
|
||||
func (s *HTTPServer) Stop() error {
|
||||
return s.server.Close()
|
||||
}
|
||||
|
||||
// handleLogin обрабатывает аутентификацию
|
||||
func (s *HTTPServer) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
s.sendError(w, "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 {
|
||||
s.sendError(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
sessionID, err := s.aclManager.Authenticate(creds.Username, creds.Password)
|
||||
if err != nil {
|
||||
s.sendError(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
s.sendSuccess(w, map[string]string{"session_id": sessionID})
|
||||
}
|
||||
|
||||
// handleLogout обрабатывает выход
|
||||
func (s *HTTPServer) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID := r.Header.Get("X-Session-ID")
|
||||
if sessionID != "" {
|
||||
s.aclManager.Logout(sessionID)
|
||||
}
|
||||
s.sendSuccess(w, map[string]string{"status": "logged out"})
|
||||
}
|
||||
|
||||
// handleDatabaseRequest обрабатывает запросы к БД
|
||||
func (s *HTTPServer) handleDatabaseRequest(w http.ResponseWriter, r *http.Request) {
|
||||
// URL: /api/db/{database}/{collection}/{document_id}
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/db/")
|
||||
parts := strings.Split(path, "/")
|
||||
|
||||
if len(parts) < 2 {
|
||||
s.sendError(w, "Invalid path. Use /api/db/{database}/{collection}[/{id}]", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
database := parts[0]
|
||||
collection := parts[1]
|
||||
docID := ""
|
||||
if len(parts) > 2 {
|
||||
docID = parts[2]
|
||||
}
|
||||
|
||||
// Проверка аутентификации
|
||||
sessionID := r.Header.Get("X-Session-ID")
|
||||
if sessionID == "" {
|
||||
s.sendError(w, "Authentication required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
s.handleGetDocument(w, r, sessionID, database, collection, docID)
|
||||
case http.MethodPost:
|
||||
s.handleInsertDocument(w, r, sessionID, database, collection)
|
||||
case http.MethodPut:
|
||||
s.handleUpdateDocument(w, r, sessionID, database, collection, docID)
|
||||
case http.MethodDelete:
|
||||
s.handleDeleteDocument(w, r, sessionID, database, collection, docID)
|
||||
default:
|
||||
s.sendError(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// handleGetDocument обрабатывает GET запросы
|
||||
func (s *HTTPServer) handleGetDocument(w http.ResponseWriter, r *http.Request, sessionID, database, collection, docID string) {
|
||||
// Проверка прав
|
||||
if !s.aclManager.CheckPermission(sessionID, database, collection, "read") {
|
||||
s.sendError(w, "Access denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
db, err := s.store.GetDatabase(database)
|
||||
if err != nil {
|
||||
s.sendError(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
coll, err := db.GetCollection(collection)
|
||||
if err != nil {
|
||||
s.sendError(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Поиск по индексу или ID
|
||||
query := r.URL.Query()
|
||||
if indexName := query.Get("index"); indexName != "" {
|
||||
indexValue := query.Get("value")
|
||||
docs, err := coll.FindByIndex(indexName, indexValue)
|
||||
if err != nil {
|
||||
s.sendError(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
s.sendSuccess(w, docs)
|
||||
return
|
||||
}
|
||||
|
||||
if docID == "" {
|
||||
// Возвращаем все документы (с пагинацией)
|
||||
limit := 100
|
||||
if limitStr := query.Get("limit"); limitStr != "" {
|
||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 1000 {
|
||||
limit = l
|
||||
}
|
||||
}
|
||||
|
||||
offset := 0
|
||||
if offsetStr := 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)
|
||||
}
|
||||
|
||||
result := allDocs[start:end]
|
||||
s.sendSuccess(w, map[string]interface{}{
|
||||
"documents": result,
|
||||
"total": len(allDocs),
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
doc, err := coll.Find(docID)
|
||||
if err != nil {
|
||||
s.sendError(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
s.sendSuccess(w, doc)
|
||||
}
|
||||
|
||||
// handleInsertDocument обрабатывает POST запросы
|
||||
func (s *HTTPServer) handleInsertDocument(w http.ResponseWriter, r *http.Request, sessionID, database, collection string) {
|
||||
if !s.aclManager.CheckPermission(sessionID, database, collection, "write") {
|
||||
s.sendError(w, "Access denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
db, err := s.store.GetDatabase(database)
|
||||
if err != nil {
|
||||
// Создаём БД если не существует
|
||||
if err := s.store.CreateDatabase(database); err != nil {
|
||||
s.sendError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
db, _ = s.store.GetDatabase(database)
|
||||
}
|
||||
|
||||
coll, err := db.GetCollection(collection)
|
||||
if err != nil {
|
||||
if err := db.CreateCollection(collection); err != nil {
|
||||
s.sendError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
coll, _ = db.GetCollection(collection)
|
||||
}
|
||||
|
||||
var doc map[string]interface{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&doc); err != nil {
|
||||
s.sendError(w, "Invalid JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := coll.InsertFromMap(doc); err != nil {
|
||||
s.sendError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
s.sendSuccess(w, map[string]string{"status": "inserted"})
|
||||
}
|
||||
|
||||
// handleUpdateDocument обрабатывает PUT запросы
|
||||
func (s *HTTPServer) handleUpdateDocument(w http.ResponseWriter, r *http.Request, sessionID, database, collection, docID string) {
|
||||
if docID == "" {
|
||||
s.sendError(w, "Document ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if !s.aclManager.CheckPermission(sessionID, database, collection, "write") {
|
||||
s.sendError(w, "Access denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
db, err := s.store.GetDatabase(database)
|
||||
if err != nil {
|
||||
s.sendError(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
coll, err := db.GetCollection(collection)
|
||||
if err != nil {
|
||||
s.sendError(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var updates map[string]interface{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
|
||||
s.sendError(w, "Invalid JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := coll.Update(docID, updates); err != nil {
|
||||
s.sendError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
s.sendSuccess(w, map[string]string{"status": "updated"})
|
||||
}
|
||||
|
||||
// handleDeleteDocument обрабатывает DELETE запросы
|
||||
func (s *HTTPServer) handleDeleteDocument(w http.ResponseWriter, r *http.Request, sessionID, database, collection, docID string) {
|
||||
if docID == "" {
|
||||
s.sendError(w, "Document ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if !s.aclManager.CheckPermission(sessionID, database, collection, "delete") {
|
||||
s.sendError(w, "Access denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
db, err := s.store.GetDatabase(database)
|
||||
if err != nil {
|
||||
s.sendError(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
coll, err := db.GetCollection(collection)
|
||||
if err != nil {
|
||||
s.sendError(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if err := coll.Delete(docID); err != nil {
|
||||
s.sendError(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
s.sendSuccess(w, map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
// handleIndexRequest обрабатывает запросы к индексам
|
||||
func (s *HTTPServer) handleIndexRequest(w http.ResponseWriter, r *http.Request) {
|
||||
// URL: /api/index/{database}/{collection}/{action}
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/index/")
|
||||
parts := strings.Split(path, "/")
|
||||
|
||||
if len(parts) < 3 {
|
||||
s.sendError(w, "Invalid path. Use /api/index/{database}/{collection}/{action}", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
database := parts[0]
|
||||
collection := parts[1]
|
||||
action := parts[2]
|
||||
|
||||
sessionID := r.Header.Get("X-Session-ID")
|
||||
if !s.aclManager.CheckPermission(sessionID, database, collection, "admin") {
|
||||
s.sendError(w, "Admin access required", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
db, err := s.store.GetDatabase(database)
|
||||
if err != nil {
|
||||
s.sendError(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
coll, err := db.GetCollection(collection)
|
||||
if err != nil {
|
||||
s.sendError(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "list":
|
||||
indexes := coll.GetIndexes()
|
||||
s.sendSuccess(w, indexes)
|
||||
|
||||
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 {
|
||||
s.sendError(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := coll.CreateIndex(req.Name, req.Fields, req.Unique); err != nil {
|
||||
s.sendError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
s.sendSuccess(w, map[string]string{"status": "index created"})
|
||||
|
||||
case "drop":
|
||||
if len(parts) < 4 {
|
||||
s.sendError(w, "Index name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
indexName := parts[3]
|
||||
if err := coll.DropIndex(indexName); err != nil {
|
||||
s.sendError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
s.sendSuccess(w, map[string]string{"status": "index dropped"})
|
||||
|
||||
default:
|
||||
s.sendError(w, "Unknown action", http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
// handleACLRequest обрабатывает запросы ACL
|
||||
func (s *HTTPServer) handleACLRequest(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/acl/")
|
||||
parts := strings.Split(path, "/")
|
||||
|
||||
if len(parts) < 1 {
|
||||
s.sendError(w, "Invalid path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
sessionID := r.Header.Get("X-Session-ID")
|
||||
if !s.aclManager.CheckPermission(sessionID, "*", "*", "admin") {
|
||||
s.sendError(w, "Admin access required", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
action := parts[0]
|
||||
|
||||
switch action {
|
||||
case "users":
|
||||
users := s.aclManager.ListUsers()
|
||||
s.sendSuccess(w, users)
|
||||
|
||||
case "user":
|
||||
if len(parts) < 2 {
|
||||
s.sendError(w, "Username required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
username := parts[1]
|
||||
|
||||
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 {
|
||||
s.sendError(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := s.aclManager.CreateUser(username, req.Password, req.Roles); err != nil {
|
||||
s.sendError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
s.sendSuccess(w, map[string]string{"status": "user created"})
|
||||
|
||||
default:
|
||||
s.sendError(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
||||
case "roles":
|
||||
roles := s.aclManager.ListRoles()
|
||||
s.sendSuccess(w, roles)
|
||||
|
||||
case "grant":
|
||||
if len(parts) < 3 {
|
||||
s.sendError(w, "Role and permission required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
roleName := parts[1]
|
||||
permission := parts[2]
|
||||
if err := s.aclManager.GrantPermission(roleName, permission); err != nil {
|
||||
s.sendError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
s.sendSuccess(w, map[string]string{"status": "permission granted"})
|
||||
|
||||
default:
|
||||
s.sendError(w, "Unknown action", http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
// handleConstraintRequest обрабатывает запросы к ограничениям
|
||||
func (s *HTTPServer) handleConstraintRequest(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/constraint/")
|
||||
parts := strings.Split(path, "/")
|
||||
|
||||
if len(parts) < 3 {
|
||||
s.sendError(w, "Invalid path. Use /api/constraint/{database}/{collection}/{action}", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
database := parts[0]
|
||||
collection := parts[1]
|
||||
action := parts[2]
|
||||
|
||||
sessionID := r.Header.Get("X-Session-ID")
|
||||
if !s.aclManager.CheckPermission(sessionID, database, collection, "admin") {
|
||||
s.sendError(w, "Admin access required", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
db, err := s.store.GetDatabase(database)
|
||||
if err != nil {
|
||||
s.sendError(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
coll, err := db.GetCollection(collection)
|
||||
if err != nil {
|
||||
s.sendError(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "required":
|
||||
if len(parts) < 4 {
|
||||
s.sendError(w, "Field name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
field := parts[3]
|
||||
coll.AddRequiredField(field)
|
||||
s.sendSuccess(w, map[string]string{"status": "required field added"})
|
||||
|
||||
case "unique":
|
||||
if len(parts) < 4 {
|
||||
s.sendError(w, "Field name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
field := parts[3]
|
||||
coll.AddUniqueConstraint(field)
|
||||
s.sendSuccess(w, map[string]string{"status": "unique constraint added"})
|
||||
|
||||
case "min":
|
||||
if len(parts) < 5 {
|
||||
s.sendError(w, "Field name and value required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
field := parts[3]
|
||||
minVal, _ := strconv.ParseFloat(parts[4], 64)
|
||||
coll.AddMinConstraint(field, minVal)
|
||||
s.sendSuccess(w, map[string]string{"status": "min constraint added"})
|
||||
|
||||
case "max":
|
||||
if len(parts) < 5 {
|
||||
s.sendError(w, "Field name and value required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
field := parts[3]
|
||||
maxVal, _ := strconv.ParseFloat(parts[4], 64)
|
||||
coll.AddMaxConstraint(field, maxVal)
|
||||
s.sendSuccess(w, map[string]string{"status": "max constraint added"})
|
||||
|
||||
default:
|
||||
s.sendError(w, "Unknown action", http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
// handleClusterRequest обрабатывает запросы к кластеру (исправлено: поддержка разных методов)
|
||||
func (s *HTTPServer) handleClusterRequest(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID := r.Header.Get("X-Session-ID")
|
||||
if !s.aclManager.CheckPermission(sessionID, "*", "*", "admin") {
|
||||
s.sendError(w, "Admin access required", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if s.coordinator == nil {
|
||||
s.sendError(w, "Cluster not available", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/cluster/")
|
||||
parts := strings.Split(path, "/")
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
if len(parts) == 0 || parts[0] == "" || parts[0] == "status" {
|
||||
status := s.coordinator.GetClusterStatus()
|
||||
s.sendSuccess(w, status)
|
||||
} else if parts[0] == "health" {
|
||||
health := s.coordinator.GetClusterHealth()
|
||||
s.sendSuccess(w, health)
|
||||
} else if parts[0] == "nodes" {
|
||||
nodes := s.coordinator.GetAllNodes()
|
||||
s.sendSuccess(w, nodes)
|
||||
} else {
|
||||
s.sendError(w, "Unknown cluster endpoint", http.StatusNotFound)
|
||||
}
|
||||
|
||||
case http.MethodPost:
|
||||
if len(parts) >= 2 && parts[0] == "replication" && parts[1] == "factor" {
|
||||
var req struct {
|
||||
Factor int `json:"factor"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
s.sendError(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Factor < 1 || req.Factor > 5 {
|
||||
s.sendError(w, "Replication factor must be between 1 and 5", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := s.coordinator.SetReplicationFactor(req.Factor); err != nil {
|
||||
s.sendError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
s.sendSuccess(w, map[string]interface{}{
|
||||
"status": "updated",
|
||||
"factor": req.Factor,
|
||||
})
|
||||
} else {
|
||||
s.sendError(w, "Unknown cluster endpoint", http.StatusNotFound)
|
||||
}
|
||||
|
||||
default:
|
||||
s.sendError(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// sendSuccess отправляет успешный ответ
|
||||
func (s *HTTPServer) sendSuccess(w http.ResponseWriter, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(APIResponse{
|
||||
Success: true,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
// sendError отправляет ответ с ошибкой
|
||||
func (s *HTTPServer) sendError(w http.ResponseWriter, errMsg string, statusCode int) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(statusCode)
|
||||
json.NewEncoder(w).Encode(APIResponse{
|
||||
Success: false,
|
||||
Error: errMsg,
|
||||
})
|
||||
}
|
||||
924
internal/api/static/app.js
Normal file
924
internal/api/static/app.js
Normal file
@@ -0,0 +1,924 @@
|
||||
/*
|
||||
* 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/static/app.js
|
||||
// JavaScript для веб-интерфейса Futriis DB Dashboard
|
||||
|
||||
// Глобальное состояние
|
||||
let currentSession = null;
|
||||
let currentDatabase = null;
|
||||
let currentCollection = null;
|
||||
let currentUser = null;
|
||||
|
||||
// DOM элементы
|
||||
const contentArea = document.getElementById('contentArea');
|
||||
const pageTitle = document.getElementById('pageTitle');
|
||||
const connectionStatus = document.getElementById('connectionStatus');
|
||||
const userInfoSpan = document.querySelector('#userInfo span');
|
||||
const logoutBtn = document.getElementById('logoutBtn');
|
||||
const menuToggle = document.getElementById('menuToggle');
|
||||
const sidebar = document.querySelector('.sidebar');
|
||||
const modal = document.getElementById('modal');
|
||||
const modalTitle = document.getElementById('modalTitle');
|
||||
const modalBody = document.getElementById('modalBody');
|
||||
const modalConfirm = document.getElementById('modalConfirm');
|
||||
const modalCloseBtns = document.querySelectorAll('.modal-close');
|
||||
|
||||
// Инициализация приложения
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
checkSession();
|
||||
initNavigation();
|
||||
initEventListeners();
|
||||
});
|
||||
|
||||
// Проверка сессии
|
||||
async function checkSession() {
|
||||
try {
|
||||
const response = await fetch('/api/webui/session');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.data.authenticated) {
|
||||
currentUser = data.data.username;
|
||||
userInfoSpan.textContent = currentUser;
|
||||
connectionStatus.classList.add('online');
|
||||
connectionStatus.classList.remove('offline');
|
||||
loadDashboard();
|
||||
} else {
|
||||
showLoginModal();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Session check failed:', error);
|
||||
showLoginModal();
|
||||
}
|
||||
}
|
||||
|
||||
// Показать модальное окно входа
|
||||
function showLoginModal() {
|
||||
modalTitle.textContent = 'Вход в систему';
|
||||
modalBody.innerHTML = `
|
||||
<div class="form-group">
|
||||
<label for="username">Имя пользователя</label>
|
||||
<input type="text" id="username" class="form-control" placeholder="Введите имя пользователя">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Пароль</label>
|
||||
<input type="password" id="password" class="form-control" placeholder="Введите пароль">
|
||||
</div>
|
||||
`;
|
||||
|
||||
modal.classList.add('show');
|
||||
|
||||
const confirmHandler = async () => {
|
||||
const username = document.getElementById('username').value;
|
||||
const password = document.getElementById('password').value;
|
||||
|
||||
if (!username || !password) {
|
||||
showNotification('Пожалуйста, заполните все поля', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/webui/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
currentUser = username;
|
||||
userInfoSpan.textContent = username;
|
||||
modal.classList.remove('show');
|
||||
showNotification('Вход выполнен успешно', 'success');
|
||||
loadDashboard();
|
||||
} else {
|
||||
showNotification(data.error || 'Ошибка входа', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showNotification('Ошибка подключения к серверу', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
modalConfirm.onclick = confirmHandler;
|
||||
|
||||
// Обработка Enter
|
||||
const handleEnter = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
confirmHandler();
|
||||
document.removeEventListener('keydown', handleEnter);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleEnter);
|
||||
}
|
||||
|
||||
// Инициализация навигации
|
||||
function initNavigation() {
|
||||
// Обработка кликов по пунктам меню
|
||||
document.querySelectorAll('.nav-link[data-section]').forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const section = link.dataset.section;
|
||||
loadSection(section);
|
||||
setActiveNav(link);
|
||||
});
|
||||
});
|
||||
|
||||
// Обработка подменю CRUD
|
||||
document.querySelectorAll('[data-action]').forEach(item => {
|
||||
item.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const action = item.dataset.action;
|
||||
handleCrudAction(action);
|
||||
});
|
||||
});
|
||||
|
||||
// Обработка раскрытия подменю
|
||||
document.querySelectorAll('.has-submenu > .nav-link').forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const parent = link.closest('.has-submenu');
|
||||
parent.classList.toggle('open');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Инициализация обработчиков событий
|
||||
function initEventListeners() {
|
||||
// Выход
|
||||
logoutBtn.addEventListener('click', async () => {
|
||||
await fetch('/api/webui/logout', { method: 'POST' });
|
||||
currentSession = null;
|
||||
currentUser = null;
|
||||
showLoginModal();
|
||||
});
|
||||
|
||||
// Мобильное меню
|
||||
if (menuToggle) {
|
||||
menuToggle.addEventListener('click', () => {
|
||||
sidebar.classList.toggle('open');
|
||||
});
|
||||
}
|
||||
|
||||
// Закрытие модального окна
|
||||
modalCloseBtns.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
modal.classList.remove('show');
|
||||
});
|
||||
});
|
||||
|
||||
// Закрытие модального окна по клику вне его
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) {
|
||||
modal.classList.remove('show');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Загрузка секции
|
||||
async function loadSection(section) {
|
||||
switch(section) {
|
||||
case 'dashboard':
|
||||
loadDashboard();
|
||||
break;
|
||||
case 'cluster':
|
||||
loadClusterManagement();
|
||||
break;
|
||||
case 'audit':
|
||||
loadAuditLog();
|
||||
break;
|
||||
case 'settings':
|
||||
loadSettings();
|
||||
break;
|
||||
default:
|
||||
loadDashboard();
|
||||
}
|
||||
}
|
||||
|
||||
// Загрузка дашборда
|
||||
async function loadDashboard() {
|
||||
pageTitle.textContent = 'Панель управления';
|
||||
contentArea.innerHTML = '<div class="loading-spinner"><i class="fas fa-spinner fa-pulse"></i><p>Загрузка данных...</p></div>';
|
||||
|
||||
try {
|
||||
const [statsRes, dbsRes] = await Promise.all([
|
||||
fetch('/api/webui/stats'),
|
||||
fetch('/api/webui/databases')
|
||||
]);
|
||||
|
||||
const stats = await statsRes.json();
|
||||
const databases = await dbsRes.json();
|
||||
|
||||
contentArea.innerHTML = `
|
||||
<div class="dashboard-stats">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon"><i class="fas fa-database"></i></div>
|
||||
<div class="stat-info">
|
||||
<h3>${stats.data.databases || 0}</h3>
|
||||
<p>Базы данных</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon"><i class="fas fa-table"></i></div>
|
||||
<div class="stat-info">
|
||||
<h3>${stats.data.collections || 0}</h3>
|
||||
<p>Коллекции</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon"><i class="fas fa-file-alt"></i></div>
|
||||
<div class="stat-info">
|
||||
<h3>${stats.data.documents || 0}</h3>
|
||||
<p>Документы</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon"><i class="fas fa-hdd"></i></div>
|
||||
<div class="stat-info">
|
||||
<h3>${stats.data.storage_used_mb?.toFixed(2) || 0} MB</h3>
|
||||
<p>Использовано памяти</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="data-table">
|
||||
<h3 style="margin-bottom: 16px;">Базы данных</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Имя БД</th><th>Коллекции</th><th>Действия</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${databases.data.map(db => `
|
||||
<tr>
|
||||
<td><strong>${escapeHtml(db.name)}</strong></td>
|
||||
<td>${db.collections}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-primary" onclick="viewDatabase('${escapeHtml(db.name)}')">
|
||||
<i class="fas fa-eye"></i> Просмотр
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
} catch (error) {
|
||||
contentArea.innerHTML = '<div class="error-message">Ошибка загрузки данных</div>';
|
||||
showNotification('Ошибка загрузки дашборда', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Просмотр базы данных
|
||||
window.viewDatabase = async function(dbName) {
|
||||
currentDatabase = dbName;
|
||||
pageTitle.textContent = `База данных: ${dbName}`;
|
||||
contentArea.innerHTML = '<div class="loading-spinner"><i class="fas fa-spinner fa-pulse"></i><p>Загрузка коллекций...</p></div>';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/webui/collections/${dbName}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
contentArea.innerHTML = `
|
||||
<div class="data-table">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||
<h3>Коллекции</h3>
|
||||
<button class="btn btn-primary btn-sm" onclick="showCreateCollectionModal()">
|
||||
<i class="fas fa-plus"></i> Создать коллекцию
|
||||
</button>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Имя коллекции</th><th>Документов</th><th>Размер</th><th>Индексы</th><th>Действия</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${data.data.collections.map(coll => `
|
||||
<tr>
|
||||
<td><strong>${escapeHtml(coll.name)}</strong></td>
|
||||
<td>${coll.count}</td>
|
||||
<td>${(coll.size / 1024).toFixed(2)} KB</td>
|
||||
<td>${coll.indexes.length}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-primary" onclick="viewCollection('${escapeHtml(dbName)}', '${escapeHtml(coll.name)}')">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteCollection('${escapeHtml(dbName)}', '${escapeHtml(coll.name)}')">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
contentArea.innerHTML = '<div class="error-message">Ошибка загрузки коллекций</div>';
|
||||
}
|
||||
} catch (error) {
|
||||
contentArea.innerHTML = '<div class="error-message">Ошибка подключения</div>';
|
||||
}
|
||||
};
|
||||
|
||||
// Просмотр коллекции
|
||||
window.viewCollection = async function(dbName, collName) {
|
||||
currentDatabase = dbName;
|
||||
currentCollection = collName;
|
||||
pageTitle.textContent = `Коллекция: ${dbName}.${collName}`;
|
||||
contentArea.innerHTML = '<div class="loading-spinner"><i class="fas fa-spinner fa-pulse"></i><p>Загрузка документов...</p></div>';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/webui/documents/${dbName}/${collName}?limit=100`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
contentArea.innerHTML = `
|
||||
<div style="margin-bottom: 16px; display: flex; gap: 12px; flex-wrap: wrap;">
|
||||
<button class="btn btn-primary" onclick="showInsertDocumentModal()">
|
||||
<i class="fas fa-plus"></i> Вставить документ
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="viewDatabase('${escapeHtml(dbName)}')">
|
||||
<i class="fas fa-arrow-left"></i> Назад
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="data-table">
|
||||
<h3 style="margin-bottom: 16px;">Документы (${data.data.total} всего)</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>ID</th><th>Поля</th><th>Создан</th><th>Действия</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${data.data.documents.map(doc => `
|
||||
<tr>
|
||||
<td><code>${escapeHtml(doc.id)}</code></td>
|
||||
<td><pre style="max-width: 400px; overflow-x: auto;">${escapeHtml(JSON.stringify(doc.fields, null, 2))}</pre></td>
|
||||
<td>${new Date(doc.created_at).toLocaleString()}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-secondary" onclick="showUpdateDocumentModal('${escapeHtml(doc.id)}', ${escapeHtml(JSON.stringify(doc.fields))})">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteDocument('${escapeHtml(dbName)}', '${escapeHtml(collName)}', '${escapeHtml(doc.id)}')">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
contentArea.innerHTML = '<div class="error-message">Ошибка загрузки документов</div>';
|
||||
}
|
||||
} catch (error) {
|
||||
contentArea.innerHTML = '<div class="error-message">Ошибка подключения</div>';
|
||||
}
|
||||
};
|
||||
|
||||
// Загрузка управления кластером
|
||||
async function loadClusterManagement() {
|
||||
pageTitle.textContent = 'Управление кластером';
|
||||
contentArea.innerHTML = '<div class="loading-spinner"><i class="fas fa-spinner fa-pulse"></i><p>Загрузка информации о кластере...</p></div>';
|
||||
|
||||
try {
|
||||
const [statusRes, nodesRes] = await Promise.all([
|
||||
fetch('/api/webui/cluster/status'),
|
||||
fetch('/api/webui/cluster/nodes')
|
||||
]);
|
||||
|
||||
const status = await statusRes.json();
|
||||
const nodes = await nodesRes.json();
|
||||
|
||||
contentArea.innerHTML = `
|
||||
<div class="dashboard-stats">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon"><i class="fas fa-heartbeat"></i></div>
|
||||
<div class="stat-info">
|
||||
<h3 style="color: ${status.data.health === 'healthy' ? '#28a745' : status.data.health === 'degraded' ? '#ffc107' : '#dc3545'}">
|
||||
${status.data.health === 'healthy' ? 'Здоров' : status.data.health === 'degraded' ? 'Деградирован' : 'Критический'}
|
||||
</h3>
|
||||
<p>Состояние кластера</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon"><i class="fas fa-server"></i></div>
|
||||
<div class="stat-info">
|
||||
<h3>${status.data.active_nodes}/${status.data.total_nodes}</h3>
|
||||
<p>Активные узлы</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon"><i class="fas fa-copy"></i></div>
|
||||
<div class="stat-info">
|
||||
<h3>${status.data.replication_factor}</h3>
|
||||
<p>Фактор репликации</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="data-table">
|
||||
<h3 style="margin-bottom: 16px;">Узлы кластера</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>ID узла</th><th>Адрес</th><th>Статус</th><th>Последний контакт</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${nodes.data.map(node => `
|
||||
<tr>
|
||||
<td><code>${escapeHtml(node.id)}</code></td>
|
||||
<td>${escapeHtml(node.ip)}:${node.port}</td>
|
||||
<td><span class="status-badge status-${node.status}">${node.status}</span></td>
|
||||
<td>${new Date(node.last_seen * 1000).toLocaleString()}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
} catch (error) {
|
||||
contentArea.innerHTML = '<div class="error-message">Ошибка загрузки информации о кластере</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// Загрузка лога аудита
|
||||
async function loadAuditLog() {
|
||||
pageTitle.textContent = 'Лог аудита';
|
||||
contentArea.innerHTML = '<div class="loading-spinner"><i class="fas fa-spinner fa-pulse"></i><p>Загрузка лога аудита...</p></div>';
|
||||
// TODO: Реализовать API для получения лога аудита
|
||||
contentArea.innerHTML = '<div class="info-message">Функция в разработке</div>';
|
||||
}
|
||||
|
||||
// Загрузка настроек
|
||||
function loadSettings() {
|
||||
pageTitle.textContent = 'Настройки';
|
||||
contentArea.innerHTML = `
|
||||
<div class="settings-panel">
|
||||
<h3>Настройки интерфейса</h3>
|
||||
<div class="form-group">
|
||||
<label>Тема оформления</label>
|
||||
<select class="form-control" id="themeSelect">
|
||||
<option value="dark">Тёмная</option>
|
||||
<option value="light">Светлая</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="saveSettings()">Сохранить настройки</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Обработка CRUD действий
|
||||
function handleCrudAction(action) {
|
||||
switch(action) {
|
||||
case 'create-db':
|
||||
showCreateDatabaseModal();
|
||||
break;
|
||||
case 'create-collection':
|
||||
showCreateCollectionModal();
|
||||
break;
|
||||
case 'insert-doc':
|
||||
showInsertDocumentModal();
|
||||
break;
|
||||
case 'find-doc':
|
||||
showFindDocumentModal();
|
||||
break;
|
||||
case 'update-doc':
|
||||
showUpdateDocumentModal();
|
||||
break;
|
||||
case 'delete-doc':
|
||||
showDeleteDocumentModal();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Показать модальное окно создания БД
|
||||
function showCreateDatabaseModal() {
|
||||
modalTitle.textContent = 'Создать базу данных';
|
||||
modalBody.innerHTML = `
|
||||
<div class="form-group">
|
||||
<label for="dbName">Имя базы данных</label>
|
||||
<input type="text" id="dbName" class="form-control" placeholder="my_database">
|
||||
</div>
|
||||
`;
|
||||
|
||||
modal.classList.add('show');
|
||||
|
||||
modalConfirm.onclick = async () => {
|
||||
const dbName = document.getElementById('dbName').value;
|
||||
if (!dbName) {
|
||||
showNotification('Введите имя базы данных', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/db/' + dbName, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
modal.classList.remove('show');
|
||||
showNotification(`База данных "${dbName}" создана`, 'success');
|
||||
loadDashboard();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showNotification(error.error || 'Ошибка создания БД', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showNotification('Ошибка подключения', 'error');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Показать модальное окно создания коллекции
|
||||
function showCreateCollectionModal() {
|
||||
if (!currentDatabase) {
|
||||
showNotification('Сначала выберите базу данных', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
modalTitle.textContent = 'Создать коллекцию';
|
||||
modalBody.innerHTML = `
|
||||
<div class="form-group">
|
||||
<label>База данных</label>
|
||||
<input type="text" class="form-control" value="${escapeHtml(currentDatabase)}" disabled>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="collName">Имя коллекции</label>
|
||||
<input type="text" id="collName" class="form-control" placeholder="my_collection">
|
||||
</div>
|
||||
`;
|
||||
|
||||
modal.classList.add('show');
|
||||
|
||||
modalConfirm.onclick = async () => {
|
||||
const collName = document.getElementById('collName').value;
|
||||
if (!collName) {
|
||||
showNotification('Введите имя коллекции', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/db/${currentDatabase}/${collName}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
modal.classList.remove('show');
|
||||
showNotification(`Коллекция "${collName}" создана`, 'success');
|
||||
viewDatabase(currentDatabase);
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showNotification(error.error || 'Ошибка создания коллекции', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showNotification('Ошибка подключения', 'error');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Показать модальное окно вставки документа
|
||||
function showInsertDocumentModal() {
|
||||
if (!currentDatabase || !currentCollection) {
|
||||
showNotification('Сначала выберите базу данных и коллекцию', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
modalTitle.textContent = 'Вставить документ';
|
||||
modalBody.innerHTML = `
|
||||
<div class="form-group">
|
||||
<label>База данных</label>
|
||||
<input type="text" class="form-control" value="${escapeHtml(currentDatabase)}" disabled>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Коллекция</label>
|
||||
<input type="text" class="form-control" value="${escapeHtml(currentCollection)}" disabled>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="docData">Данные документа (JSON)</label>
|
||||
<textarea id="docData" class="form-control" rows="8" placeholder='{"_id": "doc1", "name": "Example", "value": 123}'></textarea>
|
||||
</div>
|
||||
`;
|
||||
|
||||
modal.classList.add('show');
|
||||
|
||||
modalConfirm.onclick = async () => {
|
||||
const docData = document.getElementById('docData').value;
|
||||
if (!docData) {
|
||||
showNotification('Введите данные документа', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(docData);
|
||||
const response = await fetch(`/api/webui/documents/${currentDatabase}/${currentCollection}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
modal.classList.remove('show');
|
||||
showNotification('Документ вставлен', 'success');
|
||||
viewCollection(currentDatabase, currentCollection);
|
||||
} else {
|
||||
showNotification(result.error || 'Ошибка вставки документа', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
showNotification('Неверный формат JSON', 'error');
|
||||
} else {
|
||||
showNotification('Ошибка подключения', 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Показать модальное окно поиска документа
|
||||
function showFindDocumentModal() {
|
||||
if (!currentDatabase || !currentCollection) {
|
||||
showNotification('Сначала выберите базу данных и коллекцию', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
modalTitle.textContent = 'Найти документ';
|
||||
modalBody.innerHTML = `
|
||||
<div class="form-group">
|
||||
<label>База данных</label>
|
||||
<input type="text" class="form-control" value="${escapeHtml(currentDatabase)}" disabled>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Коллекция</label>
|
||||
<input type="text" class="form-control" value="${escapeHtml(currentCollection)}" disabled>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="docId">ID документа</label>
|
||||
<input type="text" id="docId" class="form-control" placeholder="document_id">
|
||||
</div>
|
||||
`;
|
||||
|
||||
modal.classList.add('show');
|
||||
|
||||
modalConfirm.onclick = async () => {
|
||||
const docId = document.getElementById('docId').value;
|
||||
if (!docId) {
|
||||
showNotification('Введите ID документа', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/db/${currentDatabase}/${currentCollection}/${docId}`);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
modal.classList.remove('show');
|
||||
|
||||
// Показать результат поиска
|
||||
contentArea.innerHTML = `
|
||||
<div class="data-table">
|
||||
<h3>Результат поиска</h3>
|
||||
<pre style="background: var(--bg-dark); padding: 16px; border-radius: 8px; overflow-x: auto;">
|
||||
${escapeHtml(JSON.stringify(data.data, null, 2))}
|
||||
</pre>
|
||||
<button class="btn btn-secondary" onclick="viewCollection('${escapeHtml(currentDatabase)}', '${escapeHtml(currentCollection)}')">
|
||||
<i class="fas fa-arrow-left"></i> Назад
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showNotification(error.error || 'Документ не найден', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showNotification('Ошибка подключения', 'error');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Показать модальное окно обновления документа
|
||||
function showUpdateDocumentModal(docId, currentFields = null) {
|
||||
if (!currentDatabase || !currentCollection) {
|
||||
showNotification('Сначала выберите базу данных и коллекцию', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const fieldsJson = currentFields ? JSON.stringify(currentFields, null, 2) : '';
|
||||
|
||||
modalTitle.textContent = 'Обновить документ';
|
||||
modalBody.innerHTML = `
|
||||
<div class="form-group">
|
||||
<label>База данных</label>
|
||||
<input type="text" class="form-control" value="${escapeHtml(currentDatabase)}" disabled>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Коллекция</label>
|
||||
<input type="text" class="form-control" value="${escapeHtml(currentCollection)}" disabled>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="updateDocId">ID документа</label>
|
||||
<input type="text" id="updateDocId" class="form-control" value="${escapeHtml(docId || '')}" ${docId ? 'disabled' : ''} placeholder="document_id">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="updateData">Обновления (JSON)</label>
|
||||
<textarea id="updateData" class="form-control" rows="8" placeholder='{"field1": "new value", "field2": 456}'>${escapeHtml(fieldsJson)}</textarea>
|
||||
</div>
|
||||
`;
|
||||
|
||||
modal.classList.add('show');
|
||||
|
||||
modalConfirm.onclick = async () => {
|
||||
const updateDocId = document.getElementById('updateDocId').value;
|
||||
const updateData = document.getElementById('updateData').value;
|
||||
|
||||
if (!updateDocId) {
|
||||
showNotification('Введите ID документа', 'error');
|
||||
return;
|
||||
}
|
||||
if (!updateData) {
|
||||
showNotification('Введите данные для обновления', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(updateData);
|
||||
const response = await fetch(`/api/webui/documents/${currentDatabase}/${currentCollection}?id=${encodeURIComponent(updateDocId)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
modal.classList.remove('show');
|
||||
showNotification('Документ обновлён', 'success');
|
||||
viewCollection(currentDatabase, currentCollection);
|
||||
} else {
|
||||
showNotification(result.error || 'Ошибка обновления документа', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
showNotification('Неверный формат JSON', 'error');
|
||||
} else {
|
||||
showNotification('Ошибка подключения', 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Показать модальное окно удаления документа
|
||||
function showDeleteDocumentModal() {
|
||||
if (!currentDatabase || !currentCollection) {
|
||||
showNotification('Сначала выберите базу данных и коллекцию', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
modalTitle.textContent = 'Удалить документ';
|
||||
modalBody.innerHTML = `
|
||||
<div class="form-group">
|
||||
<label>База данных</label>
|
||||
<input type="text" class="form-control" value="${escapeHtml(currentDatabase)}" disabled>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Коллекция</label>
|
||||
<input type="text" class="form-control" value="${escapeHtml(currentCollection)}" disabled>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="deleteDocId">ID документа</label>
|
||||
<input type="text" id="deleteDocId" class="form-control" placeholder="document_id">
|
||||
</div>
|
||||
`;
|
||||
|
||||
modal.classList.add('show');
|
||||
|
||||
modalConfirm.onclick = async () => {
|
||||
const docId = document.getElementById('deleteDocId').value;
|
||||
if (!docId) {
|
||||
showNotification('Введите ID документа', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/webui/documents/${currentDatabase}/${currentCollection}?id=${encodeURIComponent(docId)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
modal.classList.remove('show');
|
||||
showNotification('Документ удалён', 'success');
|
||||
viewCollection(currentDatabase, currentCollection);
|
||||
} else {
|
||||
showNotification(result.error || 'Ошибка удаления документа', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showNotification('Ошибка подключения', 'error');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Удаление коллекции
|
||||
window.deleteCollection = async function(dbName, collName) {
|
||||
if (confirm(`Вы уверены, что хотите удалить коллекцию "${collName}"? Это действие необратимо.`)) {
|
||||
try {
|
||||
const response = await fetch(`/api/db/${dbName}/${collName}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showNotification(`Коллекция "${collName}" удалена`, 'success');
|
||||
viewDatabase(dbName);
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showNotification(error.error || 'Ошибка удаления коллекции', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showNotification('Ошибка подключения', 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Удаление документа
|
||||
window.deleteDocument = async function(dbName, collName, docId) {
|
||||
if (confirm(`Вы уверены, что хотите удалить документ "${docId}"?`)) {
|
||||
try {
|
||||
const response = await fetch(`/api/webui/documents/${dbName}/${collName}?id=${encodeURIComponent(docId)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showNotification('Документ удалён', 'success');
|
||||
viewCollection(dbName, collName);
|
||||
} else {
|
||||
showNotification(result.error || 'Ошибка удаления документа', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showNotification('Ошибка подключения', 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Сохранение настроек
|
||||
function saveSettings() {
|
||||
const theme = document.getElementById('themeSelect')?.value;
|
||||
if (theme) {
|
||||
localStorage.setItem('theme', theme);
|
||||
showNotification('Настройки сохранены', 'success');
|
||||
}
|
||||
}
|
||||
|
||||
// Утилиты
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function showNotification(message, type = 'info') {
|
||||
const container = document.getElementById('notificationContainer');
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `notification ${type}`;
|
||||
|
||||
let icon = '';
|
||||
switch(type) {
|
||||
case 'success': icon = '<i class="fas fa-check-circle"></i>'; break;
|
||||
case 'error': icon = '<i class="fas fa-exclamation-circle"></i>'; break;
|
||||
case 'warning': icon = '<i class="fas fa-exclamation-triangle"></i>'; break;
|
||||
default: icon = '<i class="fas fa-info-circle"></i>';
|
||||
}
|
||||
|
||||
notification.innerHTML = `${icon}<span>${escapeHtml(message)}</span>`;
|
||||
container.appendChild(notification);
|
||||
|
||||
setTimeout(() => {
|
||||
notification.style.animation = 'slideOutRight 0.3s ease';
|
||||
setTimeout(() => notification.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function setActiveNav(activeLink) {
|
||||
document.querySelectorAll('.nav-link').forEach(link => {
|
||||
link.classList.remove('active');
|
||||
});
|
||||
activeLink.classList.add('active');
|
||||
}
|
||||
695
internal/api/static/style.css
Normal file
695
internal/api/static/style.css
Normal file
@@ -0,0 +1,695 @@
|
||||
/*
|
||||
* 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/static/style.css */
|
||||
/* Стили для веб-интерфейса Futriis DB Dashboard */
|
||||
|
||||
:root {
|
||||
--primary-color: #00bfff;
|
||||
--primary-dark: #0099cc;
|
||||
--secondary-color: #6c757d;
|
||||
--success-color: #28a745;
|
||||
--danger-color: #dc3545;
|
||||
--warning-color: #ffc107;
|
||||
--info-color: #17a2b8;
|
||||
|
||||
--bg-dark: #1a1a2e;
|
||||
--bg-sidebar: #16213e;
|
||||
--bg-card: #0f3460;
|
||||
--bg-hover: #1a1f3a;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #b8c6db;
|
||||
--border-color: #2d3a5e;
|
||||
|
||||
--shadow-sm: 0 2px 4px rgba(0,0,0,0.1);
|
||||
--shadow-md: 0 4px 8px rgba(0,0,0,0.15);
|
||||
--shadow-lg: 0 8px 16px rgba(0,0,0,0.2);
|
||||
|
||||
--transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg-dark);
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Dashboard Container */
|
||||
.dashboard-container {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Sidebar - Вертикальное меню */
|
||||
.sidebar {
|
||||
width: 280px;
|
||||
background: var(--bg-sidebar);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: var(--transition);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 100;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 24px 20px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.logo i {
|
||||
color: var(--primary-color);
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.logo span {
|
||||
background: linear-gradient(135deg, var(--primary-color), #00ffcc);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.menu-toggle {
|
||||
display: none;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
/* Navigation Menu */
|
||||
.nav-menu {
|
||||
flex: 1;
|
||||
list-style: none;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
transition: var(--transition);
|
||||
border-radius: 8px;
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-link i {
|
||||
width: 24px;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.nav-link span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Submenu */
|
||||
.has-submenu > .nav-link {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.has-submenu > .nav-link .fa-chevron-down {
|
||||
margin-left: auto;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.has-submenu.open > .nav-link .fa-chevron-down {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.submenu {
|
||||
list-style: none;
|
||||
padding-left: 56px;
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease;
|
||||
}
|
||||
|
||||
.has-submenu.open .submenu {
|
||||
max-height: 500px;
|
||||
}
|
||||
|
||||
.submenu li a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 16px;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
transition: var(--transition);
|
||||
border-radius: 6px;
|
||||
margin: 2px 8px;
|
||||
}
|
||||
|
||||
.submenu li a:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.submenu li a i {
|
||||
width: 20px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Sidebar Footer */
|
||||
.sidebar-footer {
|
||||
padding: 20px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: var(--bg-dark);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.user-info i {
|
||||
font-size: 1.2rem;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 16px;
|
||||
background: var(--danger-color);
|
||||
border: none;
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
background: #c82333;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
background: var(--bg-sidebar);
|
||||
padding: 16px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.top-bar h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.connection-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
background: var(--bg-dark);
|
||||
border-radius: 20px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.connection-status i {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.connection-status.online i {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.connection-status.offline i {
|
||||
color: var(--danger-color);
|
||||
}
|
||||
|
||||
.content-area {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* Dashboard Cards */
|
||||
.dashboard-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-card);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background: rgba(0, 191, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stat-icon i {
|
||||
font-size: 2rem;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.stat-info h3 {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stat-info p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.data-table {
|
||||
width: 100%;
|
||||
background: var(--bg-card);
|
||||
border-radius: 12px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.data-table table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
background: var(--bg-sidebar);
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.data-table tr:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-dark);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.95rem;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(0, 191, 255, 0.2);
|
||||
}
|
||||
|
||||
textarea.form-control {
|
||||
min-height: 120px;
|
||||
font-family: monospace;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-dark);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--secondary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #5a6268;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
z-index: 1000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal.show {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--bg-card);
|
||||
border-radius: 16px;
|
||||
width: 90%;
|
||||
max-width: 600px;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: modalSlideIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes modalSlideIn {
|
||||
from {
|
||||
transform: translateY(-50px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Notifications */
|
||||
.notification-container {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 1100;
|
||||
}
|
||||
|
||||
.notification {
|
||||
background: var(--bg-card);
|
||||
border-radius: 8px;
|
||||
padding: 12px 20px;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
animation: slideInRight 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.notification.success {
|
||||
border-left: 4px solid var(--success-color);
|
||||
}
|
||||
|
||||
.notification.error {
|
||||
border-left: 4px solid var(--danger-color);
|
||||
}
|
||||
|
||||
.notification.warning {
|
||||
border-left: 4px solid var(--warning-color);
|
||||
}
|
||||
|
||||
.notification.info {
|
||||
border-left: 4px solid var(--info-color);
|
||||
}
|
||||
|
||||
/* Loading Spinner */
|
||||
.loading-spinner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.loading-spinner i {
|
||||
font-size: 3rem;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.loading-spinner p {
|
||||
margin-top: 16px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 8px 16px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
left: -280px;
|
||||
height: 100%;
|
||||
transition: left 0.3s ease;
|
||||
}
|
||||
|
||||
.sidebar.open {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.menu-toggle {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.dashboard-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
width: 95%;
|
||||
margin: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.top-bar h1 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.stat-info h3 {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Scrollbar Styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-dark);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--primary-color);
|
||||
}
|
||||
636
internal/api/webui.go
Normal file
636
internal/api/webui.go
Normal file
@@ -0,0 +1,636 @@
|
||||
/*
|
||||
* 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">
|
||||
<i class="fas fa-database"></i>
|
||||
<span>Futriis DB</span>
|
||||
</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">
|
||||
<i class="fas fa-circle"></i>
|
||||
<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">×</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
|
||||
}
|
||||
|
||||
sessionID, err := w.aclManager.Authenticate(creds.Username, creds.Password)
|
||||
if err != nil {
|
||||
w.sendJSONError(wr, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Устанавливаем 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)
|
||||
w.sendJSONSuccess(wr, map[string]interface{}{
|
||||
"authenticated": true,
|
||||
"username": username,
|
||||
})
|
||||
}
|
||||
|
||||
// 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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user