Files
DevOpsLab/app/static/js/app.js
Сергей Антропов 1fbf9185a2 feat: добавлена пометка типа операции (Build/Push) в истории сборок Dockerfile
- Добавлена колонка 'Тип' во все таблицы истории сборок
- Для push операций отображается registry вместо платформ
- Сохранение пользователя при создании push лога
- Исправлена ошибка с logger в push_docker_image endpoint
- Улучшено отображение истории сборок с визуальными индикаторами
2026-02-15 22:59:02 +03:00

466 lines
18 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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');
}
}
}
});
}
})();