diff --git a/internal/api/static/app.js b/internal/api/static/app.js new file mode 100644 index 0000000..ed9ff77 --- /dev/null +++ b/internal/api/static/app.js @@ -0,0 +1,1921 @@ +/* + * 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 + */ + +/** + * @fileoverview JavaScript для веб-интерфейса Futriis DB Dashboard + * @version 1.0.0 + * @description Обеспечивает полное управление СУБД: CRUD операции, ACL, индексы, + * транзакции, триггеры, ограничения (constraints), импорт/экспорт, + * управление кластером и аудит. Использует async/await, Fetch API, + * динамическую отрисовку DOM и модальные окна. + */ + +// ============================== ГЛОБАЛЬНОЕ СОСТОЯНИЕ ============================== + +/** @type {string|null} ID текущей сессии */ +let currentSession = null; +/** @type {string|null} Имя текущей базы данных */ +let currentDatabase = null; +/** @type {string|null} Имя текущей коллекции */ +let currentCollection = null; +/** @type {string|null} Имя текущего пользователя */ +let currentUser = null; + +// ============================== DOM ЭЛЕМЕНТЫ ============================== + +/** + * @description DOM элементы, используемые для управления интерфейсом. + * Инициализируются при загрузке документа. + */ +const contentArea = document.getElementById('contentArea'); +const pageTitle = document.getElementById('pageTitle'); +const connectionStatus = document.getElementById('connectionStatus'); +const userInfoSpan = document.querySelector('#userName'); +const userRoleSpan = document.getElementById('userRole'); +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'); +const changePasswordIcon = document.getElementById('changePasswordIcon'); +const userAvatar = document.getElementById('userAvatar'); + +// ============================== ИНИЦИАЛИЗАЦИЯ ============================== + +/** + * @description Главная точка входа. Выполняется после загрузки DOM. + * Проверяет активную сессию, инициализирует навигацию и обработчики. + */ +document.addEventListener('DOMContentLoaded', () => { + checkSession(); + initNavigation(); + initEventListeners(); + initAvatarUpload(); + initChangePassword(); +}); + +// ============================== АУТЕНТИФИКАЦИЯ И СЕССИЯ ============================== + +/** + * @async + * @description Проверяет активность сессии на сервере. + * При успешной аутентификации загружает дашборд, иначе показывает форму входа. + */ +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.avatar) { + updateAvatarDisplay(data.data.avatar); + } else { + loadUserAvatar(); + } + + // Обновляем индикатор подключения к СУБД + if (data.data.connection_status === 'connected') { + connectionStatus.className = 'connection-status online'; + connectionStatus.innerHTML = 'СУБД подключена'; + } else { + connectionStatus.className = 'connection-status offline'; + connectionStatus.innerHTML = 'СУБД не подключена'; + } + + loadDashboard(); + startConnectionStatusMonitor(); + } else { + showLoginModal(); + } + } catch (error) { + console.error('Session check failed:', error); + showLoginModal(); + } +} + +/** + * @description Запускает периодическую проверку статуса подключения к СУБД. + * Обновляет индикатор каждые 5 секунд. + */ +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); +} + +/** + * @description Отображает модальное окно для входа в систему. + * Обрабатывает отправку учётных данных и сохраняет сессию. + */ +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; + + if (data.data.avatar) { + updateAvatarDisplay(data.data.avatar); + } + + 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); +} + +// ============================== АВАТАР ПОЛЬЗОВАТЕЛЯ ============================== + +/** + * @description Инициализирует загрузку аватара + */ +async function loadUserAvatar() { + try { + const response = await fetch('/api/webui/user/info'); + const data = await response.json(); + + if (data.success && data.data.avatar) { + updateAvatarDisplay(data.data.avatar); + } + } catch (error) { + console.error('Failed to load avatar:', error); + } +} + +/** + * @description Обновляет отображение аватара + * @param {string} avatarBase64 - Аватар в формате base64 + */ +function updateAvatarDisplay(avatarBase64) { + if (!userAvatar) return; + + userAvatar.innerHTML = `Avatar`; +} + +/** + * @description Инициализирует загрузку аватара + */ +function initAvatarUpload() { + const avatarModal = document.getElementById('avatarUploadModal'); + if (!avatarModal) return; + + // Клик по аватару для загрузки новой картинки + if (userAvatar) { + userAvatar.style.cursor = 'pointer'; + userAvatar.addEventListener('click', () => { + showAvatarUploadModal(); + }); + } +} + +/** + * @description Показывает модальное окно загрузки аватара + */ +function showAvatarUploadModal() { + const avatarModal = document.getElementById('avatarUploadModal'); + const fileInput = document.getElementById('avatarFile'); + const preview = document.getElementById('avatarPreview'); + const uploadBtn = document.getElementById('uploadAvatarBtn'); + + if (!avatarModal) return; + + // Очищаем предыдущие значения + if (fileInput) fileInput.value = ''; + if (preview) preview.innerHTML = ''; + + avatarModal.classList.add('show'); + + // Предпросмотр изображения + if (fileInput) { + fileInput.onchange = function() { + if (this.files && this.files[0]) { + const reader = new FileReader(); + reader.onload = function(e) { + if (preview) { + preview.innerHTML = ``; + } + }; + reader.readAsDataURL(this.files[0]); + } + }; + } + + // Загрузка аватара + if (uploadBtn) { + uploadBtn.onclick = async () => { + if (!fileInput || !fileInput.files || fileInput.files.length === 0) { + showNotification('Выберите изображение', 'warning'); + return; + } + + const formData = new FormData(); + formData.append('avatar', fileInput.files[0]); + + try { + const response = await fetch('/api/webui/user/avatar', { + method: 'POST', + body: formData + }); + const data = await response.json(); + + if (data.success) { + updateAvatarDisplay(data.data.avatar); + avatarModal.classList.remove('show'); + showNotification('Аватар успешно загружен', 'success'); + } else { + showNotification(data.error || 'Ошибка загрузки аватара', 'error'); + } + } catch (error) { + showNotification('Ошибка подключения', 'error'); + } + }; + } + + // Закрытие модального окна + const closeButtons = avatarModal.querySelectorAll('.modal-close'); + closeButtons.forEach(btn => { + btn.onclick = () => { + avatarModal.classList.remove('show'); + }; + }); +} + +// ============================== СМЕНА ПАРОЛЯ ============================== + +/** + * @description Инициализирует смену пароля + */ +function initChangePassword() { + if (changePasswordIcon) { + changePasswordIcon.addEventListener('click', () => { + showChangePasswordModal(); + }); + } +} + +/** + * @description Показывает модальное окно смены пароля + */ +function showChangePasswordModal() { + const passwordModal = document.getElementById('changePasswordModal'); + const currentPasswordInput = document.getElementById('currentPassword'); + const newPasswordInput = document.getElementById('newPassword'); + const confirmPasswordInput = document.getElementById('confirmPassword'); + const changeBtn = document.getElementById('changePasswordBtn'); + + if (!passwordModal) return; + + // Очищаем поля + if (currentPasswordInput) currentPasswordInput.value = ''; + if (newPasswordInput) newPasswordInput.value = ''; + if (confirmPasswordInput) confirmPasswordInput.value = ''; + + passwordModal.classList.add('show'); + + // Смена пароля + if (changeBtn) { + changeBtn.onclick = async () => { + const currentPassword = currentPasswordInput ? currentPasswordInput.value : ''; + const newPassword = newPasswordInput ? newPasswordInput.value : ''; + const confirmPassword = confirmPasswordInput ? confirmPasswordInput.value : ''; + + if (!currentPassword) { + showNotification('Введите текущий пароль', 'warning'); + return; + } + + if (!newPassword) { + showNotification('Введите новый пароль', 'warning'); + return; + } + + if (newPassword !== confirmPassword) { + showNotification('Новый пароль и подтверждение не совпадают', 'error'); + return; + } + + if (newPassword.length < 4) { + showNotification('Новый пароль должен содержать минимум 4 символа', 'error'); + return; + } + + try { + const response = await fetch('/api/webui/change-password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + current_password: currentPassword, + new_password: newPassword + }) + }); + const data = await response.json(); + + if (data.success) { + passwordModal.classList.remove('show'); + showNotification('Пароль успешно изменён', 'success'); + } else { + showNotification(data.error || 'Ошибка смены пароля', 'error'); + } + } catch (error) { + showNotification('Ошибка подключения', 'error'); + } + }; + } + + // Закрытие модального окна + const closeButtons = passwordModal.querySelectorAll('.modal-close'); + closeButtons.forEach(btn => { + btn.onclick = () => { + passwordModal.classList.remove('show'); + }; + }); +} + +// ============================== НАВИГАЦИЯ ============================== + +/** + * @description Инициализирует обработчики навигации: + * - Переключение секций (data-section) + * - Выполнение действий (data-action) + * - Раскрытие подменю (has-submenu) + */ +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'); + }); + }); +} + +/** + * @description Инициализирует глобальные обработчики событий: + * - Выход из системы + * - Мобильное меню + * - Закрытие модальных окон + */ +function initEventListeners() { + logoutBtn.addEventListener('click', async () => { + await fetch('/api/webui/logout', { method: 'POST' }); + currentSession = null; + currentUser = null; + connectionStatus.className = 'connection-status offline'; + connectionStatus.innerHTML = 'СУБД не подключена'; + // Сбрасываем аватар + if (userAvatar) { + userAvatar.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 + * @description Загружает соответствующую секцию интерфейса. + * @param {string} section - Идентификатор секции (значение data-section) + */ +async function loadSection(section) { + const sections = { + dashboard: loadDashboard, + cluster: loadClusterManagement, + audit: () => { contentArea.innerHTML = '
Функция в разработке
'; }, + settings: loadSettings, + 'acl-users': loadACLUsers, + 'acl-roles': loadACLRoles, + 'acl-permissions': loadACLPermissions, + 'tx-list': loadTransactionList, + 'indexes-list': loadIndexesList, + 'export-data': loadExportPage, + 'import-data': loadImportPage, + 'constraints-list': loadConstraintsList, + 'triggers-list': loadTriggersList, + 'trigger-log': loadTriggerLog + }; + (sections[section] || loadDashboard)(); +} + +// ============================== ДАШБОРД ============================== + +/** + * @async + * @description Загружает и отображает главную панель управления со статистикой и списком БД. + */ +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'); + } +} + +// ============================== БАЗЫ ДАННЫХ И КОЛЛЕКЦИИ ============================== + +/** + * @async + * @description Отображает список коллекций в выбранной базе данных. + * @param {string} dbName - Имя базы данных + */ +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 = '
Ошибка подключения
'; + } +}; + +/** + * @async + * @description Отображает документы выбранной коллекции с пагинацией и действиями. + * @param {string} dbName - Имя базы данных + * @param {string} collName - Имя коллекции + */ +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 + * @description Загружает и отображает статус кластера и список узлов. + */ +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 = '
Ошибка загрузки информации о кластере
'; + } +} + +// ============================== НАСТРОЙКИ ============================== + +/** + * @description Отображает страницу настроек интерфейса. + */ +function loadSettings() { + pageTitle.textContent = 'Настройки'; + contentArea.innerHTML = ` +

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

