/** * JavaScript для DevOpsLab * Автор: Сергей Антропов - https://devops.org.ru */ (function() { 'use strict'; const TOOLS_MENU_STORAGE_KEY = 'devopslab_tools_menu_open'; const SETTINGS_MENU_STORAGE_KEY = 'devopslab_settings_menu_open'; const SIDEBAR_COLLAPSED_KEY = 'devopslab_sidebar_collapsed'; const SIDEBAR_WIDTH_KEY = 'devopslab_sidebar_width'; const SIDEBAR_DEFAULT_WIDTH = 260; const SIDEBAR_MIN_WIDTH = 180; const SIDEBAR_MAX_WIDTH = 400; const SIDEBAR_COLLAPSED_WIDTH = 64; /** * Сохраняет состояние меню в localStorage */ function saveMenuState(storageKey, isOpen) { localStorage.setItem(storageKey, isOpen ? 'true' : 'false'); } /** * Получает сохраненное состояние меню из localStorage */ function getMenuState(storageKey) { const saved = localStorage.getItem(storageKey); return saved === 'true'; } /** * Переключает состояние меню инструментов */ function toggleToolsMenu(event) { if (event) { event.preventDefault(); } const menuItem = document.getElementById('tools-menu'); if (!menuItem) { return; } const isOpen = menuItem.classList.contains('open'); if (isOpen) { menuItem.classList.remove('open'); saveMenuState(TOOLS_MENU_STORAGE_KEY, false); } else { menuItem.classList.add('open'); saveMenuState(TOOLS_MENU_STORAGE_KEY, true); } } /** * Инициализация меню инструментов при загрузке страницы */ function initToolsMenu() { const menuItem = document.getElementById('tools-menu'); if (!menuItem) { return; } // Проверяем, есть ли активный пункт в подменю const hasActiveItem = menuItem.querySelector('.sidebar-menu-link.active'); // Восстанавливаем состояние из localStorage или открываем, если есть активный пункт const shouldBeOpen = hasActiveItem || getMenuState(TOOLS_MENU_STORAGE_KEY); if (shouldBeOpen) { menuItem.classList.add('open'); } } /** * Переключает состояние меню настроек */ function toggleSettingsMenu(event) { if (event) { event.preventDefault(); } const menuItem = document.getElementById('settings-menu'); if (!menuItem) { return; } const isOpen = menuItem.classList.contains('open'); if (isOpen) { menuItem.classList.remove('open'); saveMenuState(SETTINGS_MENU_STORAGE_KEY, false); } else { menuItem.classList.add('open'); saveMenuState(SETTINGS_MENU_STORAGE_KEY, true); } } /** * Инициализация меню настроек при загрузке страницы */ function initSettingsMenu() { const menuItem = document.getElementById('settings-menu'); if (!menuItem) { return; } // Проверяем, есть ли активный пункт в подменю const hasActiveItem = menuItem.querySelector('.sidebar-menu-link.active'); // Восстанавливаем состояние из localStorage или открываем, если есть активный пункт const shouldBeOpen = hasActiveItem || getMenuState(SETTINGS_MENU_STORAGE_KEY); if (shouldBeOpen) { menuItem.classList.add('open'); } } /** * Открыть/закрыть сайдбар на мобильных */ function toggleSidebar() { const sidebar = document.getElementById('sidebar'); const overlay = document.getElementById('sidebar-overlay'); if (!sidebar || !overlay) return; const isOpen = sidebar.classList.contains('open'); if (isOpen) { sidebar.classList.remove('open'); overlay.classList.remove('visible'); overlay.setAttribute('aria-hidden', 'true'); document.body.style.overflow = ''; } else { sidebar.classList.add('open'); overlay.classList.add('visible'); overlay.setAttribute('aria-hidden', 'false'); document.body.style.overflow = 'hidden'; } } function closeSidebar() { const sidebar = document.getElementById('sidebar'); const overlay = document.getElementById('sidebar-overlay'); if (!sidebar || !overlay) return; sidebar.classList.remove('open'); overlay.classList.remove('visible'); overlay.setAttribute('aria-hidden', 'true'); document.body.style.overflow = ''; } function initSidebarToggle() { const btn = document.getElementById('sidebar-toggle-btn'); const overlay = document.getElementById('sidebar-overlay'); const sidebar = document.getElementById('sidebar'); if (btn) { btn.addEventListener('click', toggleSidebar); } if (overlay) { overlay.addEventListener('click', closeSidebar); } if (sidebar) { sidebar.querySelectorAll('.sidebar-menu-link').forEach(function(link) { link.addEventListener('click', function() { if (window.innerWidth < 992) { closeSidebar(); } }); }); } } /** * Применить ширину сайдбара (CSS переменная и margin main-wrapper) */ function applySidebarWidth(widthPx) { document.documentElement.style.setProperty('--sidebar-width', widthPx + 'px'); } /** * Сохранить и применить состояние сайдбара из localStorage */ function applySidebarState() { const sidebar = document.getElementById('sidebar'); if (!sidebar) return; const collapsed = localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === 'true'; const savedWidth = parseInt(localStorage.getItem(SIDEBAR_WIDTH_KEY), 10); const width = (savedWidth >= SIDEBAR_MIN_WIDTH && savedWidth <= SIDEBAR_MAX_WIDTH) ? savedWidth : SIDEBAR_DEFAULT_WIDTH; if (collapsed) { sidebar.classList.add('collapsed'); applySidebarWidth(SIDEBAR_COLLAPSED_WIDTH); } else { sidebar.classList.remove('collapsed'); applySidebarWidth(width); } } /** * Переключить сворачивание сайдбара (десктоп) */ function toggleSidebarCollapse() { const sidebar = document.getElementById('sidebar'); if (!sidebar) return; // На мобильных устройствах не сворачиваем if (window.innerWidth < 992) { return; } const isCollapsed = sidebar.classList.contains('collapsed'); const newCollapsed = !isCollapsed; // Переключаем класс if (newCollapsed) { sidebar.classList.add('collapsed'); } else { sidebar.classList.remove('collapsed'); } // Сохраняем состояние localStorage.setItem(SIDEBAR_COLLAPSED_KEY, newCollapsed ? 'true' : 'false'); // Применяем ширину if (newCollapsed) { applySidebarWidth(SIDEBAR_COLLAPSED_WIDTH); } else { const savedWidth = parseInt(localStorage.getItem(SIDEBAR_WIDTH_KEY), 10); const width = (savedWidth >= SIDEBAR_MIN_WIDTH && savedWidth <= SIDEBAR_MAX_WIDTH) ? savedWidth : SIDEBAR_DEFAULT_WIDTH; applySidebarWidth(width); } // Обновляем атрибуты кнопки const btn = document.getElementById('sidebar-collapse-btn'); if (btn) { btn.setAttribute('aria-label', newCollapsed ? 'Развернуть меню' : 'Свернуть меню'); btn.setAttribute('title', newCollapsed ? 'Развернуть меню' : 'Свернуть меню'); } } /** * Инициализация перетаскивания ширины сайдбара */ function initSidebarResize() { const sidebar = document.getElementById('sidebar'); const handle = document.getElementById('sidebar-resize-handle'); if (!sidebar || !handle || window.innerWidth < 992) return; let startX, startWidth; handle.addEventListener('mousedown', function(e) { if (sidebar.classList.contains('collapsed')) return; e.preventDefault(); startX = e.clientX; startWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width'), 10) || SIDEBAR_DEFAULT_WIDTH; handle.classList.add('dragging'); document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; function onMouseMove(e) { const delta = e.clientX - startX; let newWidth = startWidth + delta; newWidth = Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, newWidth)); applySidebarWidth(newWidth); localStorage.setItem(SIDEBAR_WIDTH_KEY, String(newWidth)); } function onMouseUp() { handle.classList.remove('dragging'); document.body.style.cursor = ''; document.body.style.userSelect = ''; document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); } document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); }); } /** * Инициализация кнопки сворачивания и ресайза */ function initSidebarCollapseAndResize() { applySidebarState(); const collapseBtn = document.getElementById('sidebar-collapse-btn'); if (collapseBtn) { collapseBtn.addEventListener('click', toggleSidebarCollapse); } initSidebarResize(); } /** * Показать модальное окно с сообщением * @param {string} message - Текст сообщения * @param {string} type - Тип сообщения: 'success', 'error', 'warning', 'info' * @param {string} title - Заголовок модального окна (опционально) * @param {Function} onClose - Callback при закрытии модального окна (опционально) */ function showMessageModal(message, type = 'info', title = null, onClose = null) { const modal = document.getElementById('messageModal'); if (!modal) { console.error('Message modal not found'); return; } const modalElement = new bootstrap.Modal(modal); const modalHeader = document.getElementById('messageModalHeader'); const modalTitle = document.getElementById('messageModalTitle'); const modalIcon = document.getElementById('messageModalIcon'); const modalText = document.getElementById('messageModalText'); // Устанавливаем текст сообщения modalText.textContent = message; // Устанавливаем заголовок if (title) { modalTitle.textContent = title; } else { // Заголовок по умолчанию в зависимости от типа switch(type) { case 'success': modalTitle.textContent = 'Успешно'; break; case 'error': modalTitle.textContent = 'Ошибка'; break; case 'warning': modalTitle.textContent = 'Предупреждение'; break; default: modalTitle.textContent = 'Сообщение'; } } // Устанавливаем иконку и цвет в зависимости от типа modalHeader.className = 'modal-header'; modalIcon.className = 'fas me-2'; switch(type) { case 'success': modalHeader.classList.add('bg-success', 'text-white'); modalIcon.classList.add('fa-check-circle'); break; case 'error': modalHeader.classList.add('bg-danger', 'text-white'); modalIcon.classList.add('fa-exclamation-circle'); break; case 'warning': modalHeader.classList.add('bg-warning', 'text-dark'); modalIcon.classList.add('fa-exclamation-triangle'); break; default: modalHeader.classList.add('bg-info', 'text-white'); modalIcon.classList.add('fa-info-circle'); } // Показываем модальное окно modalElement.show(); // Обработчик закрытия if (onClose) { modal.addEventListener('hidden.bs.modal', function handler() { onClose(); modal.removeEventListener('hidden.bs.modal', handler); }); } } // Делаем функции доступными глобально window.toggleToolsMenu = toggleToolsMenu; window.toggleSettingsMenu = toggleSettingsMenu; window.toggleSidebar = toggleSidebar; window.closeSidebar = closeSidebar; window.toggleSidebarCollapse = toggleSidebarCollapse; window.showMessageModal = showMessageModal; // Инициализация при загрузке DOM if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', function() { initToolsMenu(); initSettingsMenu(); initSidebarToggle(); initSidebarCollapseAndResize(); // Автоскролл для логов const logContainers = document.querySelectorAll('.log-container'); logContainers.forEach(container => { container.scrollTop = container.scrollHeight; }); }); } else { initToolsMenu(); initSettingsMenu(); initSidebarToggle(); initSidebarCollapseAndResize(); // Автоскролл для логов const logContainers = document.querySelectorAll('.log-container'); logContainers.forEach(container => { container.scrollTop = container.scrollHeight; }); } // HTMX конфигурация if (typeof htmx !== 'undefined') { htmx.config.globalViewTransitions = true; htmx.config.useTemplateFragments = true; // Обработка ошибок HTMX htmx.on("htmx:responseError", function(event) { console.error("HTMX Error:", event.detail); const errorMessage = event.detail.error || event.detail.xhr?.responseText || 'Неизвестная ошибка'; showMessageModal(errorMessage, 'error'); }); // Обработка успешных запросов с JSON ответами htmx.on("htmx:afterRequest", function(event) { // Автоскролл для логов после обновления const logContainers = document.querySelectorAll('.log-container'); logContainers.forEach(container => { container.scrollTop = container.scrollHeight; }); // Проверяем, является ли ответ JSON с полем message if (event.detail.xhr) { const status = event.detail.xhr.status; try { const response = JSON.parse(event.detail.xhr.responseText); // Обрабатываем сообщения об успехе (200) if (status === 200 && response.message) { // Определяем тип сообщения let messageType = 'info'; const msgLower = response.message.toLowerCase(); if (msgLower.includes('успешно') || msgLower.includes('success') || msgLower.includes('создан') || msgLower.includes('обновлен') || msgLower.includes('удален') || msgLower.includes('отменен')) { messageType = 'success'; } else if (msgLower.includes('ошибка') || msgLower.includes('error') || msgLower.includes('не удалось') || msgLower.includes('failed')) { messageType = 'error'; } // Показываем модальное окно для всех сообщений showMessageModal(response.message, messageType); } // Обрабатываем ошибки (4xx, 5xx) else if (status >= 400 && (response.detail || response.message)) { const errorMessage = response.detail || response.message || 'Произошла ошибка'; showMessageModal(errorMessage, 'error'); } } catch (e) { // Не JSON ответ, для ошибок показываем общее сообщение if (status >= 400) { showMessageModal('Произошла ошибка при выполнении запроса', 'error'); } } } }); } })();