first commit

This commit is contained in:
2026-04-19 16:42:41 +03:00
commit e82fb947be
37 changed files with 14591 additions and 0 deletions

672
internal/api/http.go Normal file
View 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
View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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');
}

View 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
View 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">&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
}
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,
})
}