+
+
+ `; +} + +// ============================== ACL (УПРАВЛЕНИЕ ДОСТУПОМ) ============================== + +/** + * @async + * @description Загружает и отображает список пользователей системы. + */ +async function loadACLUsers() { + pageTitle.textContent = 'Управление пользователями ACL'; + contentArea.innerHTML = '

Загрузка пользователей...

'; + + try { + const response = await fetch('/api/webui/acl/users'); + const data = await response.json(); + + if (data.success) { + contentArea.innerHTML = ` +
+

Пользователи

+ ${data.data.map(user => ` + + + + + + + + + `).join('')} +
ИмяРолиСтатусСозданПоследний входДействия
${escapeHtml(user.username)}${user.roles.map(r => `${escapeHtml(r)}`).join(' ') || '-'}${user.active ? 'Активен' : 'Отключён'}${new Date(user.created_at).toLocaleString()}${user.last_login ? new Date(user.last_login).toLocaleString() : '-'}
+ `; + } else { + contentArea.innerHTML = '
Ошибка загрузки пользователей
'; + } + } catch (error) { + contentArea.innerHTML = '
Ошибка подключения
'; + } +} + +/** + * @async + * @description Загружает и отображает список ролей и их разрешений. + */ +async function loadACLRoles() { + pageTitle.textContent = 'Управление ролями ACL'; + contentArea.innerHTML = '

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

'; + + try { + const response = await fetch('/api/webui/acl/roles'); + const data = await response.json(); + + if (data.success) { + contentArea.innerHTML = ` +
+

Роли

