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 = `
`;
+}
+
+/**
+ * @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 => `| ${escapeHtml(db.name)} | ${db.collections} | |
`).join('')}
+
+ `;
+ } 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 => `
+
+ | ${escapeHtml(coll.name)} |
+ ${coll.count} |
+ ${(coll.size / 1024).toFixed(2)} KB |
+ ${coll.indexes.length} |
+
+
+
+ |
+
+ `).join('')}
+
+ `;
+ } 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} всего)
| ID | Поля | Создан | Действия |
+ ${data.data.documents.map(doc => `
+
+ ${escapeHtml(doc.id)} |
+ ${escapeHtml(JSON.stringify(doc.fields, null, 2))} |
+ ${new Date(doc.created_at).toLocaleString()} |
+
+
+
+ |
+
+ `).join('')}
+
+ `;
+ } 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}
Фактор репликации
+
+ Узлы кластера
| ID узла | Адрес | Статус | Последний контакт |
+ ${nodes.data.map(node => `${escapeHtml(node.id)} | ${escapeHtml(node.ip)}:${node.port} | ${node.status} | ${new Date(node.last_seen * 1000).toLocaleString()} |
`).join('')}
+
+ `;
+ } 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 => `
+
+ | ${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() : '-'} |
+ |
+
+ `).join('')}
+
+ `;
+ } 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 => `
+
+ | ${escapeHtml(role.name)} |
+ ${role.permissions.map(p => `${escapeHtml(p)}`).join(' ') || '-'} |
+ |
+
+ `).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 = `
+
+
+
+
+
+
+
+ Транзакции
| ID транзакции | Статус | Начало | Операций | Действия |
+ ${data.data.map(tx => `
+
+ ${escapeHtml(tx.id)} |
+ ${escapeHtml(tx.status)} |
+ ${new Date(tx.start_time).toLocaleString()} |
+ ${tx.operation_count || 0} |
+
+
+ ${tx.status === 'active' ? `` : ''}
+ |
+
+ `).join('') || '| Нет активных транзакций |
'}
+
+ Информация о транзакциях:- Транзакции поддерживают INSERT, UPDATE, DELETE
- Используется MVCC для изоляции
- WAL гарантирует персистентность
+ `;
+ } 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 => `${escapeHtml(idx.name)} | ${idx.fields.join(', ')} | ${idx.unique ? 'Да' : 'Нет'} | |
`).join('') || '| Нет индексов |
'}
+
+ `;
+ } 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 => `
+
+ ${escapeHtml(trigger.name)} |
+ ${escapeHtml(trigger.event)} | ${escapeHtml(trigger.action)} |
+ ${trigger.enabled ? 'Включён' : 'Отключён'} |
+ ${escapeHtml(trigger.description || '-')} |
+
+ ${trigger.enabled ? `` : ``}
+
+ |
+
+ `).join('')}
+
+ `;
+ } 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 => `${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 || '-')} |
`).join('')}
+
+ `;
+ } 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}
| Поле | ${type === 'enum' ? 'Допустимые значения' : (type === 'min' || type === 'max' ? 'Значение' : (type === 'regex' ? 'Шаблон' : ''))} | Действия |
`;
+ for (const c of grouped[type]) {
+ html += `${escapeHtml(c.field)} | ${type === 'enum' ? c.values.map(v => `${escapeHtml(String(v))}`).join(' ') : (c.value || c.pattern || '-')} | |
`;
+ }
+ html += `
`;
+ }
+ }
+ 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 ? `
| Тип | БД | Коллекция | ID документа |
${tx.operations.map(op => `| ${escapeHtml(op.type)} | ${escapeHtml(op.database)} | ${escapeHtml(op.collection)} | ${escapeHtml(op.document_id)} |
`).join('')}
` : '
Нет операций
'}
`;
+ } 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'); }
+ };
+}