diff --git a/internal/api/static/app.js b/internal/api/static/app.js new file mode 100644 index 0000000..e7cf296 --- /dev/null +++ b/internal/api/static/app.js @@ -0,0 +1,959 @@ +/* + * 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; + + if (data.data.connection_status === 'connected') { + connectionStatus.className = 'connection-status online'; + connectionStatus.innerHTML = 'СУБД подключена'; + } else { + connectionStatus.className = 'connection-status offline'; + connectionStatus.innerHTML = 'СУБД не подключена'; + } + + loadDashboard(); + } else { + showLoginModal(); + } + } catch (error) { + console.error('Session check failed:', error); + showLoginModal(); + } +} + +// Функция для проверки статуса подключения +function startConnectionStatusMonitor() { + setInterval(async () => { + if (currentUser) { + try { + const response = await fetch('/api/webui/session'); + const data = await response.json(); + + if (data.success && data.data.connection_status === 'connected') { + connectionStatus.className = 'connection-status online'; + connectionStatus.innerHTML = 'СУБД подключена'; + } else { + connectionStatus.className = 'connection-status offline'; + connectionStatus.innerHTML = 'СУБД не подключена'; + } + } catch (error) { + connectionStatus.className = 'connection-status offline'; + connectionStatus.innerHTML = 'СУБД не подключена'; + } + } + }, 5000); +} + +// Показать модальное окно входа +function showLoginModal() { + modalTitle.textContent = 'Вход в систему субд Futriis'; + modalBody.innerHTML = ` +
+ + +
+
+ + +
+ `; + + // Меняем текст на кнопке "Подтвердить" на "Войти" + modalConfirm.textContent = 'Войти'; + + 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'); + connectionStatus.className = 'connection-status online'; + connectionStatus.innerHTML = 'СУБД подключена'; + startConnectionStatusMonitor(); + 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); + }); + }); + + 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; + connectionStatus.className = 'connection-status offline'; + connectionStatus.innerHTML = 'СУБД не подключена'; + 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 = '

Загрузка данных...

'; + + 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 = ` +
+
+
+
+

${stats.data.databases || 0}

+

Базы данных

+
+
+
+
+
+

${stats.data.collections || 0}

+

Коллекции

+
+
+
+
+
+

${stats.data.documents || 0}

+

Документы

+
+
+
+
+
+

${stats.data.storage_used_mb?.toFixed(2) || 0} MB

+

Использовано памяти

+
+
+
+ +
+

Базы данных

+ + + + + + ${databases.data.map(db => ` + + + + + + `).join('')} + +
Имя БДКоллекцииДействия
${escapeHtml(db.name)}${db.collections} + +
+
+ `; + } catch (error) { + contentArea.innerHTML = '
Ошибка загрузки данных
'; + showNotification('Ошибка загрузки дашборда', 'error'); + } +} + +// Просмотр базы данных +window.viewDatabase = async function(dbName) { + currentDatabase = dbName; + pageTitle.textContent = `База данных: ${dbName}`; + contentArea.innerHTML = '

Загрузка коллекций...

'; + + try { + const response = await fetch(`/api/webui/collections/${dbName}`); + const data = await response.json(); + + if (data.success) { + contentArea.innerHTML = ` +
+
+

Коллекции

+ +
+ + + + + + ${data.data.collections.map(coll => ` + + + + + + + + `).join('')} + +
Имя коллекцииДокументовРазмерИндексыДействия
${escapeHtml(coll.name)}${coll.count}${(coll.size / 1024).toFixed(2)} KB${coll.indexes.length} + + +
+
+ `; + } else { + contentArea.innerHTML = '
Ошибка загрузки коллекций
'; + } + } catch (error) { + contentArea.innerHTML = '
Ошибка подключения
'; + } +}; + +// Просмотр коллекции +window.viewCollection = async function(dbName, collName) { + currentDatabase = dbName; + currentCollection = collName; + pageTitle.textContent = `Коллекция: ${dbName}.${collName}`; + contentArea.innerHTML = '

Загрузка документов...

'; + + try { + const response = await fetch(`/api/webui/documents/${dbName}/${collName}?limit=100`); + const data = await response.json(); + + if (data.success) { + contentArea.innerHTML = ` +
+ + +
+ +
+

Документы (${data.data.total} всего)

+ + + + + + ${data.data.documents.map(doc => ` + + + + + + + `).join('')} + +
IDПоляСозданДействия
${escapeHtml(doc.id)}
${escapeHtml(JSON.stringify(doc.fields, null, 2))}
${new Date(doc.created_at).toLocaleString()} + + +
+
+ `; + } else { + contentArea.innerHTML = '
Ошибка загрузки документов
'; + } + } catch (error) { + contentArea.innerHTML = '
Ошибка подключения
'; + } +}; + +// Загрузка управления кластером +async function loadClusterManagement() { + pageTitle.textContent = 'Управление кластером'; + contentArea.innerHTML = '

Загрузка информации о кластере...

'; + + 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 = ` +
+
+
+
+

+ ${status.data.health === 'healthy' ? 'Здоров' : status.data.health === 'degraded' ? 'Деградирован' : 'Критический'} +

+

Состояние кластера

+
+
+
+
+
+

${status.data.active_nodes}/${status.data.total_nodes}

+

Активные узлы

+
+
+
+
+
+

${status.data.replication_factor}

+

Фактор репликации

+
+
+
+ +
+

Узлы кластера

+ + + + + + ${nodes.data.map(node => ` + + + + + + + `).join('')} + +
ID узлаАдресСтатусПоследний контакт
${escapeHtml(node.id)}${escapeHtml(node.ip)}:${node.port}${node.status}${new Date(node.last_seen * 1000).toLocaleString()}
+
+ `; + } catch (error) { + contentArea.innerHTML = '
Ошибка загрузки информации о кластере
'; + } +} + +// Загрузка лога аудита +async function loadAuditLog() { + pageTitle.textContent = 'Лог аудита'; + contentArea.innerHTML = '

Загрузка лога аудита...

'; + contentArea.innerHTML = '
Функция в разработке
'; +} + +// Загрузка настроек +function loadSettings() { + pageTitle.textContent = 'Настройки'; + contentArea.innerHTML = ` +
+

Настройки интерфейса

+
+ + +
+ +
+ `; +} + +// Обработка 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 = 'Создать базу данных'; + modalConfirm.textContent = 'Подтвердить'; + modalBody.innerHTML = ` +
+ + +
+ `; + + 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 = 'Создать коллекцию'; + modalConfirm.textContent = 'Подтвердить'; + modalBody.innerHTML = ` +
+ + +
+
+ + +
+ `; + + 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 = 'Вставить документ'; + modalConfirm.textContent = 'Подтвердить'; + modalBody.innerHTML = ` +
+ + +
+
+ + +
+
+ + +
+ `; + + 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 = 'Найти документ'; + modalConfirm.textContent = 'Найти'; + modalBody.innerHTML = ` +
+ + +
+
+ + +
+
+ + +
+ `; + + 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 = ` +
+

Результат поиска

+
+                            ${escapeHtml(JSON.stringify(data.data, null, 2))}
+                        
+ +
+ `; + } 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 = 'Обновить документ'; + modalConfirm.textContent = 'Обновить'; + modalBody.innerHTML = ` +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ `; + + 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 = 'Удалить документ'; + modalConfirm.textContent = 'Удалить'; + modalBody.innerHTML = ` +
+ + +
+
+ + +
+
+ + +
+ `; + + 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 String(str) + .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 = ''; break; + case 'error': icon = ''; break; + case 'warning': icon = ''; break; + default: icon = ''; + } + + notification.innerHTML = `${icon}${escapeHtml(message)}`; + 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'); +}