+ ${data.data.map(role => ` + + + + + + `).join('')} +
НазваниеРазрешенияДействия
${escapeHtml(role.name)}${role.permissions.map(p => `${escapeHtml(p)}`).join('
') || '-'}
+ `; + } else { + contentArea.innerHTML = '
Ошибка загрузки ролей
'; + } + } catch (error) { + contentArea.innerHTML = '
Ошибка подключения
'; + } +} + +/** + * @async + * @description Загружает и отображает все разрешения, сгруппированные по ролям. + */ +async function loadACLPermissions() { + pageTitle.textContent = 'Управление разрешениями ACL'; + contentArea.innerHTML = '

Загрузка разрешений...

'; + + try { + const response = await fetch('/api/webui/acl/permissions'); + const data = await response.json(); + + if (data.success) { + let html = '

Разрешения по ролям

'; + for (const [roleName, permissions] of Object.entries(data.data)) { + html += `

Роль: ${escapeHtml(roleName)}

${permissions.map(p => `${escapeHtml(p)}`).join('') || 'Нет разрешений'}
`; + } + html += '
'; + contentArea.innerHTML = html; + } else { + contentArea.innerHTML = '
Ошибка загрузки разрешений
'; + } + } catch (error) { + contentArea.innerHTML = '
Ошибка подключения
'; + } +} + +// ============================== ТРАНЗАКЦИИ ============================== + +/** + * @async + * @description Загружает и отображает список активных транзакций с возможностью управления. + */ +async function loadTransactionList() { + pageTitle.textContent = 'Активные транзакции'; + contentArea.innerHTML = '

Загрузка транзакций...

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

Транзакции

+ ${data.data.map(tx => ` + + + + + + + + `).join('') || ''} +
ID транзакцииСтатусНачалоОперацийДействия
${escapeHtml(tx.id)}${escapeHtml(tx.status)}${new Date(tx.start_time).toLocaleString()}${tx.operation_count || 0} + + ${tx.status === 'active' ? `` : ''} +
Нет активных транзакций
+
Информация о транзакциях:
+ `; + } else { + contentArea.innerHTML = '
Ошибка загрузки транзакций
'; + } + } catch (error) { + contentArea.innerHTML = '
Ошибка подключения
'; + } +} + +// ============================== ИНДЕКСЫ ============================== + +/** + * @async + * @description Загружает страницу управления индексами с выбором БД и коллекции. + */ +async function loadIndexesList() { + pageTitle.textContent = 'Управление индексами'; + contentArea.innerHTML = ` +
+
+
+

Выберите базу данных и коллекцию

+ `; + + try { + const response = await fetch('/api/webui/databases'); + const data = await response.json(); + if (data.success) { + const dbSelect = document.getElementById('indexDbSelect'); + dbSelect.innerHTML = '' + data.data.map(db => ``).join(''); + } + } catch (error) { + showNotification('Ошибка загрузки БД', 'error'); + } +} + +/** + * @description Загружает коллекции для выбранной БД в интерфейсе индексов. + */ +window.loadCollectionsForIndex = async function() { + const dbName = document.getElementById('indexDbSelect').value; + const collSelect = document.getElementById('indexCollSelect'); + if (!dbName) { + collSelect.innerHTML = ''; + document.getElementById('indexesContent').innerHTML = '

Выберите базу данных и коллекцию

'; + return; + } + + collSelect.innerHTML = ''; + try { + const response = await fetch(`/api/webui/collections/${encodeURIComponent(dbName)}`); + const data = await response.json(); + if (data.success && data.data.collections) { + collSelect.innerHTML = '' + data.data.collections.map(coll => ``).join(''); + } else { + collSelect.innerHTML = ''; + } + } catch (error) { + collSelect.innerHTML = ''; + } +}; + +/** + * @async + * @description Загружает и отображает список индексов выбранной коллекции. + */ +async function loadIndexesForCollection() { + const dbName = document.getElementById('indexDbSelect').value; + const collName = document.getElementById('indexCollSelect').value; + if (!dbName || !collName) { + document.getElementById('indexesContent').innerHTML = '

Выберите базу данных и коллекцию

'; + return; + } + + document.getElementById('indexesContent').innerHTML = '

Загрузка индексов...

'; + try { + const response = await fetch(`/api/webui/indexes/${encodeURIComponent(dbName)}/${encodeURIComponent(collName)}`); + const data = await response.json(); + + if (data.success) { + document.getElementById('indexesContent').innerHTML = ` +

Индексы коллекции ${escapeHtml(dbName)}.${escapeHtml(collName)}

+ + ${data.data.map(idx => ``).join('') || ''} +
Имя индексаПоляУникальныйДействия
${escapeHtml(idx.name)}${idx.fields.join(', ')}${idx.unique ? 'Да' : 'Нет'}
Нет индексов
+ `; + } else { + document.getElementById('indexesContent').innerHTML = '
Ошибка загрузки индексов
'; + } + } catch (error) { + document.getElementById('indexesContent').innerHTML = '
Ошибка подключения
'; + } +} + +// ============================== ТРИГГЕРЫ ============================== + +/** + * @async + * @description Загружает страницу управления триггерами. + */ +async function loadTriggersList() { + pageTitle.textContent = 'Управление триггерами'; + contentArea.innerHTML = ` +
+
+
+

Выберите базу данных и коллекцию

+ `; + + try { + const response = await fetch('/api/webui/databases'); + const data = await response.json(); + if (data.success) { + const dbSelect = document.getElementById('triggerDbSelect'); + dbSelect.innerHTML = '' + data.data.map(db => ``).join(''); + } + } catch (error) { + showNotification('Ошибка загрузки БД', 'error'); + } +} + +/** + * @description Загружает коллекции для выбранной БД в интерфейсе триггеров. + */ +window.loadCollectionsForTrigger = async function() { + const dbName = document.getElementById('triggerDbSelect').value; + const collSelect = document.getElementById('triggerCollSelect'); + if (!dbName) { + collSelect.innerHTML = ''; + document.getElementById('triggersContent').innerHTML = '

Выберите базу данных и коллекцию

'; + return; + } + + collSelect.innerHTML = ''; + try { + const response = await fetch(`/api/webui/collections/${encodeURIComponent(dbName)}`); + const data = await response.json(); + if (data.success && data.data.collections) { + collSelect.innerHTML = '' + data.data.collections.map(coll => ``).join(''); + } else { + collSelect.innerHTML = ''; + } + } catch (error) { + collSelect.innerHTML = ''; + } +}; + +/** + * @async + * @description Загружает и отображает список триггеров выбранной коллекции. + */ +async function loadTriggersForCollection() { + const dbName = document.getElementById('triggerDbSelect').value; + const collName = document.getElementById('triggerCollSelect').value; + if (!dbName || !collName) { + document.getElementById('triggersContent').innerHTML = '

Выберите базу данных и коллекцию

'; + return; + } + + document.getElementById('triggersContent').innerHTML = '

Загрузка триггеров...

'; + try { + const response = await fetch(`/api/webui/triggers/${encodeURIComponent(dbName)}/${encodeURIComponent(collName)}`); + const data = await response.json(); + + if (data.success) { + if (data.data.length === 0) { + document.getElementById('triggersContent').innerHTML = '

Нет триггеров для этой коллекции

'; + return; + } + document.getElementById('triggersContent').innerHTML = ` +

Триггеры коллекции ${escapeHtml(dbName)}.${escapeHtml(collName)}

+ + ${data.data.map(trigger => ` + + + + + + + + `).join('')} +
ИмяСобытиеДействиеСтатусОписаниеДействия
${escapeHtml(trigger.name)}${escapeHtml(trigger.event)}${escapeHtml(trigger.action)}${trigger.enabled ? 'Включён' : 'Отключён'}${escapeHtml(trigger.description || '-')} + ${trigger.enabled ? `` : ``} + +
+ `; + } else { + document.getElementById('triggersContent').innerHTML = '
Ошибка загрузки триггеров
'; + } + } catch (error) { + document.getElementById('triggersContent').innerHTML = '
Ошибка подключения
'; + } +} + +/** + * @async + * @description Загружает и отображает лог выполнения триггеров. + */ +async function loadTriggerLog() { + pageTitle.textContent = 'Лог выполнения триггеров'; + contentArea.innerHTML = '

Загрузка лога...

'; + + try { + const response = await fetch('/api/webui/trigger/log'); + const data = await response.json(); + + if (data.success) { + if (data.data.length === 0) { + contentArea.innerHTML = '

Лог выполнения триггеров пуст

'; + return; + } + contentArea.innerHTML = ` +

Лог выполнения триггеров

+ ${data.data.map(entry => ``).join('')} +
ТриггерСобытиеКоллекцияБДДокументВремяПользователь
${escapeHtml(entry.trigger_name)}${escapeHtml(entry.event)}${escapeHtml(entry.collection)}${escapeHtml(entry.database)}${escapeHtml(entry.document_id)}${new Date(entry.timestamp).toLocaleString()}${escapeHtml(entry.user || '-')}
+ `; + } else { + contentArea.innerHTML = '
Ошибка загрузки лога
'; + } + } catch (error) { + contentArea.innerHTML = '
Ошибка подключения
'; + } +} + +// ============================== ОГРАНИЧЕНИЯ (CONSTRAINTS) ============================== + +/** + * @async + * @description Загружает страницу управления ограничениями коллекции. + */ +async function loadConstraintsList() { + pageTitle.textContent = 'Управление ограничениями (Constraints)'; + contentArea.innerHTML = ` +
+
+

Выберите базу данных и коллекцию

+ `; + + try { + const response = await fetch('/api/webui/databases'); + const data = await response.json(); + if (data.success) { + const dbSelect = document.getElementById('constraintDbSelect'); + dbSelect.innerHTML = '' + data.data.map(db => ``).join(''); + } + } catch (error) { + showNotification('Ошибка загрузки БД', 'error'); + } +} + +/** + * @description Загружает коллекции для выбранной БД в интерфейсе ограничений. + */ +window.loadCollectionsForConstraints = async function() { + const dbName = document.getElementById('constraintDbSelect').value; + const collSelect = document.getElementById('constraintCollSelect'); + if (!dbName) { + collSelect.innerHTML = ''; + document.getElementById('constraintsContent').innerHTML = '

Выберите базу данных и коллекцию

'; + return; + } + + collSelect.innerHTML = ''; + try { + const response = await fetch(`/api/webui/collections/${encodeURIComponent(dbName)}`); + const data = await response.json(); + if (data.success && data.data.collections) { + collSelect.innerHTML = '' + data.data.collections.map(coll => ``).join(''); + } else { + collSelect.innerHTML = ''; + } + } catch (error) { + collSelect.innerHTML = ''; + } +}; + +/** + * @async + * @description Загружает и отображает все ограничения выбранной коллекции. + */ +async function loadConstraintsForCollection() { + const dbName = document.getElementById('constraintDbSelect').value; + const collName = document.getElementById('constraintCollSelect').value; + if (!dbName || !collName) { + document.getElementById('constraintsContent').innerHTML = '

Выберите базу данных и коллекцию

'; + return; + } + + document.getElementById('constraintsContent').innerHTML = '

Загрузка ограничений...

'; + try { + const response = await fetch(`/api/webui/constraints/${encodeURIComponent(dbName)}/${encodeURIComponent(collName)}`); + const data = await response.json(); + + if (data.success) { + const constraints = data.data; + if (constraints.length === 0) { + document.getElementById('constraintsContent').innerHTML = '

Нет ограничений для этой коллекции

'; + return; + } + + // Группировка ограничений по типу + const grouped = { required: [], unique: [], min: [], max: [], enum: [], regex: [] }; + constraints.forEach(c => { if (grouped[c.type]) grouped[c.type].push(c); }); + + let html = `

Ограничения коллекции ${escapeHtml(dbName)}.${escapeHtml(collName)}

`; + for (const [type, title, icon] of [['required', 'Обязательные поля', 'fa-exclamation-circle'], ['unique', 'Уникальные поля', 'fa-unique'], ['min', 'Минимальные значения', 'fa-greater-than'], ['max', 'Максимальные значения', 'fa-less-than'], ['enum', 'Перечисления', 'fa-list-ul'], ['regex', 'Регулярные выражения', 'fa-code']]) { + if (grouped[type].length > 0) { + html += `

${title}

`; + for (const c of grouped[type]) { + html += ``; + } + html += `
Поле${type === 'enum' ? 'Допустимые значения' : (type === 'min' || type === 'max' ? 'Значение' : (type === 'regex' ? 'Шаблон' : ''))}Действия
${escapeHtml(c.field)}${type === 'enum' ? c.values.map(v => `${escapeHtml(String(v))}`).join(' ') : (c.value || c.pattern || '-')}
`; + } + } + html += `
`; + document.getElementById('constraintsContent').innerHTML = html; + } else { + document.getElementById('constraintsContent').innerHTML = '
Ошибка загрузки ограничений
'; + } + } catch (error) { + document.getElementById('constraintsContent').innerHTML = '
Ошибка подключения
'; + } +} + +// ============================== ИМПОРТ/ЭКСПОРТ ============================== + +/** + * @async + * @description Загружает страницу экспорта данных. + */ +async function loadExportPage() { + pageTitle.textContent = 'Экспорт данных'; + contentArea.innerHTML = ` +
+
+ +
+ `; + + try { + const response = await fetch('/api/webui/databases'); + const data = await response.json(); + if (data.success) { + const dbSelect = document.getElementById('exportDbSelect'); + dbSelect.innerHTML = '' + data.data.map(db => ``).join(''); + } + } catch (error) { + showNotification('Ошибка загрузки БД', 'error'); + } +} + +/** + * @async + * @description Выполняет экспорт данных в JSON-файл. + */ +async function performExport() { + const dbName = document.getElementById('exportDbSelect').value; + const filename = document.getElementById('exportFilename').value; + if (!dbName) { showNotification('Выберите базу данных', 'error'); return; } + + const exportResult = document.getElementById('exportResult'); + exportResult.innerHTML = '

Экспорт данных...

'; + + try { + const response = await fetch('/api/webui/export', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ database: dbName, filename }) + }); + const data = await response.json(); + + if (data.success) { + const jsonStr = JSON.stringify(data.data.data, null, 2); + const blob = new Blob([jsonStr], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = data.data.filename.replace('.msgpack', '.json'); + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + exportResult.innerHTML = `

Экспорт завершён

БД: ${escapeHtml(dbName)}

Коллекций: ${data.data.collections}

Файл: ${data.data.filename}

`; + showNotification('Экспорт завершён', 'success'); + } else { + exportResult.innerHTML = `
Ошибка: ${data.error}
`; + } + } catch (error) { + exportResult.innerHTML = '
Ошибка подключения
'; + } +} + +/** + * @async + * @description Загружает страницу импорта данных. + */ +async function loadImportPage() { + pageTitle.textContent = 'Импорт данных'; + contentArea.innerHTML = ` +
+
+
+ +
+ `; +} + +/** + * @async + * @description Выполняет импорт данных из JSON-файла. + */ +async function performImport() { + const dbName = document.getElementById('importDbName').value; + const fileInput = document.getElementById('importFile'); + const overwrite = document.getElementById('importOverwrite').checked; + if (!dbName) { showNotification('Введите имя целевой базы данных', 'error'); return; } + if (!fileInput.files || fileInput.files.length === 0) { showNotification('Выберите файл для импорта', 'error'); return; } + + const importResult = document.getElementById('importResult'); + importResult.innerHTML = '

Импорт данных...

'; + + try { + const file = fileInput.files[0]; + const fileContent = await file.text(); + const importData = JSON.parse(fileContent); + + const response = await fetch('/api/webui/import', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ database: dbName, data: importData, overwrite }) + }); + const data = await response.json(); + + if (data.success) { + importResult.innerHTML = `

Импорт завершён

БД: ${escapeHtml(dbName)}

Импортировано коллекций: ${data.data.collections}

Импортировано документов: ${data.data.documents}

`; + showNotification('Импорт завершён', 'success'); + } else { + importResult.innerHTML = `
Ошибка: ${data.error}
`; + } + } catch (error) { + importResult.innerHTML = `
Ошибка: ${error.message}
`; + } +} + +// ============================== CRUD ОПЕРАЦИИ (МОДАЛЬНЫЕ ОКНА) ============================== + +/** + * @description Отображает модальное окно для создания базы данных. + */ +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'); } + }; +} + +/** + * @description Отображает модальное окно для создания коллекции в текущей БД. + */ +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'); } + }; +} + +/** + * @description Отображает модальное окно для вставки документа в текущую коллекцию. + */ +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) { + showNotification(error instanceof SyntaxError ? 'Неверный формат JSON' : 'Ошибка подключения', 'error'); + } + }; +} + +/** + * @description Отображает модальное окно для обновления документа. + * @param {string} docId - ID документа (опционально) + * @param {Object|null} currentFields - Текущие поля документа + */ +function showUpdateDocumentModal(docId = '', currentFields = null) { + if (!currentDatabase || !currentCollection) { showNotification('Сначала выберите базу данных и коллекцию', 'warning'); return; } + 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) { + showNotification(error instanceof SyntaxError ? 'Неверный формат JSON' : 'Ошибка подключения', 'error'); + } + }; +} + +/** + * @description Отображает модальное окно для создания пользователя ACL. + */ +function showCreateUserModal() { + modalTitle.textContent = 'Создать пользователя'; + modalConfirm.textContent = 'Создать'; + modalBody.innerHTML = `
`; + modal.classList.add('show'); + + modalConfirm.onclick = async () => { + const username = document.getElementById('username').value; + const password = document.getElementById('password').value; + const rolesStr = document.getElementById('roles').value; + const roles = rolesStr ? rolesStr.split(',').map(r => r.trim()) : []; + if (!username || !password) { showNotification('Заполните имя пользователя и пароль', 'error'); return; } + try { + const response = await fetch(`/api/webui/acl/user/${encodeURIComponent(username)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password, roles }) }); + const data = await response.json(); + if (data.success) { + modal.classList.remove('show'); + showNotification(`Пользователь ${username} создан`, 'success'); + loadACLUsers(); + } else { + showNotification(data.error || 'Ошибка создания пользователя', 'error'); + } + } catch (error) { showNotification('Ошибка подключения', 'error'); } + }; +} + +/** + * @description Отображает модальное окно для создания роли ACL. + */ +function showCreateRoleModal() { + modalTitle.textContent = 'Создать роль'; + modalConfirm.textContent = 'Создать'; + modalBody.innerHTML = `
`; + modal.classList.add('show'); + + modalConfirm.onclick = async () => { + const roleName = document.getElementById('roleName').value; + if (!roleName) { showNotification('Введите название роли', 'error'); return; } + try { + const response = await fetch(`/api/webui/acl/role/${encodeURIComponent(roleName)}`, { method: 'POST' }); + const data = await response.json(); + if (data.success) { + modal.classList.remove('show'); + showNotification(`Роль ${roleName} создана`, 'success'); + loadACLRoles(); + } else { + showNotification(data.error || 'Ошибка создания роли', 'error'); + } + } catch (error) { showNotification('Ошибка подключения', 'error'); } + }; +} + +/** + * @description Отображает модальное окно для создания индекса. + */ +function showCreateIndexModal() { + const dbName = document.getElementById('indexDbSelect')?.value; + const collName = document.getElementById('indexCollSelect')?.value; + if (!dbName || !collName) { showNotification('Сначала выберите базу данных и коллекцию на странице "Список индексов"', 'warning'); return; } + modalTitle.textContent = 'Создать индекс'; + modalConfirm.textContent = 'Создать'; + modalBody.innerHTML = `
`; + modal.classList.add('show'); + + modalConfirm.onclick = async () => { + const indexName = document.getElementById('indexName').value; + const fieldsStr = document.getElementById('indexFields').value; + const unique = document.getElementById('indexUnique').checked; + if (!indexName || !fieldsStr) { showNotification('Заполните имя индекса и поля', 'error'); return; } + const fields = fieldsStr.split(',').map(f => f.trim()); + try { + const response = await fetch(`/api/webui/index/${encodeURIComponent(dbName)}/${encodeURIComponent(collName)}/create`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: indexName, fields, unique }) }); + const data = await response.json(); + if (data.success) { + modal.classList.remove('show'); + showNotification(`Индекс ${indexName} создан`, 'success'); + loadIndexesForCollection(); + } else { + showNotification(data.error || 'Ошибка создания индекса', 'error'); + } + } catch (error) { showNotification('Ошибка подключения', 'error'); } + }; +} + +/** + * @description Отображает модальное окно для создания триггера. + */ +function showCreateTriggerModal() { + const dbName = document.getElementById('triggerDbSelect').value; + const collName = document.getElementById('triggerCollSelect').value; + if (!dbName || !collName) { showNotification('Сначала выберите базу данных и коллекцию на странице "Список триггеров"', 'warning'); return; } + modalTitle.textContent = 'Создать триггер'; + modalConfirm.textContent = 'Создать'; + modalBody.innerHTML = ` +
+
+
+
+
+
+
+
Доступные операции: set, unset, inc, mul, rename, currentDate. Спецзначения: $$NOW, $$USER, $$ROLE
+ `; + modal.classList.add('show'); + + modalConfirm.onclick = async () => { + const triggerName = document.getElementById('triggerName').value; + const triggerEvent = document.getElementById('triggerEvent').value; + const triggerAction = document.getElementById('triggerAction').value; + const triggerDescription = document.getElementById('triggerDescription').value; + if (!triggerName) { showNotification('Введите имя триггера', 'error'); return; } + + let condition = null; + const conditionField = document.getElementById('conditionField').value; + if (conditionField) { + condition = { field: conditionField, operator: document.getElementById('conditionOperator').value, value: document.getElementById('conditionValue').value }; + if (condition.value && !isNaN(condition.value) && condition.value.trim() !== '') condition.value = parseFloat(condition.value); + } + + let operations = []; + const opsText = document.getElementById('triggerOperations').value; + if (opsText && opsText.trim()) { + try { operations = JSON.parse(opsText); } catch (e) { showNotification('Неверный формат JSON для операций', 'error'); return; } + } + + const requestBody = { name: triggerName, event: triggerEvent, action: triggerAction, description: triggerDescription, operations }; + if (condition) requestBody.condition = condition; + + try { + const response = await fetch(`/api/webui/trigger/${encodeURIComponent(dbName)}/${encodeURIComponent(collName)}/create`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody) }); + const data = await response.json(); + if (data.success) { + modal.classList.remove('show'); + showNotification(`Триггер ${triggerName} создан`, 'success'); + loadTriggersForCollection(); + } else { + showNotification(data.error || 'Ошибка создания триггера', 'error'); + } + } catch (error) { showNotification('Ошибка подключения', 'error'); } + }; +} + +// ============================== ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ============================== + +/** + * @description Устанавливает активный пункт навигации. + * @param {HTMLElement} activeLink - Активный элемент ссылки + */ +function setActiveNav(activeLink) { + document.querySelectorAll('.nav-link').forEach(link => link.classList.remove('active')); + activeLink.classList.add('active'); +} + +/** + * @description Экранирует HTML-спецсимволы для предотвращения XSS. + * @param {any} str - Входная строка + * @returns {string} Экранированная строка + */ +function escapeHtml(str) { + if (!str) return ''; + return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); +} + +/** + * @description Отображает всплывающее уведомление. + * @param {string} message - Текст уведомления + * @param {string} type - Тип уведомления (success, error, warning, info) + */ +function showNotification(message, type = 'info') { + const container = document.getElementById('notificationContainer'); + const notification = document.createElement('div'); + notification.className = `notification ${type}`; + const icons = { success: '', error: '', warning: '', info: '' }; + notification.innerHTML = `${icons[type] || icons.info}${escapeHtml(message)}`; + container.appendChild(notification); + setTimeout(() => { notification.style.animation = 'slideOutRight 0.3s ease'; setTimeout(() => notification.remove(), 300); }, 3000); +} + +/** + * @description Сохраняет настройки интерфейса (тема). + */ +function saveSettings() { + const theme = document.getElementById('themeSelect')?.value; + if (theme) { localStorage.setItem('theme', theme); showNotification('Настройки сохранены', 'success'); } +} + +// ============================== ТРАНЗАКЦИИ (ДОПОЛНИТЕЛЬНЫЕ ФУНКЦИИ) ============================== + +/** + * @async + * @description Загружает детали транзакции в модальном окне. + * @param {string} txId - ID транзакции + */ +async function loadTransactionDetails(txId) { + modalTitle.textContent = `Детали транзакции ${txId}`; + modalConfirm.textContent = 'Закрыть'; + modalBody.innerHTML = '

Загрузка деталей...

'; + modal.classList.add('show'); + + const originalConfirmHandler = modalConfirm.onclick; + modalConfirm.onclick = () => { modal.classList.remove('show'); modalConfirm.onclick = originalConfirmHandler; }; + + try { + const response = await fetch(`/api/webui/transaction/${txId}/details`); + const data = await response.json(); + if (data.success && data.data) { + const tx = data.data; + modalBody.innerHTML = `
ID: ${escapeHtml(tx.id)}
Статус: ${escapeHtml(tx.status)}
Время начала: ${new Date(tx.start_time).toLocaleString()}
Количество операций: ${tx.operation_count}

Операции (${tx.operations?.length || 0})

${tx.operations?.length ? `${tx.operations.map(op => ``).join('')}
ТипБДКоллекцияID документа
${escapeHtml(op.type)}${escapeHtml(op.database)}${escapeHtml(op.collection)}${escapeHtml(op.document_id)}
` : '

Нет операций

'}
`; + } else { + modalBody.innerHTML = `
Ошибка загрузки деталей: ${data.error || 'Неизвестная ошибка'}
`; + } + } catch (error) { + modalBody.innerHTML = '
Ошибка подключения
'; + } +} + +/** + * @async + * @description Начинает новую сессию транзакций. + */ +async function startSession() { + try { + const response = await fetch('/api/webui/transactions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'start_session' }) }); + const data = await response.json(); + data.success ? (showNotification('Сессия начата', 'success'), loadTransactionList()) : showNotification(data.error || 'Ошибка', 'error'); + } catch (error) { showNotification('Ошибка подключения', 'error'); } +} + +/** + * @async + * @description Начинает новую транзакцию. + */ +async function startTransaction() { + try { + const response = await fetch('/api/webui/transactions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'start_transaction' }) }); + const data = await response.json(); + data.success ? (showNotification('Транзакция начата', 'success'), loadTransactionList()) : showNotification(data.error || 'Ошибка', 'error'); + } catch (error) { showNotification('Ошибка подключения', 'error'); } +} + +/** + * @async + * @description Фиксирует текущую транзакцию. + */ +async function commitTransaction() { + try { + const response = await fetch('/api/webui/transaction/commit', { method: 'POST' }); + const data = await response.json(); + data.success ? (showNotification('Транзакция зафиксирована', 'success'), loadTransactionList()) : showNotification(data.error || 'Ошибка', 'error'); + } catch (error) { showNotification('Ошибка подключения', 'error'); } +} + +/** + * @async + * @description Отменяет текущую транзакцию. + */ +async function abortTransaction() { + try { + const response = await fetch('/api/webui/transaction/abort', { method: 'POST' }); + const data = await response.json(); + data.success ? (showNotification('Транзакция отменена', 'success'), loadTransactionList()) : showNotification(data.error || 'Ошибка', 'error'); + } catch (error) { showNotification('Ошибка подключения', 'error'); } +} + +/** + * @async + * @description Фиксирует транзакцию по ID. + * @param {string} txId - ID транзакции + */ +async function commitTransactionById(txId) { + if (!confirm(`Зафиксировать транзакцию ${txId}?`)) return; + try { + const response = await fetch(`/api/webui/transaction/${txId}/commit`, { method: 'POST' }); + const data = await response.json(); + data.success ? (showNotification(`Транзакция ${txId} зафиксирована`, 'success'), loadTransactionList()) : showNotification(data.error || 'Ошибка фиксации', 'error'); + } catch (error) { showNotification('Ошибка подключения', 'error'); } +} + +/** + * @async + * @description Отменяет транзакцию по ID. + * @param {string} txId - ID транзакции + */ +async function abortTransactionById(txId) { + if (!confirm(`Отменить транзакцию ${txId}?`)) return; + try { + const response = await fetch(`/api/webui/transaction/${txId}/abort`, { method: 'POST' }); + const data = await response.json(); + data.success ? (showNotification(`Транзакция ${txId} отменена`, 'success'), loadTransactionList()) : showNotification(data.error || 'Ошибка отмены', 'error'); + } catch (error) { showNotification('Ошибка подключения', 'error'); } +} + +// ============================== УПРАВЛЕНИЕ ДАННЫМИ (CRUD) ============================== + +/** + * @async + * @description Удаляет коллекцию. + * @param {string} dbName - Имя БД + * @param {string} collName - Имя коллекции + */ +window.deleteCollection = async function(dbName, collName) { + if (!confirm(`Удалить коллекцию "${collName}"? Это действие необратимо.`)) return; + try { + const response = await fetch(`/api/db/${dbName}/${collName}`, { method: 'DELETE' }); + response.ok ? (showNotification(`Коллекция "${collName}" удалена`, 'success'), viewDatabase(dbName)) : showNotification((await response.json()).error || 'Ошибка удаления коллекции', 'error'); + } catch (error) { showNotification('Ошибка подключения', 'error'); } +}; + +/** + * @async + * @description Удаляет документ. + * @param {string} dbName - Имя БД + * @param {string} collName - Имя коллекции + * @param {string} docId - ID документа + */ +window.deleteDocument = async function(dbName, collName, docId) { + if (!confirm(`Удалить документ "${docId}"?`)) return; + try { + const response = await fetch(`/api/webui/documents/${dbName}/${collName}?id=${encodeURIComponent(docId)}`, { method: 'DELETE' }); + const result = await response.json(); + result.success ? (showNotification('Документ удалён', 'success'), viewCollection(dbName, collName)) : showNotification(result.error || 'Ошибка удаления документа', 'error'); + } catch (error) { showNotification('Ошибка подключения', 'error'); } +}; + +/** + * @async + * @description Удаляет пользователя. + * @param {string} username - Имя пользователя + */ +window.deleteUser = async function(username) { + if (!confirm(`Удалить пользователя "${username}"?`)) return; + try { + const response = await fetch(`/api/webui/acl/user/${encodeURIComponent(username)}`, { method: 'DELETE' }); + const data = await response.json(); + data.success ? (showNotification(`Пользователь ${username} удалён`, 'success'), loadACLUsers()) : showNotification(data.error || 'Ошибка удаления', 'error'); + } catch (error) { showNotification('Ошибка подключения', 'error'); } +}; + +/** + * @async + * @description Удаляет роль. + * @param {string} roleName - Имя роли + */ +window.deleteRole = async function(roleName) { + if (!confirm(`Удалить роль "${roleName}"?`)) return; + try { + const response = await fetch(`/api/webui/acl/role/${encodeURIComponent(roleName)}`, { method: 'DELETE' }); + const data = await response.json(); + data.success ? (showNotification(`Роль ${roleName} удалена`, 'success'), loadACLRoles()) : showNotification(data.error || 'Ошибка удаления', 'error'); + } catch (error) { showNotification('Ошибка подключения', 'error'); } +}; + +/** + * @async + * @description Отключает пользователя. + * @param {string} username - Имя пользователя + */ +window.disableUser = async function(username) { + try { + const response = await fetch(`/api/webui/acl/user/${encodeURIComponent(username)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disable: true }) }); + const data = await response.json(); + data.success ? (showNotification(`Пользователь ${username} отключён`, 'success'), loadACLUsers()) : showNotification(data.error || 'Ошибка', 'error'); + } catch (error) { showNotification('Ошибка подключения', 'error'); } +}; + +/** + * @async + * @description Включает пользователя. + * @param {string} username - Имя пользователя + */ +window.enableUser = async function(username) { + try { + const response = await fetch(`/api/webui/acl/user/${encodeURIComponent(username)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enable: true }) }); + const data = await response.json(); + data.success ? (showNotification(`Пользователь ${username} включён`, 'success'), loadACLUsers()) : showNotification(data.error || 'Ошибка', 'error'); + } catch (error) { showNotification('Ошибка подключения', 'error'); } +}; + +/** + * @async + * @description Отзывает разрешение у роли. + * @param {string} roleName - Имя роли + * @param {string} permission - Разрешение + */ +window.revokePermission = async function(roleName, permission) { + try { + const response = await fetch(`/api/webui/acl/role/${encodeURIComponent(roleName)}/revoke/${encodeURIComponent(permission)}`, { method: 'PUT' }); + const data = await response.json(); + data.success ? (showNotification('Разрешение отозвано', 'success'), loadACLRoles()) : showNotification(data.error || 'Ошибка', 'error'); + } catch (error) { showNotification('Ошибка подключения', 'error'); } +}; + +/** + * @async + * @description Удаляет индекс. + * @param {string} dbName - Имя БД + * @param {string} collName - Имя коллекции + * @param {string} indexName - Имя индекса + */ +window.dropIndex = async function(dbName, collName, indexName) { + if (!confirm(`Удалить индекс "${indexName}"?`)) return; + try { + const response = await fetch(`/api/webui/index/${encodeURIComponent(dbName)}/${encodeURIComponent(collName)}/drop/${encodeURIComponent(indexName)}`, { method: 'POST' }); + const data = await response.json(); + data.success ? (showNotification(`Индекс ${indexName} удалён`, 'success'), loadIndexesForCollection()) : showNotification(data.error || 'Ошибка удаления индекса', 'error'); + } catch (error) { showNotification('Ошибка подключения', 'error'); } +}; + +/** + * @async + * @description Включает или отключает триггер. + * @param {string} dbName - Имя БД + * @param {string} collName - Имя коллекции + * @param {string} triggerName - Имя триггера + * @param {string} triggerEvent - Событие триггера + * @param {boolean} enable - Включить (true) или отключить (false) + */ +window.toggleTrigger = async function(dbName, collName, triggerName, triggerEvent, enable) { + const action = enable ? 'enable' : 'disable'; + try { + const response = await fetch(`/api/webui/trigger/${encodeURIComponent(dbName)}/${encodeURIComponent(collName)}/${action}/${encodeURIComponent(triggerName)}`, { method: 'POST' }); + const data = await response.json(); + data.success ? (showNotification(`Триггер ${triggerName} ${enable ? 'включён' : 'отключён'}`, 'success'), loadTriggersForCollection()) : showNotification(data.error || 'Ошибка', 'error'); + } catch (error) { showNotification('Ошибка подключения', 'error'); } +}; + +/** + * @async + * @description Удаляет триггер. + * @param {string} dbName - Имя БД + * @param {string} collName - Имя коллекции + * @param {string} triggerName - Имя триггера + * @param {string} triggerEvent - Событие триггера + */ +window.deleteTrigger = async function(dbName, collName, triggerName, triggerEvent) { + if (!confirm(`Удалить триггер "${triggerName}"?`)) return; + try { + const response = await fetch(`/api/webui/trigger/${encodeURIComponent(dbName)}/${encodeURIComponent(collName)}/delete/${encodeURIComponent(triggerName)}/${encodeURIComponent(triggerEvent)}`, { method: 'DELETE' }); + const data = await response.json(); + data.success ? (showNotification(`Триггер ${triggerName} удалён`, 'success'), loadTriggersForCollection()) : showNotification(data.error || 'Ошибка удаления', 'error'); + } catch (error) { showNotification('Ошибка подключения', 'error'); } +}; + +/** + * @async + * @description Удаляет ограничение. + * @param {string} dbName - Имя БД + * @param {string} collName - Имя коллекции + * @param {string} constraintType - Тип ограничения + * @param {string} field - Имя поля + */ +window.removeConstraint = async function(dbName, collName, constraintType, field) { + const confirmMsg = { required: `Удалить обязательное поле "${field}"?`, unique: `Удалить уникальное ограничение для поля "${field}"?`, min: `Удалить минимальное значение для поля "${field}"?`, max: `Удалить максимальное значение для поля "${field}"?`, enum: `Удалить перечисление для поля "${field}"?`, regex: `Удалить регулярное выражение для поля "${field}"?` }[constraintType] || `Удалить ограничение "${field}"?`; + if (!confirm(confirmMsg)) return; + try { + const response = await fetch(`/api/webui/constraint/${encodeURIComponent(dbName)}/${encodeURIComponent(collName)}/${constraintType}/${encodeURIComponent(field)}`, { method: 'DELETE' }); + const data = await response.json(); + data.success ? (showNotification('Ограничение удалено', 'success'), loadConstraintsForCollection()) : showNotification(data.error || 'Ошибка удаления', 'error'); + } catch (error) { showNotification('Ошибка подключения', 'error'); } +}; + +/** + * @description Обрабатывает быстрые действия из меню. + * @param {string} action - Идентификатор действия (data-action) + */ +function handleCrudAction(action) { + const actions = { + 'create-db': showCreateDatabaseModal, + 'create-collection': showCreateCollectionModal, + 'insert-doc': showInsertDocumentModal, + 'find-doc': () => showUpdateDocumentModal(), + 'update-doc': () => showUpdateDocumentModal(), + 'delete-doc': () => showDeleteDocumentModal(), + 'acl-create-user': showCreateUserModal, + 'acl-create-role': showCreateRoleModal, + 'tx-start-session': startSession, + 'tx-start': startTransaction, + 'tx-commit': commitTransaction, + 'tx-abort': abortTransaction, + 'index-create': () => { if (document.getElementById('indexDbSelect')?.value && document.getElementById('indexCollSelect')?.value) showCreateIndexModal(); else showNotification('Сначала выберите БД и коллекцию на странице индексов', 'warning'); }, + 'constraint-add-required': showAddRequiredConstraintModal, + 'constraint-add-unique': showAddUniqueConstraintModal, + 'constraint-add-min': showAddMinConstraintModal, + 'constraint-add-max': showAddMaxConstraintModal, + 'constraint-add-enum': showAddEnumConstraintModal, + 'constraint-add-regex': showAddRegexConstraintModal + }; + (actions[action] || (() => showNotification('Неизвестное действие', 'warning')))(); +} + +// ============================== ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ДЛЯ ОГРАНИЧЕНИЙ ============================== + +function showAddRequiredConstraintModal() { showConstraintModal('required', 'Добавить обязательное поле', [{ name: 'field', label: 'Имя поля', type: 'text' }]); } +function showAddUniqueConstraintModal() { showConstraintModal('unique', 'Добавить уникальное поле', [{ name: 'field', label: 'Имя поля', type: 'text' }]); } +function showAddMinConstraintModal() { showConstraintModal('min', 'Добавить минимальное значение', [{ name: 'field', label: 'Имя поля', type: 'text' }, { name: 'value', label: 'Минимальное значение', type: 'number' }]); } +function showAddMaxConstraintModal() { showConstraintModal('max', 'Добавить максимальное значение', [{ name: 'field', label: 'Имя поля', type: 'text' }, { name: 'value', label: 'Максимальное значение', type: 'number' }]); } +function showAddEnumConstraintModal() { showConstraintModal('enum', 'Добавить перечисление (Enum)', [{ name: 'field', label: 'Имя поля', type: 'text' }, { name: 'values', label: 'Допустимые значения (через запятую)', type: 'text' }]); } +function showAddRegexConstraintModal() { showConstraintModal('regex', 'Добавить регулярное выражение', [{ name: 'field', label: 'Имя поля', type: 'text' }, { name: 'pattern', label: 'Регулярное выражение', type: 'text' }]); } + +function showConstraintModal(type, title, fields) { + const dbName = document.getElementById('constraintDbSelect')?.value; + const collName = document.getElementById('constraintCollSelect')?.value; + if (!dbName || !collName) { showNotification('Сначала выберите БД и коллекцию на странице ограничений', 'warning'); return; } + modalTitle.textContent = title; + modalConfirm.textContent = 'Добавить'; + modalBody.innerHTML = `
${fields.map(f => `
`).join('')}`; + modal.classList.add('show'); + modalConfirm.onclick = async () => { + const field = document.getElementById('constraint_field')?.value; + if (!field) { showNotification('Введите имя поля', 'error'); return; } + let url = `/api/webui/constraint/${encodeURIComponent(dbName)}/${encodeURIComponent(collName)}/${type}/${encodeURIComponent(field)}`; + if (type === 'min' || type === 'max') { + const value = document.getElementById('constraint_value')?.value; + if (value === '') { showNotification('Введите значение', 'error'); return; } + url += `/${encodeURIComponent(value)}`; + } else if (type === 'enum') { + const valuesStr = document.getElementById('constraint_values')?.value; + if (!valuesStr) { showNotification('Введите допустимые значения', 'error'); return; } + const values = valuesStr.split(',').map(v => encodeURIComponent(v.trim())); + url += `/${values.join('/')}`; + } else if (type === 'regex') { + const pattern = document.getElementById('constraint_pattern')?.value; + if (!pattern) { showNotification('Введите регулярное выражение', 'error'); return; } + url += `/${encodeURIComponent(pattern)}`; + } + try { + const response = await fetch(url, { method: 'POST' }); + const data = await response.json(); + if (data.success) { + modal.classList.remove('show'); + showNotification(`Ограничение добавлено`, 'success'); + loadConstraintsForCollection(); + } else { + showNotification(data.error || 'Ошибка добавления', 'error'); + } + } catch (error) { showNotification('Ошибка подключения', 'error'); } + }; +}