/** * LogBoard+ - Веб-панель для просмотра логов микросервисов * Автор: Сергей Антропов * Сайт: https://devops.org.ru * Версия: 2.0 */ // LogBoard+ script loaded - VERSION 2 /** * Глобальное состояние приложения * Содержит все данные о контейнерах, настройках и режимах отображения */ const state = { services: [], // Список всех доступных сервисов current: null, // Текущий выбранный контейнер для single view open: {}, // Открытые WebSocket соединения: id -> {ws, logEl, wrapEl, counters, pausedBuffer:[], serviceName} wsConnections: {}, // WebSocket соединения для multiview: id -> WebSocket layout: 'tabs', // Режим отображения: 'tabs' | 'grid2' | 'grid3' | 'grid4' filter: null, // Текущий фильтр для логов levels: {debug:true, info:true, warn:true, err:true, other:true}, // Уровни логирования для отображения selectedContainers: [], // Массив ID выбранных контейнеров для мультипросмотра multiViewMode: false, // Режим мультипросмотра (true = multi-view, false = single-view) }; /** * Ссылки на DOM элементы интерфейса * Содержит все элементы управления и отображения */ const els = { // Legacy elements (старые элементы для обратной совместимости) tabs: document.getElementById('tabs'), // Контейнер с вкладками grid: document.getElementById('grid'), // Контейнер с сеткой tail: document.getElementById('tail'), // Поле ввода количества строк логов autoscroll: document.getElementById('autoscroll'), // Чекбокс автопрокрутки wrapToggle: document.getElementById('wrap'), // Переключатель переноса строк autoRefreshOnRestore: document.getElementById('autoRefreshOnRestore'), // Чекбокс автообновления при восстановлении filter: document.getElementById('filter'), // Поле фильтра логов wsstate: document.getElementById('wsstate'), // Индикатор состояния WebSocket ajaxUpdateBtn: document.getElementById('ajaxUpdateBtn'), // Кнопка AJAX обновления projectBadge: document.getElementById('projectBadge'), // Бейдж текущего проекта clearBtn: document.getElementById('clear'), // Кнопка очистки логов refreshBtn: document.getElementById('refresh'), // Кнопка обновления snapshotBtn: document.getElementById('snapshot'), // Кнопка создания снимка lvlDebug: document.getElementById('lvlDebug'), // Кнопка уровня DEBUG lvlInfo: document.getElementById('lvlInfo'), // Кнопка уровня INFO lvlWarn: document.getElementById('lvlWarn'), // Кнопка уровня WARN lvlErr: document.getElementById('lvlErr'), // Кнопка уровня ERROR lvlOther: document.getElementById('lvlOther'), // Кнопка уровня OTHER layoutBadge: document.getElementById('layoutBadge') || { textContent: '' }, // Бейдж режима отображения aggregate: document.getElementById('aggregate') || { checked: false }, // Чекбокс агрегации themeSwitch: document.getElementById('themeSwitch'), // Переключатель темы copyFab: document.getElementById('copyFab'), // Кнопка копирования groupBtn: document.getElementById('groupBtn') || { onclick: null }, // Кнопка группировки // New modern elements (новые элементы современного интерфейса) containerList: document.getElementById('containerList'), // Список контейнеров logContent: document.getElementById('logContent'), // Основной контент логов mobileToggle: document.getElementById('mobileToggle'), // Переключатель мобильного режима optionsBtn: document.getElementById('optionsBtn'), // Кнопка настроек helpBtn: document.getElementById('helpBtn'), // Кнопка помощи logoutBtn: document.getElementById('logoutBtn'), // Кнопка выхода sidebar: document.getElementById('sidebar'), // Боковая панель sidebarToggle: document.getElementById('sidebarToggle'), // Переключатель боковой панели header: document.getElementById('header'), // Заголовок hotkeysModal: document.getElementById('hotkeysModal'), // Модальное окно горячих клавиш hotkeysModalClose: document.getElementById('hotkeysModalClose'), // Кнопка закрытия модального окна multiViewPanel: document.getElementById('multiViewPanel'), // Панель мультипросмотра multiViewPanelTitle: document.getElementById('multiViewPanelTitle'), // Заголовок мультипросмотра singleViewPanel: document.getElementById('singleViewPanel'), // Панель одиночного просмотра singleViewTitle: document.getElementById('singleViewTitle'), // Заголовок одиночного просмотра }; /** * Инициализация переключателя темы * Загружает сохраненную тему из localStorage и настраивает переключатель */ (function initTheme(){ const saved = localStorage.lb_theme || 'dark'; document.documentElement.setAttribute('data-theme', saved); els.themeSwitch.checked = (saved==='light'); els.themeSwitch.addEventListener('change', ()=>{ const t = els.themeSwitch.checked ? 'light' : 'dark'; document.documentElement.setAttribute('data-theme', t); localStorage.lb_theme = t; }); })(); /** * Устанавливает состояние WebSocket соединения в интерфейсе * @param {string} s - Состояние: 'on', 'off', 'err', 'available' */ function setWsState(s){ els.wsstate.textContent = 'ws: ' + s; // Удаляем все классы состояний els.wsstate.classList.remove('ws-on', 'ws-off', 'ws-err', 'ws-available'); // Добавляем соответствующий класс if (s === 'on') { els.wsstate.classList.add('ws-on'); } else if (s === 'off') { els.wsstate.classList.add('ws-off'); } else if (s === 'err') { els.wsstate.classList.add('ws-err'); } else if (s === 'available') { els.wsstate.classList.add('ws-available'); } } /** * Определяет общее состояние WebSocket соединений * Проверяет все открытые соединения и устанавливает соответствующее состояние */ function determineWsState() { const openConnections = Object.keys(state.open); // Если нет открытых соединений, проверяем сервер через AJAX if (openConnections.length === 0) { // Асинхронно проверяем сервер, но возвращаем 'off' для немедленного отображения // Если сервер доступен, checkWebSocketStatus установит 'on' setTimeout(() => { checkWebSocketStatus(); }, 100); return 'off'; } // Проверяем состояние всех соединений let hasActiveConnection = false; let hasConnecting = false; let closedConnections = []; let errorConnections = []; for (const id of openConnections) { const obj = state.open[id]; if (obj && obj.ws) { if (obj.ws.readyState === WebSocket.OPEN) { hasActiveConnection = true; } else if (obj.ws.readyState === WebSocket.CONNECTING) { hasConnecting = true; } else if (obj.ws.readyState === WebSocket.CLOSED || obj.ws.readyState === WebSocket.CLOSING) { closedConnections.push(id); } } else if (obj && obj.ajaxOnly) { // Не удаляем ajax-only объекты из state.open, они используются для AJAX обновлений continue; } else { closedConnections.push(id); } } // Удаляем закрытые соединения closedConnections.forEach(id => { const obj = state.open[id]; if (obj && obj.ajaxOnly) return; // сохраняем ajax-only объекты delete state.open[id]; }); // Если есть активные соединения или есть соединения в процессе установки if (hasActiveConnection || hasConnecting) { return 'on'; } else { // Асинхронно проверяем сервер, но возвращаем 'off' для немедленного отображения // Если сервер доступен, checkWebSocketStatus установит 'on' setTimeout(() => { checkWebSocketStatus(); }, 100); return 'off'; } } // Функция для проверки состояния WebSocket через AJAX async function checkWebSocketStatus() { try { const token = localStorage.getItem('access_token'); if (!token) { setWsState('off'); return; } const response = await fetch('/api/websocket/status', { headers: { 'Authorization': `Bearer ${token}` } }); if (response.ok) { const data = await response.json(); if (data.status === 'available') { // Проверяем активные клиентские соединения const openConnections = Object.keys(state.open); let hasActiveConnection = false; for (const id of openConnections) { const obj = state.open[id]; if (obj && obj.ws && obj.ws.readyState === WebSocket.OPEN) { hasActiveConnection = true; break; } } // Если сервер доступен, всегда показываем 'on' setWsState('on'); } else if (data.status === 'no_containers') { setWsState('off'); } else { setWsState('err'); } } else { setWsState('err'); } } catch (error) { console.error('checkWebSocketStatus: Ошибка запроса:', error); setWsState('err'); } } // Интервал для автоматической проверки состояния WebSocket let wsStatusInterval = null; // Функция для запуска автоматической проверки состояния WebSocket function startWebSocketStatusCheck() { if (wsStatusInterval) { clearInterval(wsStatusInterval); } // Проверяем каждые 3 секунды wsStatusInterval = setInterval(() => { checkWebSocketStatus(); }, 3000); } // Функция для остановки автоматической проверки function stopWebSocketStatusCheck() { if (wsStatusInterval) { clearInterval(wsStatusInterval); wsStatusInterval = null; } } /** * Устанавливает визуальное состояние кнопки AJAX обновления * @param {boolean} enabled - Включено ли AJAX обновление */ function setAjaxUpdateState(enabled) { if (els.ajaxUpdateBtn) { // Удаляем все классы состояний els.ajaxUpdateBtn.classList.remove('ajax-on', 'ajax-off'); // Добавляем соответствующий класс if (enabled) { els.ajaxUpdateBtn.classList.add('ajax-on'); els.ajaxUpdateBtn.textContent = 'update'; } else { els.ajaxUpdateBtn.classList.add('ajax-off'); els.ajaxUpdateBtn.textContent = 'update'; } } } /** * Обновляет отображение всех логов при изменении фильтров * Перерисовывает логи с учетом текущих настроек фильтрации и уровней */ function refreshAllLogs() { // Обновляем обычный просмотр Object.keys(state.open).forEach(id => { const obj = state.open[id]; if (!obj || !obj.logEl) return; // Получаем все логи из буфера const allLogs = obj.allLogs || []; const filteredHtml = []; allLogs.forEach(logEntry => { // Проверяем уровень логирования if (!allowedByLevel(logEntry.cls)) return; // Проверяем фильтр if (!applyFilter(logEntry.line)) return; filteredHtml.push(logEntry.html); }); // Обновляем отображение obj.logEl.innerHTML = filteredHtml.join(''); // Сразу очищаем пустые строки в legacy панели cleanSingleViewEmptyLines(obj.logEl); cleanDuplicateLines(obj.logEl); // Обновляем современный интерфейс if (state.current && state.current.id === id && els.logContent) { els.logContent.innerHTML = obj.logEl.innerHTML; // Очищаем дублированные строки в Single View после обновления cleanSingleViewEmptyLines(els.logContent); cleanDuplicateLines(els.logContent); } }); // Обновляем мультипросмотр if (state.multiViewMode) { state.selectedContainers.forEach(containerId => { const obj = state.open[containerId]; if (!obj || !obj.logEl) return; // Получаем все логи из буфера const allLogs = obj.allLogs || []; const filteredHtml = []; allLogs.forEach(logEntry => { // Проверяем уровень логирования if (!allowedByLevel(logEntry.cls)) return; // Проверяем фильтр if (!applyFilter(logEntry.line)) return; filteredHtml.push(logEntry.html); }); // Обновляем отображение в мультипросмотре const multiViewLog = document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`); if (multiViewLog) { multiViewLog.innerHTML = filteredHtml.join(''); // Сразу очищаем пустые строки в мультипросмотре cleanMultiViewEmptyLines(multiViewLog); cleanMultiViewDuplicateLines(multiViewLog); } }); } // Пересчитываем счетчики в зависимости от режима после обновления логов setTimeout(() => { if (state.multiViewMode) { recalculateMultiViewCounters(); } else { recalculateCounters(); } // Прокручиваем к последним логам после обновления scrollToBottom(); }, 100); } /** * Экранирует HTML символы для безопасного отображения * @param {string} s - Строка для экранирования * @returns {string} Экранированная строка */ function escapeHtml(s){ return s.replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m])); } /** * Классифицирует строку лога по уровню логирования * Определяет уровень на основе ключевых слов и паттернов в строке * @param {string} line - Строка лога для классификации * @returns {string} Класс уровня: 'dbg', 'err', 'warn', 'ok', 'other' */ function classify(line){ const l = line.toLowerCase(); // Проверяем различные форматы уровней логирования (более специфичные сначала) // DEBUG - ищем точное совпадение уровня логирования if (/\s- DEBUG -|\s\[debug\]|level=debug|\bdebug\b(?=\s|$)/.test(l)) { return 'dbg'; } // ERROR - ищем точное совпадение уровня логирования if (/\s- ERROR -|\s\[error\]|level=error/.test(l)) { return 'err'; } // FATAL - ищем точное совпадение уровня логирования (раскрашиваем как ERROR) if (/\s- FATAL -|\s\[fatal\]|level=fatal/.test(l)) { return 'err'; } // PostgreSQL FATAL - специальная проверка для FATAL логов PostgreSQL if (/\[\d+\]\s+FATAL:/i.test(l)) { return 'err'; } // Простая проверка для любых строк содержащих "FATAL:" if (l.includes('fatal:')) { return 'err'; } // WARNING - ищем точное совпадение уровня логирования if (/\s- WARNING -|\s\[warn\]|level=warn/.test(l)) { return 'warn'; } // INFO - ищем точное совпадение уровня логирования if (/\s- INFO -|\s\[info\]|level=info/.test(l)) { return 'ok'; } // LOG - ищем точное совпадение уровня логирования (раскрашиваем как INFO) if (/\s- LOG -|\s\[log\]|level=log|^log:/i.test(l)) { return 'ok'; } // PostgreSQL LOG - специальная проверка для логов PostgreSQL if (/\[\d+\]\s+LOG:/i.test(l)) { return 'ok'; } // Простая проверка для любых строк содержащих "LOG:" if (l.includes('log:')) { return 'ok'; } // Дополнительные проверки для других форматов (только если не найдены точные совпадения) if (/\bdebug\b/i.test(l)) return 'dbg'; if (/\berror\b/i.test(l)) return 'err'; if (/\bfatal\b/i.test(l)) { return 'err'; // FATAL также раскрашиваем как ERROR } if (/\bwarning\b/i.test(l)) return 'warn'; if (/\binfo\b/i.test(l)) return 'ok'; if (/\blog\b/i.test(l)) { return 'ok'; // LOG также раскрашиваем как INFO } // Отладка для неклассифицированных логов (убрано для снижения шума в консоли) return 'other'; } /** * Проверяет, разрешен ли отображение лога данного уровня * @param {string} cls - Класс уровня лога ('dbg', 'err', 'warn', 'ok', 'other') * @returns {boolean} Разрешен ли отображение */ function allowedByLevel(cls){ if (cls==='dbg') return state.levels.debug; if (cls==='err') return state.levels.err; if (cls==='warn') return state.levels.warn; if (cls==='ok') return state.levels.info; if (cls==='other') return state.levels.other; // Показываем неклассифицированные строки в зависимости от настройки return true; } /** * Проверяет, разрешен ли отображение лога данного уровня для конкретного контейнера * Используется в режиме мультипросмотра для индивидуальных настроек контейнеров * @param {string} cls - Класс уровня лога ('dbg', 'err', 'warn', 'ok', 'other') * @param {string} containerId - ID контейнера * @returns {boolean} Разрешен ли отображение */ function allowedByContainerLevel(cls, containerId) { // Если настройки контейнера не инициализированы, инициализируем их if (!state.containerLevels) { state.containerLevels = {}; } if (!state.containerLevels[containerId]) { state.containerLevels[containerId] = {debug: true, info: true, warn: true, err: true, other: true}; } const containerLevels = state.containerLevels[containerId]; let result; if (cls==='dbg') result = containerLevels.debug; else if (cls==='err') result = containerLevels.err; else if (cls==='warn') result = containerLevels.warn; else if (cls==='ok') result = containerLevels.info; else if (cls==='other') result = containerLevels.other; else result = true; return result; } /** * Обновляет видимость логов в Single View режиме * Перерисовывает логи с учетом текущих фильтров и настроек уровней * @param {HTMLElement} logElement - Элемент для обновления */ function updateLogVisibility(logElement) { if (!logElement || !state.current) return; const containerId = state.current.id; const obj = state.open[containerId]; if (!obj || !obj.allLogs) return; // Пересоздаем содержимое лога с учетом фильтров, сохраняя HTML-разметку let visibleHtml = ''; obj.allLogs.forEach(logEntry => { const shouldShow = allowedByLevel(logEntry.cls) && applyFilter(logEntry.line); if (shouldShow) { visibleHtml += logEntry.html + '\n'; } }); logElement.innerHTML = visibleHtml; // Обновляем счетчики recalculateCounters(); // Обновляем состояние кнопок уровней логирования только для single-view const singleLevelBtns = document.querySelectorAll('.single-view-levels .level-btn'); singleLevelBtns.forEach(btn => { const level = btn.getAttribute('data-level'); const isActive = state.levels[level]; btn.classList.toggle('active', isActive); btn.classList.toggle('disabled', !isActive); }); } // Функция для обновления видимости логов конкретного контейнера в Multi View function updateContainerLogVisibility(containerId) { if (!state.multiViewMode) return; const logElement = document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`); if (!logElement) return; const obj = state.open[containerId]; if (!obj || !obj.allLogs) return; // Пересоздаем содержимое лога с учетом фильтров контейнера, сохраняя HTML-разметку let visibleHtml = ''; obj.allLogs.forEach(logEntry => { const shouldShow = allowedByContainerLevel(logEntry.cls, containerId) && applyFilter(logEntry.line); if (shouldShow) { visibleHtml += logEntry.html + '\n'; } }); logElement.innerHTML = visibleHtml; // Обновляем счетчики для этого контейнера updateContainerCounters(containerId); // Обновляем состояние кнопок уровней логирования только для этого контейнера const levelBtns = document.querySelectorAll(`.level-btn[data-container-id="${containerId}"]`); levelBtns.forEach(btn => { const level = btn.getAttribute('data-level'); // Используем настройки контейнера, если они есть const containerLevels = state.containerLevels && state.containerLevels[containerId] ? state.containerLevels[containerId] : {debug: true, info: true, warn: true, err: true, other: true}; const isActive = containerLevels[level]; btn.classList.toggle('active', isActive); btn.classList.toggle('disabled', !isActive); }); } // Функция для обновления счетчиков конкретного контейнера function updateContainerCounters(containerId) { const obj = state.open[containerId]; if (!obj || !obj.allLogs) return; // Получаем значение Tail Lines const tailLines = parseInt(els.tail.value) || 50; // Берем только последние N логов const visibleLogs = obj.allLogs.slice(-tailLines); // Сбрасываем счетчики obj.counters = {dbg: 0, info: 0, warn: 0, err: 0, other: 0}; // Пересчитываем счетчики только для отображаемых логов visibleLogs.forEach(logEntry => { const shouldShow = allowedByContainerLevel(logEntry.cls, containerId) && applyFilter(logEntry.line); if (shouldShow) { if (logEntry.cls === 'dbg') obj.counters.dbg++; if (logEntry.cls === 'ok') obj.counters.info++; if (logEntry.cls === 'warn') obj.counters.warn++; if (logEntry.cls === 'err') obj.counters.err++; if (logEntry.cls === 'other') obj.counters.other++; } }); // Обновляем отображение счетчиков в кнопках заголовка const levelBtns = document.querySelectorAll(`.level-btn[data-container-id="${containerId}"]`); levelBtns.forEach(btn => { const level = btn.getAttribute('data-level'); const valueEl = btn.querySelector('.level-value'); if (valueEl) { switch (level) { case 'debug': valueEl.textContent = obj.counters.dbg; break; case 'info': valueEl.textContent = obj.counters.info; break; case 'warn': valueEl.textContent = obj.counters.warn; break; case 'err': valueEl.textContent = obj.counters.err; break; case 'other': valueEl.textContent = obj.counters.other; break; } } }); } // Функция для обновления счетчиков в кнопках заголовков function updateHeaderCounters(containerId, counters) { // Обновляем счетчики для single-view (если это текущий контейнер) if (state.current && state.current.id === containerId) { const singleLevelBtns = document.querySelectorAll('.single-view-levels .level-btn'); singleLevelBtns.forEach(btn => { const level = btn.getAttribute('data-level'); const valueEl = btn.querySelector('.level-value'); if (valueEl) { switch (level) { case 'debug': valueEl.textContent = counters.dbg; break; case 'info': valueEl.textContent = counters.info; break; case 'warn': valueEl.textContent = counters.warn; break; case 'err': valueEl.textContent = counters.err; break; case 'other': valueEl.textContent = counters.other; break; } } }); } // Обновляем счетчики для multi-view (только для конкретного контейнера) if (state.multiViewMode && state.selectedContainers.includes(containerId)) { const multiLevelBtns = document.querySelectorAll(`.level-btn[data-container-id="${containerId}"]`); multiLevelBtns.forEach(btn => { const level = btn.getAttribute('data-level'); const valueEl = btn.querySelector('.level-value'); if (valueEl) { switch (level) { case 'debug': valueEl.textContent = counters.dbg; break; case 'info': valueEl.textContent = counters.info; break; case 'warn': valueEl.textContent = counters.warn; break; case 'err': valueEl.textContent = counters.err; break; case 'other': valueEl.textContent = counters.other; break; } } }); } } // Функция для инициализации состояния кнопок уровней логирования function initializeLevelButtons() { // Восстанавливаем состояние кнопок loglevels из localStorage const savedLevelsState = getLogLevelsStateFromStorage(); if (savedLevelsState) { // Восстанавливаем глобальные настройки для single-view if (savedLevelsState.globalLevels) { state.levels = { ...state.levels, ...savedLevelsState.globalLevels }; } // Восстанавливаем настройки контейнеров для multi-view if (savedLevelsState.containerLevels) { state.containerLevels = { ...state.containerLevels, ...savedLevelsState.containerLevels }; } } // Инициализируем кнопки для single-view const singleLevelBtns = document.querySelectorAll('.single-view-levels .level-btn'); singleLevelBtns.forEach(btn => { const level = btn.getAttribute('data-level'); const isActive = state.levels[level]; btn.classList.toggle('active', isActive); btn.classList.toggle('disabled', !isActive); }); // Инициализируем кнопки для multi-view (если есть) const multiLevelBtns = document.querySelectorAll('.multi-view-levels .level-btn'); multiLevelBtns.forEach((btn, index) => { const level = btn.getAttribute('data-level'); const containerId = btn.getAttribute('data-container-id'); // Инициализируем настройки контейнера, если их нет if (containerId && (!state.containerLevels || !state.containerLevels[containerId])) { if (!state.containerLevels) { state.containerLevels = {}; } state.containerLevels[containerId] = {debug: true, info: true, warn: true, err: true, other: true}; } // Используем настройки контейнера const isActive = state.containerLevels && state.containerLevels[containerId] ? state.containerLevels[containerId][level] : true; btn.classList.toggle('active', isActive); btn.classList.toggle('disabled', !isActive); }); // Обновляем стили логов после инициализации кнопок updateLogStyles(); // Применяем настройки wrap text applyWrapSettings(); // Устанавливаем обработчик событий для кнопок уровней логирования if (window.levelButtonClickHandler) { document.addEventListener('click', window.levelButtonClickHandler); } } /** * Применяет фильтр к строке лога * Проверяет, соответствует ли строка текущему фильтру (безопасный regex поиск) * @param {string} line - Строка лога для проверки * @returns {boolean} Проходит ли строка фильтр */ function applyFilter(line){ if(!state.filter) return true; try{ // Экранируем специальные символы regex для безопасного поиска const escapedFilter = state.filter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return new RegExp(escapedFilter, 'i').test(line); }catch(e){ console.error('Filter error:', e); return true; } } /** * Константы и настройки для работы с ANSI цветами * SGR (Select Graphic Rendition): 0/1/3/4, 30-37 */ /** * Настройки экземпляров контейнеров * Содержит цвета, фильтры и палитру для визуального различия контейнеров */ const inst = { colors: {}, // Кэш цветов для контейнеров filters: {}, // Фильтры для экземпляров palette: [ // Палитра цветов для контейнеров '#7aa2f7','#9ece6a','#e0af68','#f7768e','#bb9af7','#7dcfff','#c0caf5','#f6bd60', '#84cc16','#06b6d4','#fb923c','#ef4444','#22c55e','#a855f7' ] }; /** * Генерирует уникальный цвет для контейнера на основе его ID * Использует хеш-функцию для детерминированного выбора цвета из палитры * @param {string} id8 - Первые 8 символов ID контейнера * @returns {string} HEX цвет для контейнера */ function idColor(id8){ if (inst.colors[id8]) return inst.colors[id8]; // simple hash to pick from palette let h = 0; for (let i=0;i>>0; } const color = inst.palette[h % inst.palette.length]; inst.colors[id8] = color; return color; } function updateIdFiltersBar(){ const bar = document.getElementById('idFilters'); bar.innerHTML = ''; const ids = Object.keys(inst.filters); if (!ids.length){ bar.style.display='none'; return; } bar.style.display='flex'; ids.forEach(id8=>{ const wrap = document.createElement('label'); wrap.style.display='inline-flex'; wrap.style.alignItems='center'; wrap.style.gap='6px'; const cb = document.createElement('input'); cb.type='checkbox'; cb.checked = inst.filters[id8] !== false; cb.onchange = ()=> inst.filters[id8] = cb.checked; const chip = document.createElement('span'); chip.className='inst-tag'; chip.style.borderColor = idColor(id8); chip.style.color = idColor(id8); chip.textContent = id8; wrap.appendChild(cb); wrap.appendChild(chip); bar.appendChild(wrap); }); } function shouldShowInstance(id8){ if (!Object.keys(inst.filters).length) return true; const val = inst.filters[id8]; return val !== false; } function parsePrefixAndStrip(line){ // Accept "[id]" or "[id service]" prefixes from fan/fan_group const m = line.match(/^\[([0-9a-f]{8})(?:\s+[^\]]+)?\]\s(.*)$/i); if (!m) return null; return {id8: m[1], rest: m[2]}; } /** * Конвертирует ANSI escape-последовательности в HTML * Поддерживает цвета (30-37), жирный (1), курсив (3), подчеркивание (4) * @param {string} text - Текст с ANSI кодами * @returns {string} HTML с CSS классами для стилизации */ function ansiToHtml(text){ const ESC = '\u001b['; const parts = text.split(ESC); if (parts.length === 1) return escapeHtml(text); let html = escapeHtml(parts[0]); let classes = []; for (let i=1;i=30 && c<=37){ classes = classes.filter(x=>!x.startsWith('ansi-')); const map = {30:'black',31:'red',32:'green',33:'yellow',34:'blue',35:'magenta',36:'cyan',37:'white'}; classes.push('ansi-'+map[c]); } } if (classes.length) html += `` + escapeHtml(rest) + ``; else html += escapeHtml(rest); } return html; } function panelTemplate(svc){ const div = document.createElement('div'); div.className='panel'; div.dataset.cid = svc.id; div.innerHTML = `
${escapeHtml((svc.project?`[${svc.project}] `:'')+(svc.service||svc.name))} dbg:0 info:0 warn:0 err:0
`; return div; } function buildTabs(){ // Legacy tabs (hidden) els.tabs.innerHTML=''; state.services.forEach(svc=>{ const b = document.createElement('button'); b.className='tab' + ((state.current && svc.id===state.current.id) ? ' active':''); b.textContent = (svc.project?(`[${svc.project}] `):'') + (svc.service||svc.name); b.title = `${svc.name} • ${svc.image} • ${svc.status}`; b.onclick = async ()=> await switchToSingle(svc); els.tabs.appendChild(b); }); // Modern container list els.containerList.innerHTML = ''; // Миникарточки контейнеров const miniContainerList = document.getElementById('miniContainerList'); if (miniContainerList) { miniContainerList.innerHTML = ''; } state.services.forEach(svc => { // Создаем обычную карточку контейнера const item = document.createElement('div'); item.className = 'container-item'; if (state.current && svc.id === state.current.id) { item.classList.add('active'); } item.setAttribute('data-cid', svc.id); const statusClass = svc.status === 'running' ? 'running' : svc.status === 'stopped' ? 'stopped' : 'paused'; item.innerHTML = `
${escapeHtml(svc.name)}
${escapeHtml(svc.service || svc.name)} • ${escapeHtml(svc.project || 'standalone')}
${escapeHtml(svc.status)} ${svc.status === 'running' && svc.host_port ? ` on ${svc.host_port} port` : ''} ${svc.url ? `` : ''}
`; // Устанавливаем состояние selected для контейнера if (state.selectedContainers.includes(svc.id)) { item.classList.add('selected'); } item.onclick = async (e) => { // Не переключаем контейнер, если кликнули на ссылку или чекбокс if (e.target.closest('.container-link') || e.target.closest('.container-select')) { e.stopPropagation(); return; } await switchToSingle(svc); }; els.containerList.appendChild(item); // Создаем миникарточку контейнера if (miniContainerList) { const miniItem = document.createElement('div'); miniItem.className = 'mini-container-item'; if (state.current && svc.id === state.current.id) { miniItem.classList.add('active'); } miniItem.setAttribute('data-cid', svc.id); // Сокращаем имя для миникарточки const shortName = svc.name.length > 8 ? svc.name.substring(0, 6) + '..' : svc.name; miniItem.innerHTML = `
${escapeHtml(shortName)}
`; // Добавляем обработчики для всплывающих подсказок miniItem.addEventListener('mouseenter', (e) => { showMiniContainerTooltip(e, svc); }); miniItem.addEventListener('mouseleave', () => { // Добавляем задержку перед скрытием подсказки const tooltip = document.getElementById('miniContainerTooltip'); if (tooltip) { tooltip.hideTimer = setTimeout(() => { if (tooltip && !tooltip.matches(':hover')) { hideMiniContainerTooltip(); } }, 150); } }); // Обработчик клика для миникарточек с поддержкой Shift+клик и Ctrl+клик miniItem.addEventListener('click', async (e) => { e.preventDefault(); e.stopPropagation(); if (e.shiftKey && lastSelectedContainerId && lastSelectedContainerId !== svc.id) { // Shift+клик с предыдущим выбором - диапазонный выбор selectContainerRange(lastSelectedContainerId, svc.id); } else if (e.shiftKey) { // Shift+клик - добавляем/убираем из мультивыбора toggleContainerSelection(svc.id); lastSelectedContainerId = svc.id; } else if (e.ctrlKey || e.metaKey) { // Ctrl/Cmd+клик - добавляем/убираем из мультивыбора toggleContainerSelection(svc.id); lastSelectedContainerId = svc.id; } else { // Обычный клик - переключаемся в single view lastSelectedContainerId = svc.id; await switchToSingle(svc); } }); miniContainerList.appendChild(miniItem); } }); } function setLayout(cls){ state.layout = cls; if (els.layoutBadge) { els.layoutBadge.textContent = 'view: ' + (cls==='tabs'?'tabs':cls); } els.grid.className = cls==='tabs' ? 'grid-1' : (cls==='grid2'?'grid-2':cls==='grid3'?'grid-3':'grid-4'); } async function fetchProjects(){ try { console.log('Fetching projects...'); const url = new URL(location.origin + '/api/containers/projects'); const token = localStorage.getItem('access_token'); if (!token) { console.error('No access token found'); window.location.href = '/login'; return; } const res = await fetch(url, { headers: { 'Authorization': `Bearer ${token}` } }); if (!res.ok){ if (res.status === 401) { console.error('Unauthorized, redirecting to login'); window.location.href = '/login'; return; } console.error('Failed to fetch projects:', res.status, res.statusText); return; } const projects = await res.json(); console.log('Projects loaded:', projects); // Обновляем мультивыбор проектов в заголовке const dropdown = document.getElementById('projectSelectDropdown'); const display = document.getElementById('projectSelectDisplay'); const displayText = display?.querySelector('.multi-select-text'); console.log('Multi-select elements found:', {dropdown: !!dropdown, display: !!display, displayText: !!displayText}); if (dropdown && displayText) { // Очищаем dropdown dropdown.innerHTML = ''; // Добавляем опцию "All Projects" const allOption = document.createElement('div'); allOption.className = 'multi-select-option'; allOption.setAttribute('data-value', 'all'); allOption.innerHTML = ` `; dropdown.appendChild(allOption); // Добавляем проекты console.log('Adding projects to multi-select:', projects); projects.forEach(project => { const option = document.createElement('div'); option.className = 'multi-select-option'; option.setAttribute('data-value', project); option.innerHTML = ` `; dropdown.appendChild(option); }); // Восстанавливаем сохраненные выбранные проекты const savedProjects = JSON.parse(localStorage.getItem('lb_selected_projects') || '["all"]'); updateMultiSelect(savedProjects); console.log('Multi-select updated, current selection:', savedProjects); } else { console.error('Multi-select elements not found!'); } } catch (error) { console.error('Error fetching projects:', error); } } // Функция для обновления мультивыбора проектов function updateMultiSelect(selectedProjects) { const dropdown = document.getElementById('projectSelectDropdown'); const displayText = document.querySelector('.multi-select-text'); if (!dropdown || !displayText) return; // Фильтруем выбранные проекты, оставляя только те, которые есть в dropdown const availableProjects = []; dropdown.querySelectorAll('.multi-select-option').forEach(option => { const value = option.getAttribute('data-value'); availableProjects.push(value); }); const filteredProjects = selectedProjects.filter(project => project === 'all' || availableProjects.includes(project) ); // Если все выбранные проекты исчезли, выбираем "All Projects" if (filteredProjects.length === 0 || (filteredProjects.length === 1 && filteredProjects[0] === 'all')) { filteredProjects.length = 0; filteredProjects.push('all'); } // Обновляем чекбоксы dropdown.querySelectorAll('.multi-select-option').forEach(option => { const value = option.getAttribute('data-value'); const checkbox = option.querySelector('input[type="checkbox"]'); if (checkbox) { checkbox.checked = filteredProjects.includes(value); } }); // Обновляем текст отображения if (filteredProjects.includes('all') || filteredProjects.length === 0) { displayText.textContent = 'All Projects'; } else if (filteredProjects.length === 1) { displayText.textContent = filteredProjects[0]; } else { displayText.textContent = `${filteredProjects.length} Projects`; } // Сохраняем в localStorage localStorage.setItem('lb_selected_projects', JSON.stringify(filteredProjects)); } // Функция для получения выбранных проектов function getSelectedProjects() { const dropdown = document.getElementById('projectSelectDropdown'); if (!dropdown) return ['all']; const selectedProjects = []; dropdown.querySelectorAll('.multi-select-option input[type="checkbox"]:checked').forEach(checkbox => { const option = checkbox.closest('.multi-select-option'); const value = option.getAttribute('data-value'); selectedProjects.push(value); }); return selectedProjects.length > 0 ? selectedProjects : ['all']; } // Функции для работы с исключенными контейнерами async function loadExcludedContainers() { try { const token = localStorage.getItem('access_token'); if (!token) { console.error('No access token found'); return []; } const response = await fetch('/api/containers/excluded', { headers: { 'Authorization': `Bearer ${token}` } }); if (!response.ok) { if (response.status === 401) { console.error('Unauthorized, redirecting to login'); window.location.href = '/login'; return []; } console.error('Ошибка загрузки исключенных контейнеров:', response.status); return []; } const data = await response.json(); return data.excluded_containers || []; } catch (error) { console.error('Ошибка загрузки исключенных контейнеров:', error); return []; } } async function saveExcludedContainers(containers) { try { const token = localStorage.getItem('access_token'); if (!token) { console.error('No access token found'); return false; } const response = await fetch('/api/containers/excluded', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify(containers) }); if (!response.ok) { if (response.status === 401) { console.error('Unauthorized, redirecting to login'); window.location.href = '/login'; return false; } console.error('Ошибка сохранения исключенных контейнеров:', response.status); return false; } const data = await response.json(); console.log('Исключенные контейнеры сохранены:', data); return true; } catch (error) { console.error('Ошибка сохранения исключенных контейнеров:', error); return false; } } function renderExcludedContainers(containers) { const list = document.getElementById('excludedContainersList'); if (!list) return; list.innerHTML = ''; if (containers.length === 0) { list.innerHTML = '
Нет исключенных контейнеров
'; return; } containers.forEach(container => { const item = document.createElement('div'); item.className = 'excluded-container-item'; item.innerHTML = ` ${escapeHtml(container)} `; list.appendChild(item); }); } async function addExcludedContainer() { const input = document.getElementById('newExcludedContainer'); const containerName = input.value.trim(); if (!containerName) { alert('Введите имя контейнера'); return; } const currentContainers = await loadExcludedContainers(); if (currentContainers.includes(containerName)) { alert('Контейнер уже в списке исключенных'); return; } currentContainers.push(containerName); const success = await saveExcludedContainers(currentContainers); if (success) { renderExcludedContainers(currentContainers); input.value = ''; // Обновляем список проектов и контейнеров await fetchProjects(); await fetchServices(); } else { alert('Ошибка сохранения'); } } async function removeExcludedContainer(containerName) { const currentContainers = await loadExcludedContainers(); const updatedContainers = currentContainers.filter(name => name !== containerName); const success = await saveExcludedContainers(updatedContainers); if (success) { renderExcludedContainers(updatedContainers); // Обновляем список проектов и контейнеров await fetchProjects(); await fetchServices(); } else { alert('Ошибка удаления'); } } // Функции для мультивыбора контейнеров function toggleContainerSelection(containerId) { console.log('toggleContainerSelection called for:', containerId); console.log('Current state.selectedContainers before:', [...state.selectedContainers]); const index = state.selectedContainers.indexOf(containerId); if (index > -1) { state.selectedContainers.splice(index, 1); console.log('Removed container from selection:', containerId); } else { state.selectedContainers.push(containerId); console.log('Added container to selection:', containerId); } console.log('Current selected containers after:', state.selectedContainers); updateContainerSelectionUI(); updateMultiViewMode(); } function updateContainerSelectionUI() { console.log('updateContainerSelectionUI called, selected containers:', state.selectedContainers); // Обновляем чекбоксы и обычные карточки контейнеров const checkboxes = document.querySelectorAll('.container-checkbox'); checkboxes.forEach(checkbox => { const containerId = checkbox.getAttribute('data-container-id'); const containerItem = checkbox.closest('.container-item'); // Processing checkbox for container if (state.selectedContainers.includes(containerId)) { checkbox.checked = true; containerItem.classList.add('selected'); } else { checkbox.checked = false; containerItem.classList.remove('selected'); } }); // Обновляем миникарточки контейнеров const miniContainerItems = document.querySelectorAll('.mini-container-item'); miniContainerItems.forEach(miniItem => { const containerId = miniItem.getAttribute('data-cid'); if (state.selectedContainers.includes(containerId)) { miniItem.classList.add('selected'); } else { miniItem.classList.remove('selected'); } }); // Обновляем single-view-title если он существует const singleViewTitle = document.getElementById('singleViewTitle'); if (singleViewTitle && state.selectedContainers.length === 1) { const service = state.services.find(s => s.id === state.selectedContainers[0]); if (service) { singleViewTitle.textContent = `${service.name} (${service.service || service.name})`; } } else if (singleViewTitle && state.selectedContainers.length === 0) { singleViewTitle.textContent = 'No container selected'; } else if (singleViewTitle && state.selectedContainers.length > 1) { singleViewTitle.textContent = `Multi-view: ${state.selectedContainers.length} containers`; } // Если есть сохраненный контейнер, убеждаемся что его чекбокс отмечен const savedContainerId = getSelectedContainerFromStorage(); if (savedContainerId && state.selectedContainers.includes(savedContainerId)) { const checkbox = document.querySelector(`.container-checkbox[data-container-id="${savedContainerId}"]`); if (checkbox) { checkbox.checked = true; const containerItem = checkbox.closest('.container-item'); if (containerItem) { containerItem.classList.add('selected'); } } } // Обновляем видимость кнопок LogLevels updateLogLevelsVisibility(); } // Функция для обновления активного состояния контейнеров в UI function updateActiveContainerUI(activeContainerId) { console.log('updateActiveContainerUI called for:', activeContainerId); // Обновляем обычные карточки контейнеров const containerItems = document.querySelectorAll('.container-item'); containerItems.forEach(item => { const containerId = item.getAttribute('data-cid'); if (activeContainerId && containerId === activeContainerId) { item.classList.add('active'); } else { item.classList.remove('active'); } }); // Обновляем миникарточки контейнеров const miniContainerItems = document.querySelectorAll('.mini-container-item'); miniContainerItems.forEach(miniItem => { const containerId = miniItem.getAttribute('data-cid'); if (activeContainerId && containerId === activeContainerId) { miniItem.classList.add('active'); } else { miniItem.classList.remove('active'); } }); // Обновляем legacy tabs const tabButtons = document.querySelectorAll('.tab'); tabButtons.forEach(tab => { const tabText = tab.textContent; const service = state.services.find(s => (s.project ? `[${s.project}] ` : '') + (s.service || s.name) === tabText ); if (service && activeContainerId && service.id === activeContainerId) { tab.classList.add('active'); } else { tab.classList.remove('active'); } }); } // Функции для всплывающих подсказок миникарточек function showMiniContainerTooltip(event, service) { // Удаляем существующую подсказку hideMiniContainerTooltip(); // Очищаем все таймеры скрытия const existingTooltips = document.querySelectorAll('.mini-container-tooltip'); existingTooltips.forEach(tooltip => { if (tooltip.hideTimer) { clearTimeout(tooltip.hideTimer); } }); // Создаем новую подсказку const tooltip = document.createElement('div'); tooltip.className = 'mini-container-tooltip'; tooltip.id = 'miniContainerTooltip'; const statusClass = service.status === 'running' ? 'running' : service.status === 'stopped' ? 'stopped' : 'paused'; tooltip.innerHTML = `
Контейнер
${escapeHtml(service.name)}
${escapeHtml(service.service || service.name)} • ${escapeHtml(service.project || 'standalone')}
${escapeHtml(service.status)}
${service.status === 'running' && service.host_port ? `
Порт: ${escapeHtml(service.host_port)}
` : ''} ${service.url ? ` Открыть сайт` : ''} `; // Добавляем подсказку в body document.body.appendChild(tooltip); // Позиционируем подсказку сразу positionTooltip(event, tooltip); // Показываем подсказку сразу tooltip.classList.add('show'); // Добавляем обработчики для подсказки tooltip.addEventListener('mouseenter', () => { // Останавливаем таймер скрытия при наведении на подсказку clearTimeout(tooltip.hideTimer); }); tooltip.addEventListener('mouseleave', () => { // Скрываем подсказку при уходе курсора с неё hideMiniContainerTooltip(); }); // Добавляем обработчик для клика по ссылке const link = tooltip.querySelector('.mini-container-tooltip-url'); if (link) { link.addEventListener('click', (e) => { e.stopPropagation(); // Ссылка откроется в новой вкладке благодаря target="_blank" }); } } function hideMiniContainerTooltip() { const tooltip = document.getElementById('miniContainerTooltip'); if (tooltip) { // Очищаем таймер скрытия if (tooltip.hideTimer) { clearTimeout(tooltip.hideTimer); } tooltip.remove(); } } function positionTooltip(event, tooltip) { const rect = event.target.getBoundingClientRect(); const tooltipRect = tooltip.getBoundingClientRect(); const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; // Определяем позицию по умолчанию (справа от миникарточки) let position = 'right'; let left = rect.right + 8; // Всегда центрируем по высоте относительно миникарточки let top = rect.top + (rect.height / 2) - (tooltipRect.height / 2); // Проверяем, помещается ли подсказка справа if (left + tooltipRect.width > viewportWidth - 10) { // Не помещается справа, пробуем слева position = 'left'; left = rect.left - tooltipRect.width - 8; } // Проверяем, помещается ли подсказка по вертикали if (top < 10) { // Не помещается сверху, выравниваем по верху с отступом top = 10; } else if (top + tooltipRect.height > viewportHeight - 10) { // Не помещается снизу, выравниваем по низу с отступом top = viewportHeight - tooltipRect.height - 10; } // Применяем позицию сразу, без анимации tooltip.className = `mini-container-tooltip ${position}`; tooltip.style.left = `${left}px`; tooltip.style.top = `${top}px`; // Принудительно применяем стили без анимации tooltip.style.transition = 'none'; // Сбрасываем transition после применения позиции setTimeout(() => { tooltip.style.transition = ''; }, 10); } // Функции для help modal function showHelpTooltip() { const helpTooltip = document.getElementById('helpTooltip'); if (!helpTooltip) return; // Показываем модальное окно helpTooltip.classList.add('show'); // Блокируем скролл страницы document.body.style.overflow = 'hidden'; } function hideHelpTooltip() { const helpTooltip = document.getElementById('helpTooltip'); if (helpTooltip) { helpTooltip.classList.remove('show'); // Восстанавливаем скролл страницы document.body.style.overflow = ''; } } // Глобальные переменные для выбора контейнеров let lastSelectedContainerId = null; // Функция для диапазонного выбора контейнеров function selectContainerRange(startId, endId) { console.log('selectContainerRange:', startId, 'to', endId); const miniContainerItems = document.querySelectorAll('.mini-container-item'); const containerIds = Array.from(miniContainerItems).map(item => item.getAttribute('data-cid')); const startIndex = containerIds.indexOf(startId); const endIndex = containerIds.indexOf(endId); if (startIndex === -1 || endIndex === -1) { console.error('Container not found in range selection'); return; } const minIndex = Math.min(startIndex, endIndex); const maxIndex = Math.max(startIndex, endIndex); // Выбираем все контейнеры в диапазоне for (let i = minIndex; i <= maxIndex; i++) { const containerId = containerIds[i]; if (!state.selectedContainers.includes(containerId)) { state.selectedContainers.push(containerId); } } // Обновляем UI updateContainerSelectionUI(); updateMultiViewMode(); } // Глобальные обработчики для подсказок document.addEventListener('click', (event) => { const tooltip = document.getElementById('miniContainerTooltip'); if (tooltip && !tooltip.contains(event.target) && !event.target.closest('.mini-container-item')) { hideMiniContainerTooltip(); } }); window.addEventListener('resize', () => { hideMiniContainerTooltip(); hideHelpTooltip(); }); window.addEventListener('scroll', () => { hideMiniContainerTooltip(); hideHelpTooltip(); }); // Обработчик для скрытия подсказки при клике на ссылку document.addEventListener('click', (event) => { if (event.target.closest('.mini-container-tooltip-url')) { // Не скрываем подсказку при клике на ссылку event.stopPropagation(); } }); // Функция для сохранения выбранного контейнера в localStorage function saveSelectedContainer(containerId) { if (containerId) { localStorage.setItem('lb_selected_container', containerId); console.log('Saved selected container to localStorage:', containerId); } else { localStorage.removeItem('lb_selected_container'); console.log('Removed selected container from localStorage'); } } // Функция для восстановления выбранного контейнера из localStorage function getSelectedContainerFromStorage() { const containerId = localStorage.getItem('lb_selected_container'); console.log('Retrieved selected container from localStorage:', containerId); return containerId; } // Функция для сохранения режима просмотра в localStorage function saveViewMode(multiViewMode, selectedContainers) { const viewModeData = { multiViewMode: multiViewMode, selectedContainers: selectedContainers || [] }; localStorage.setItem('lb_view_mode', JSON.stringify(viewModeData)); console.log('Saved view mode to localStorage:', viewModeData); } // Функция для восстановления режима просмотра из localStorage function getViewModeFromStorage() { const viewModeData = localStorage.getItem('lb_view_mode'); if (viewModeData) { try { const data = JSON.parse(viewModeData); console.log('Retrieved view mode from localStorage:', data); return data; } catch (error) { console.error('Error parsing view mode from localStorage:', error); return null; } } return null; } // Функция для сохранения состояния кнопок loglevels в localStorage function saveLogLevelsState() { const levelsData = { globalLevels: state.levels, containerLevels: state.containerLevels }; localStorage.setItem('lb_log_levels', JSON.stringify(levelsData)); console.log('Saved log levels state to localStorage:', levelsData); } // Функция для восстановления состояния кнопок loglevels из localStorage function getLogLevelsStateFromStorage() { const levelsData = localStorage.getItem('lb_log_levels'); if (levelsData) { try { const data = JSON.parse(levelsData); console.log('Retrieved log levels state from localStorage:', data); return data; } catch (error) { console.error('Error parsing log levels state from localStorage:', error); return null; } } return null; } async function updateMultiViewMode() { console.log(`updateMultiViewMode called: selectedContainers.length = ${state.selectedContainers.length}, containers:`, state.selectedContainers); if (state.selectedContainers.length > 1) { state.multiViewMode = true; state.current = null; // Сбрасываем текущий контейнер console.log('Setting up multi-view mode'); // Сохраняем режим просмотра в localStorage saveViewMode(true, state.selectedContainers); await setupMultiView(); } else if (state.selectedContainers.length === 1) { // Переключаемся в single view для одного контейнера console.log('Switching from multi-view to single view'); state.multiViewMode = false; const selectedService = state.services.find(s => s.id === state.selectedContainers[0]); if (selectedService) { // Сохраняем режим просмотра в localStorage saveViewMode(false, [selectedService.id]); // Сохраняем выбранный контейнер в localStorage saveSelectedContainer(selectedService.id); // Обновляем страницу для полного сброса состояния console.log('Refreshing page to switch to single view'); window.location.reload(); return; // Прерываем выполнение, так как страница перезагрузится } } else { // Когда снимаем все галочки, переключаемся в single view state.multiViewMode = false; state.current = null; // Сохраняем режим просмотра в localStorage saveViewMode(false, []); clearLogArea(); // Очищаем область логов и показываем пустое состояние const logArea = document.querySelector('.log-area'); if (logArea) { const logContent = logArea.querySelector('.log-content'); if (logContent) { logContent.innerHTML = '
Выберите контейнер для просмотра логов
'; } } // Очищаем активное состояние всех контейнеров updateActiveContainerUI(null); } // Multi-view mode updated // Сохраняем состояние кнопок loglevels при переключении режимов saveLogLevelsState(); // Обновляем состояние кнопок уровней логирования при переключении режимов setTimeout(() => { initializeLevelButtons(); }, 100); // Обновляем видимость кнопок LogLevels updateLogLevelsVisibility(); } /** * Настраивает интерфейс для режима мультипросмотра (multi-view) * Создает сетку панелей для одновременного просмотра нескольких контейнеров * Открывает WebSocket соединения для всех выбранных контейнеров */ async function setupMultiView() { // Проверяем, что у нас действительно больше одного контейнера if (state.selectedContainers.length <= 1) { if (state.selectedContainers.length === 1) { const selectedService = state.services.find(s => s.id === state.selectedContainers[0]); if (selectedService) { await switchToSingle(selectedService); } } else { clearLogArea(); } return; } // Дополнительная проверка - если уже есть мультипросмотр, удаляем его для пересоздания const existingMultiView = document.getElementById('multiViewGrid'); if (existingMultiView) { existingMultiView.remove(); } const logArea = document.querySelector('.log-area'); if (!logArea) { console.log('Log area not found'); return; } // Очищаем область логов const logContent = logArea.querySelector('.log-content'); if (logContent) { logContent.innerHTML = ''; } // Удаляем single-view-panel если он существует const singleViewPanel = document.getElementById('singleViewPanel'); if (singleViewPanel) { singleViewPanel.remove(); } // Создаем сетку для мультипросмотра const gridContainer = document.createElement('div'); gridContainer.className = 'multi-view-grid'; gridContainer.id = 'multiViewGrid'; // Определяем количество колонок в зависимости от количества контейнеров let columns = 1; if (state.selectedContainers.length === 1) columns = 1; else if (state.selectedContainers.length === 2) columns = 2; else if (state.selectedContainers.length <= 4) columns = 2; else if (state.selectedContainers.length <= 6) columns = 3; else columns = 4; gridContainer.style.gridTemplateColumns = `repeat(${columns}, 1fr)`; // Создаем панели для каждого выбранного контейнера state.selectedContainers.forEach((containerId, index) => { const service = state.services.find(s => s.id === containerId); if (!service) { console.error(`setupMultiView: Service not found for container ID: ${containerId}`); console.error(`setupMultiView: Available service IDs:`, state.services.map(s => s.id)); return; } const panel = createMultiViewPanel(service); gridContainer.appendChild(panel); }); if (logContent) { logContent.appendChild(gridContainer); } else { console.error('setupMultiView: logContent not found'); } // Применяем настройки wrap lines applyWrapSettings(); // Очищаем активное состояние всех контейнеров в мультипросмотре updateActiveContainerUI(null); // Принудительно обновляем стили логов для multi-view setTimeout(() => { updateLogStyles(); // Дополнительная проверка для multi-view логов forceFixMultiViewStyles(); }, 200); // Подключаем WebSocket для каждого контейнера state.selectedContainers.forEach((containerId, index) => { const service = state.services.find(s => s.id === containerId); if (service) { openMultiViewWs(service); } else { console.error(`setupMultiView: Service not found for container ID: ${containerId}`); } }); // Multi-view setup completed // Применяем сохраненный порядок панелей setTimeout(() => { // Сначала очищаем дубликаты, если они есть cleanupDuplicatePanels(); // Затем применяем порядок applyPanelOrder(); }, 100); // Небольшая задержка для завершения создания панелей // Обновляем счетчики для multi view setTimeout(() => { recalculateMultiViewCounters(); }, 1000); // Небольшая задержка для завершения загрузки логов // Применяем стили логов после настройки multi view setTimeout(() => { updateLogStyles(); }, 1500); // Задержка после настройки счетчиков // Обновляем видимость кнопок LogLevels updateLogLevelsVisibility(); } /** * Создает панель для мультипросмотра контейнера * Генерирует HTML структуру с заголовком, кнопками уровней и областью логов * @param {Object} service - Объект сервиса/контейнера * @returns {HTMLElement} Созданная панель мультипросмотра */ function createMultiViewPanel(service) { console.log(`Creating multi-view panel for service: ${service.name} (${service.id})`); const panel = document.createElement('div'); panel.className = 'multi-view-panel'; panel.setAttribute('data-container-id', service.id); panel.innerHTML = `

${escapeHtml(service.name)}

`; // Проверяем, что элемент создался правильно const logElement = panel.querySelector(`.multi-view-log[data-container-id="${service.id}"]`); if (logElement) { // Очищаем пустые строки после создания панели cleanMultiViewEmptyLines(logElement); // Очищаем дублированные строки после создания панели cleanMultiViewDuplicateLines(logElement); } else { console.error(`Failed to create multi-view log element for ${service.name}`); } // Добавляем drag & drop функциональность if (window.setupDragAndDrop) { window.setupDragAndDrop(panel); } else { console.warn('setupDragAndDrop not available yet, will be set up later'); // Устанавливаем drag & drop позже, когда функция будет доступна setTimeout(() => { if (window.setupDragAndDrop) { window.setupDragAndDrop(panel); } }, 100); } // Инициализируем состояние кнопок уровней логирования для этого контейнера setTimeout(() => { initializeLevelButtons(); }, 100); // Multi-view panel created // Применяем стили к новой панели setTimeout(() => { updateLogStyles(); }, 200); return panel; } /** * Открывает WebSocket соединение для контейнера в режиме мультипросмотра * Настраивает обработчики сообщений и управляет отображением логов * @param {Object} service - Объект сервиса/контейнера */ function openMultiViewWs(service) { const containerId = service.id; // Закрываем существующее соединение только если оно действительно существует const existingConnection = state.open[containerId]; if (existingConnection && existingConnection.ws) { closeWs(containerId); // Добавляем небольшую задержку перед созданием нового соединения setTimeout(() => { createWebSocketConnection(service, containerId); }, 100); return; } // Создаем новое WebSocket соединение createWebSocketConnection(service, containerId); } function createWebSocketConnection(service, containerId) { const ws = new WebSocket(wsUrl(containerId, service.service, service.project)); ws.onopen = () => { const logEl = document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`); if (logEl) { // Убираем сообщение "Connected..." для MultiView режима logEl.textContent = ''; // Очищаем пустые строки после установки соединения setTimeout(() => { cleanMultiViewEmptyLines(logEl); cleanMultiViewDuplicateLines(logEl); }, 100); } }; ws.onmessage = (event) => { // Устанавливаем состояние 'on' при получении сообщений setWsState('on'); const parts = (event.data||'').split(/\r?\n/); // Проверяем на дублирование в исходных данных if (event.data.includes('FoundINFO:')) { // FoundINFO detected in WebSocket data } // Проверяем на дублирование строк и убираем дубликаты const lines = event.data.split(/\r?\n/).filter(line => line.trim().length > 0); const uniqueLines = [...new Set(lines)]; if (lines.length !== uniqueLines.length) { // Дублирование строк обнаружено, используем только уникальные строки const uniqueParts = uniqueLines.map(line => line.trim()).filter(line => line.length > 0); for (let i=0;i { // WebSocket закрыт }; ws.onerror = (error) => { console.error(`Multi-view WebSocket error for ${service.name}:`, error); }; // Сохраняем соединение с полным набором полей как в openWs const logEl = document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`); state.open[containerId] = { ws: ws, serviceName: service.service, logEl: logEl, wrapEl: logEl, counters: {dbg:0, info:0, warn:0, err:0, other:0}, pausedBuffer: [], allLogs: [] // Добавляем буфер для логов }; // Также сохраняем WebSocket в wsConnections для проверки в applyPanelOrder state.wsConnections[containerId] = ws; } function clearLogArea() { console.log('clearLogArea called'); // Очищаем мультипросмотр если он был активен if (state.multiViewMode) { console.log('Clearing multi-view grid'); const multiViewGrid = document.getElementById('multiViewGrid'); if (multiViewGrid) { multiViewGrid.remove(); } } const logContent = document.querySelector('.log-content'); if (logContent) { logContent.innerHTML = `

No container selected

No container selected
`; } const logTitle = document.getElementById('logTitle'); if (logTitle) { logTitle.textContent = 'LogBoard+'; } // Обновляем видимость кнопок LogLevels updateLogLevelsVisibility(); } function applyWrapSettings() { const wrapEnabled = els.wrapToggle && els.wrapToggle.checked; const wrapStyle = wrapEnabled ? 'pre-wrap' : 'pre'; // Применяем к обычному просмотру (только к логам в основном контенте) document.querySelectorAll('.main-content .log').forEach(el => { el.style.whiteSpace = wrapStyle; }); // Применяем к мультипросмотру document.querySelectorAll('.multi-view-content .multi-view-log').forEach(el => { el.style.whiteSpace = wrapStyle; }); // Применяем к single-view document.querySelectorAll('.single-view-content .log').forEach(el => { el.style.whiteSpace = wrapStyle; }); } async function fetchServices(){ try { console.log('Fetching services...'); const url = new URL(location.origin + '/api/containers/services'); const selectedProjects = getSelectedProjects(); // Если выбраны конкретные проекты (не "all"), добавляем их в URL как строку через запятую if (selectedProjects.length > 0 && !selectedProjects.includes('all')) { url.searchParams.set('projects', selectedProjects.join(',')); } const token = localStorage.getItem('access_token'); if (!token) { console.error('No access token found'); window.location.href = '/login'; return; } const res = await fetch(url, { headers: { 'Authorization': `Bearer ${token}` } }); if (!res.ok){ if (res.status === 401) { console.error('Unauthorized, redirecting to login'); window.location.href = '/login'; return; } console.error('Auth failed (HTTP):', res.status, res.statusText); alert('Auth failed (HTTP)'); return; } const data = await res.json(); console.log('Services loaded:', data); state.services = data; buildTabs(); // Проверяем, нужно ли пропустить восстановление (например, после автоматического обновления) const skipRestore = localStorage.getItem('lb_skip_restore'); if (skipRestore === 'true') { console.log('Skipping panel restoration due to lb_skip_restore flag'); localStorage.removeItem('lb_skip_restore'); return; } // Восстанавливаем режим просмотра из localStorage const savedViewMode = getViewModeFromStorage(); if (savedViewMode) { console.log('Restoring view mode from localStorage:', savedViewMode); if (savedViewMode.multiViewMode && savedViewMode.selectedContainers.length > 1) { // Восстанавливаем Multi View режим console.log('Restoring Multi View mode with containers:', savedViewMode.selectedContainers); state.multiViewMode = true; state.selectedContainers = savedViewMode.selectedContainers; // Отмечаем чекбоксы для выбранных контейнеров savedViewMode.selectedContainers.forEach(containerId => { const checkbox = document.querySelector(`.container-checkbox[data-container-id="${containerId}"]`); if (checkbox) { checkbox.checked = true; const containerItem = checkbox.closest('.container-item'); if (containerItem) { containerItem.classList.add('selected'); } } }); // Настраиваем Multi View await setupMultiView(); // Проверяем настройку автоматического обновления логов при восстановлении панелей const autoRefreshOnRestore = localStorage.getItem('lb_auto_refresh_on_restore'); if (autoRefreshOnRestore === 'true') { console.log('Auto-refresh logs on restore is enabled, refreshing logs in 1 second...'); setTimeout(() => { // Обновляем логи панелей вместо обновления страницы refreshLogsAndCounters(); console.log('Logs refreshed after panel restoration'); // Дополнительная прокрутка через небольшую задержку setTimeout(() => { scrollToBottom(); }, 1500); }, 1000); } else { // Если это восстановление из localStorage, проверяем через некоторое время // нужно ли обновить страницу для корректной работы обработчиков setTimeout(() => { const hasLevelButtons = document.querySelectorAll('.level-btn').length > 0; if (hasLevelButtons) { console.log('Panel restoration completed, checking event handlers in 2 seconds...'); setTimeout(() => { // Простая проверка - если кнопки есть, но клики не работают, обновляем страницу const testButton = document.querySelector('.level-btn'); if (testButton) { // Симулируем клик для проверки const clickEvent = new MouseEvent('click', { bubbles: true }); const originalHandler = testButton.onclick; // Временно устанавливаем обработчик для проверки testButton.onclick = () => { testButton.onclick = originalHandler; }; testButton.dispatchEvent(clickEvent); // Если через 100ms обработчик не сработал, обновляем логи setTimeout(() => { if (testButton.onclick && testButton.onclick.toString().includes('testButton.onclick = originalHandler')) { refreshLogsAndCounters(); } }, 100); } }, 2000); } }, 1000); } } else if (savedViewMode.selectedContainers.length === 1) { // Восстанавливаем Single View режим console.log('Restoring Single View mode for container:', savedViewMode.selectedContainers[0]); state.multiViewMode = false; const selectedService = state.services.find(s => s.id === savedViewMode.selectedContainers[0]); if (selectedService) { await switchToSingle(selectedService); } } else { // Нет сохраненного режима, не открываем автоматически первый контейнер console.log('No saved view mode, not auto-opening first container'); // Пользователь сам выберет нужный контейнер } } else { // Нет сохраненного режима, не открываем автоматически первый контейнер console.log('No saved view mode found, not auto-opening first container'); // Пользователь сам выберет нужный контейнер } // Добавляем обработчики для счетчиков после загрузки сервисов addCounterClickHandlers(); } catch (error) { console.error('Error fetching services:', error); } } function wsUrl(containerId, service, project){ const proto = location.protocol === 'https:' ? 'wss' : 'ws'; const tail = els.tail.value || '500'; const token = encodeURIComponent(localStorage.getItem('access_token') || ''); const sp = service?`&service=${encodeURIComponent(service)}`:''; const pj = project?`&project=${encodeURIComponent(project)}`:''; if (els.aggregate && els.aggregate.checked && service){ // fan-in by service return `${proto}://${location.host}/api/websocket/fan/${encodeURIComponent(service)}?tail=${tail}&token=${token}${pj}`; } return `${proto}://${location.host}/api/websocket/logs/${encodeURIComponent(containerId)}?tail=${tail}&token=${token}${sp}${pj}`; } /** * Закрывает WebSocket соединение для контейнера * @param {string} id - ID контейнера */ function closeWs(id){ const o = state.open[id]; if (!o) return; try { o.ws.close(); } catch(e){} delete state.open[id]; } /** * Создает и скачивает снимок логов контейнера * В режиме мультипросмотра создает отдельные файлы для каждого контейнера * @param {string} id - ID контейнера */ async function sendSnapshot(id){ const o = state.open[id]; if (!o){ alert('not open'); return; } const token = localStorage.getItem('access_token'); if (!token) { console.error('No access token found'); window.location.href = '/login'; return; } if (state.multiViewMode && state.selectedContainers.length > 0) { // В Multi View режиме создаем отдельный файл для каждого контейнера console.log('Creating snapshots for Multi View mode with containers:', state.selectedContainers); let hasLogs = false; // Создаем отдельный файл для каждого выбранного контейнера for (const containerId of state.selectedContainers) { const multiViewLog = document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`); if (multiViewLog && multiViewLog.textContent.trim()) { const service = state.services.find(s => s.id === containerId); const serviceName = service ? (service.service || service.name) : containerId; const text = multiViewLog.textContent; console.log(`Saving snapshot for ${serviceName} with content length:`, text.length); const payload = {container_id: containerId, service: serviceName, content: text}; try { const res = await fetch('/api/logs/snapshot', { method:'POST', headers:{ 'Content-Type':'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify(payload) }); if (!res.ok){ if (res.status === 401) { console.error('Unauthorized, redirecting to login'); window.location.href = '/login'; return; } console.error(`Snapshot failed for ${serviceName}:`, res.status, res.statusText); alert(`snapshot failed for ${serviceName}`); return; } const js = await res.json(); const a = document.createElement('a'); a.href = js.url; a.download = js.file; a.click(); hasLogs = true; // Небольшая задержка между скачиваниями файлов await new Promise(resolve => setTimeout(resolve, 100)); } catch (error) { console.error(`Error saving snapshot for ${serviceName}:`, error); alert(`Error saving snapshot for ${serviceName}`); return; } } } if (!hasLogs) { alert('No logs to save in Multi View mode'); return; } } else { // Обычный режим просмотра let text = ''; if (state.current && state.current.id === id && els.logContent) { text = els.logContent.textContent; } else if (o.logEl) { text = o.logEl.textContent; } if (!text || text.trim() === '') { alert('No logs to save'); return; } console.log('Saving snapshot with content length:', text.length); const serviceName = o.serviceName || id; const payload = {container_id: id, service: serviceName, content: text}; const res = await fetch('/api/logs/snapshot', { method:'POST', headers:{ 'Content-Type':'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify(payload) }); if (!res.ok){ if (res.status === 401) { console.error('Unauthorized, redirecting to login'); window.location.href = '/login'; return; } console.error('Snapshot failed:', res.status, res.statusText); alert('snapshot failed'); return; } const js = await res.json(); const a = document.createElement('a'); a.href = js.url; a.download = js.file; a.click(); } } /** * Открывает WebSocket соединение для контейнера * Настраивает обработчики событий и управляет отображением логов * @param {Object} svc - Объект сервиса/контейнера * @param {HTMLElement} panel - Панель для отображения логов */ function openWs(svc, panel){ const id = svc.id; const logEl = panel.querySelector('.log'); const wrapEl = panel.querySelector('.logwrap'); // Ищем счетчики в panel или в глобальных элементах const cdbg = panel.querySelector('.cdbg') || document.querySelector('.cdbg'); const cinfo = panel.querySelector('.cinfo') || document.querySelector('.cinfo'); const cwarn = panel.querySelector('.cwarn') || document.querySelector('.cwarn'); const cerr = panel.querySelector('.cerr') || document.querySelector('.cerr'); const cother = panel.querySelector('.cother') || document.querySelector('.cother'); const counters = {dbg:0,info:0,warn:0,err:0,other:0}; const ws = new WebSocket(wsUrl(id, (svc.service||svc.name), svc.project||'')); state.open[id] = {ws, logEl, wrapEl, counters, pausedBuffer:[], serviceName: (svc.service||svc.name)}; ws.onopen = ()=> { setWsState('on'); // Очищаем сообщение "Connecting..." когда соединение установлено if (state.current && state.current.id === id && els.logContent) { els.logContent.innerHTML = ''; } // Также очищаем legacy элемент лога const current = state.open[id]; if (current && current.logEl) { current.logEl.innerHTML = ''; } // Принудительно проверяем состояние через AJAX через 500мс и 1 секунду setTimeout(() => { checkWebSocketStatus(); }, 500); setTimeout(() => { checkWebSocketStatus(); }, 1000); }; ws.onclose = ()=> { setWsState(determineWsState()); // Принудительно проверяем состояние через AJAX через 500мс setTimeout(() => { checkWebSocketStatus(); }, 500); }; ws.onerror = (error)=> { setWsState('err'); }; ws.onmessage = (ev)=>{ // Устанавливаем состояние 'on' при получении сообщений setWsState('on'); const parts = (ev.data||'').split(/\r?\n/); // Проверяем на дублирование в исходных данных для Single View if (ev.data.includes('FoundINFO:')) { // FoundINFO detected in Single View WebSocket data } // Проверяем на дублирование строк и убираем дубликаты const lines = ev.data.split(/\r?\n/).filter(line => line.trim().length > 0); const uniqueLines = [...new Set(lines)]; if (lines.length !== uniqueLines.length) { // Дублирование строк обнаружено в Single View, используем только уникальные строки const uniqueParts = uniqueLines.map(line => line.trim()).filter(line => line.length > 0); for (let i=0;i { const textContent = line.textContent || line.innerText || ''; if (textContent.trim() === '') { line.remove(); removedCount++; } }); // Удаляем все текстовые узлы, которые содержат только пробелы и переносы строк const walker = document.createTreeWalker( multiViewLog, NodeFilter.SHOW_TEXT, null, false ); const textNodesToRemove = []; let node; while (node = walker.nextNode()) { const content = node.textContent; // Удаляем все узлы, которые содержат только пробелы, переносы строк или табуляцию if (content.trim() === '') { textNodesToRemove.push(node); } } textNodesToRemove.forEach(node => node.remove()); // Удаляем все пустые текстовые узлы между элементами .line const allNodes = Array.from(multiViewLog.childNodes); for (let i = allNodes.length - 1; i >= 0; i--) { const node = allNodes[i]; if (node.nodeType === Node.TEXT_NODE && node.textContent.trim() === '') { node.remove(); } } if (removedCount > 0) { console.log(`cleanMultiViewEmptyLines: Удалено ${removedCount} пустых строк`); } } /** * Функция для очистки дублированных строк в multi view * Удаляет последовательные дублированные строки * @param {HTMLElement} multiViewLog - элемент лога multi view */ function cleanMultiViewDuplicateLines(multiViewLog) { if (!multiViewLog) return; const lines = Array.from(multiViewLog.querySelectorAll('.line')); let removedCount = 0; // Проходим по строкам с конца, чтобы не нарушить индексы for (let i = lines.length - 1; i > 0; i--) { const currentLine = lines[i]; const previousLine = lines[i - 1]; if (currentLine && previousLine) { const currentText = currentLine.textContent || currentLine.innerText || ''; const previousText = previousLine.textContent || previousLine.innerText || ''; // Удаляем дублированные строки (включая пустые) if (currentText.trim() === previousText.trim()) { console.log(`cleanMultiViewDuplicateLines: Удаляем дублированную строку: ${currentText.substring(0, 50)}...`); currentLine.remove(); removedCount++; } } } // После удаления дубликатов очищаем лишние пустые строки cleanMultiViewEmptyLines(multiViewLog); if (removedCount > 0) { console.log(`cleanMultiViewDuplicateLines: Удалено ${removedCount} дублированных строк`); } } /** * Универсальная функция для очистки дублированных строк * Работает как для Single View, так и для MultiView * @param {HTMLElement} logElement - элемент лога (любого типа) */ function cleanDuplicateLines(logElement) { if (!logElement) return; const lines = Array.from(logElement.querySelectorAll('.line')); let removedCount = 0; // Проходим по строкам с конца, чтобы не нарушить индексы for (let i = lines.length - 1; i > 0; i--) { const currentLine = lines[i]; const previousLine = lines[i - 1]; if (currentLine && previousLine) { const currentText = currentLine.textContent || currentLine.innerText || ''; const previousText = previousLine.textContent || previousLine.innerText || ''; // Удаляем дублированные строки (включая пустые) if (currentText.trim() === previousText.trim()) { currentLine.remove(); removedCount++; } } } // После удаления дубликатов очищаем лишние пустые строки if (logElement.classList.contains('multi-view-log')) { cleanMultiViewEmptyLines(logElement); } else { cleanSingleViewEmptyLines(logElement); } // Дублированные строки удалены } /** * Функция для радикальной очистки пустых строк в Single View * Удаляет все пустые строки и лишние переносы строк * @param {HTMLElement} logElement - элемент лога Single View */ function cleanSingleViewEmptyLines(logElement) { if (!logElement) return; let removedCount = 0; // Удаляем все пустые строки (элементы .line без текста) const lines = Array.from(logElement.querySelectorAll('.line')); lines.forEach(line => { const textContent = line.textContent || line.innerText || ''; if (textContent.trim() === '') { line.remove(); removedCount++; } }); // Удаляем все текстовые узлы, которые содержат только пробелы и переносы строк const walker = document.createTreeWalker( logElement, NodeFilter.SHOW_TEXT, null, false ); const textNodesToRemove = []; let node; while (node = walker.nextNode()) { const content = node.textContent; // Удаляем все узлы, которые содержат только пробелы, переносы строк или табуляцию if (content.trim() === '') { textNodesToRemove.push(node); } } textNodesToRemove.forEach(node => node.remove()); // Удаляем все пустые текстовые узлы между элементами .line const allNodes = Array.from(logElement.childNodes); for (let i = allNodes.length - 1; i >= 0; i--) { const node = allNodes[i]; if (node.nodeType === Node.TEXT_NODE && node.textContent.trim() === '') { node.remove(); } } if (removedCount > 0) { console.log(`cleanSingleViewEmptyLines: Удалено ${removedCount} пустых строк`); } } /** * Функция для нормализации пробелов в логах * Заменяет множественные пробелы на один пробел * @param {string} text - исходный текст * @returns {string} - текст с нормализованными пробелами */ function normalizeSpaces(text) { if (!text) return text; // Заменяем множественные пробелы на один пробел // Используем регулярное выражение для замены 2+ пробелов на один return text.replace(/\s{2,}/g, ' '); } /** * Функция для периодической очистки пустых строк * Вызывается автоматически каждые 2 секунды для поддержания чистоты логов */ function periodicCleanup() { // Очищаем пустые строки в Single View if (!state.multiViewMode && els.logContent) { cleanSingleViewEmptyLines(els.logContent); cleanDuplicateLines(els.logContent); } // Очищаем пустые строки в мультипросмотре if (state.multiViewMode) { state.selectedContainers.forEach(containerId => { const multiViewLog = document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`); if (multiViewLog) { cleanMultiViewEmptyLines(multiViewLog); cleanMultiViewDuplicateLines(multiViewLog); } }); } // Очищаем пустые строки в legacy панелях Object.values(state.open).forEach(obj => { if (obj.logEl) { cleanSingleViewEmptyLines(obj.logEl); cleanDuplicateLines(obj.logEl); } }); } // Запускаем периодическую очистку каждые 2 секунды setInterval(periodicCleanup, 2000); // Запускаем периодическую проверку стилей multi-view логов каждые 5 секунд setInterval(() => { if (state.multiViewMode) { const multiViewLogs = document.querySelectorAll('.multi-view-content .multi-view-log'); if (multiViewLogs.length > 0) { forceFixMultiViewStyles(); // Дополнительно исправляем все контейнеры if (window.fixAllContainers) { window.fixAllContainers(); } } } }, 5000); /** * Функция для обработки специальных замен в MultiView логах * Выполняет специфичные замены для улучшения читаемости логов * @param {string} text - исходный текст * @returns {string} - текст с примененными заменами */ function processMultiViewSpecialReplacements(text) { if (!text) return text; let processedText = text; // Добавляем отладочную информацию для проверки if (text.includes('FoundINFO:')) { // FoundINFO detected in processMultiViewSpecialReplacements } // Проверяем на дублирование строк в исходном тексте const lines = processedText.split('\n'); const uniqueLines = [...new Set(lines)]; if (lines.length !== uniqueLines.length) { console.log('🚨 processMultiViewSpecialReplacements: Обнаружено дублирование в исходном тексте!'); console.log('🚨 Исходные строки:', lines.length, 'Уникальные строки:', uniqueLines.length); // Убираем дублированные строки processedText = uniqueLines.join('\n'); } // Заменяем случаи, где INFO: прилипает к предыдущему тексту // Ищем паттерн: любой текст + INFO: (но не в начале строки) // Используем более точное регулярное выражение для поиска processedText = processedText.replace(/([A-Za-z0-9\s]+)INFO:/g, '$1\nINFO:'); // Убираем лишние переносы строк в начале, если они есть processedText = processedText.replace(/^\n+/, ''); // Проверяем результат if (text.includes('FoundINFO:') && processedText !== text) { console.log('✅ processMultiViewSpecialReplacements: Замена выполнена:', processedText); } else if (text.includes('FoundINFO:') && processedText === text) { console.log('❌ processMultiViewSpecialReplacements: Замена НЕ выполнена для:', text); } return processedText; } /** * Функция для обработки специальных замен в Single View логах * Не добавляет лишние переносы строк * @param {string} text - исходный текст * @returns {string} - текст с примененными заменами */ function processSingleViewSpecialReplacements(text) { if (!text) return text; let processedText = text; // Добавляем отладочную информацию для проверки if (text.includes('FoundINFO:')) { // FoundINFO detected in processSingleViewSpecialReplacements } // Проверяем на дублирование строк в исходном тексте const lines = processedText.split('\n'); const uniqueLines = [...new Set(lines)]; if (lines.length !== uniqueLines.length) { console.log('🚨 processSingleViewSpecialReplacements: Обнаружено дублирование в исходном тексте!'); console.log('🚨 Исходные строки:', lines.length, 'Уникальные строки:', uniqueLines.length); // Убираем дублированные строки processedText = uniqueLines.join('\n'); } // Для Single View НЕ добавляем переносы строк, только убираем дубликаты // Убираем лишние переносы строк в начале, если они есть processedText = processedText.replace(/^\n+/, ''); // Проверяем результат if (text.includes('FoundINFO:') && processedText !== text) { console.log('✅ processSingleViewSpecialReplacements: Замена выполнена:', processedText); } else if (text.includes('FoundINFO:') && processedText === text) { console.log('❌ processSingleViewSpecialReplacements: Замена НЕ выполнена для:', text); } return processedText; } // Тестовая функция для проверки работы processMultiViewLineBreaks (убрана для снижения шума в консоли) function testMultiViewLineBreaks() { // Функция оставлена для совместимости, но логи убраны } // Функция для тестирования исправлений дублирования function testDuplicateRemoval() { // Функция оставлена для совместимости, но логи убраны } // Функция для тестирования Single View дублирования (убрана для снижения шума в консоли) function testSingleViewDuplicateRemoval() { // Функция оставлена для совместимости, но логи убраны } // Функция для тестирования очистки пустых строк в Single View (убрана для снижения шума в консоли) function testSingleViewEmptyLinesRemoval() { // Функция оставлена для совместимости, но логи убраны } // Функция для тестирования правильного отображения переносов строк function testSingleViewLineBreaks() { // Функция оставлена для совместимости, но логи убраны } // Тестовая функция для проверки работы cleanMultiViewEmptyLines function testCleanMultiViewEmptyLines() { // Функция оставлена для совместимости, но логи убраны } // Тестовая функция для проверки работы normalizeSpaces function testNormalizeSpaces() { // Функция оставлена для совместимости, но логи убраны } // Тестовая функция для проверки работы processMultiViewSpecialReplacements function testMultiViewSpecialReplacements() { // Функция оставлена для совместимости, но логи убраны console.log('Тест 1 (обычная строка):', JSON.stringify(processMultiViewSpecialReplacements('Hello World'))); console.log('Тест 2 (200 OKINFO:):', JSON.stringify(processMultiViewSpecialReplacements('200 OKINFO: Some message'))); console.log('Тест 3 (404 Not FoundINFO:):', JSON.stringify(processMultiViewSpecialReplacements('404 Not FoundINFO: Some message'))); console.log('Тест 4 (FoundINFO:):', JSON.stringify(processMultiViewSpecialReplacements('FoundINFO: Some message'))); console.log('Тест 5 (Found INFO:):', JSON.stringify(processMultiViewSpecialReplacements('Found INFO: Some message'))); console.log('Тест 6 (500 Internal Server ErrorINFO:):', JSON.stringify(processMultiViewSpecialReplacements('500 Internal Server ErrorINFO: Some message'))); console.log('Тест 7 (GET /api/usersINFO:):', JSON.stringify(processMultiViewSpecialReplacements('GET /api/usersINFO: Some message'))); console.log('Тест 8 (POST /api/loginINFO:):', JSON.stringify(processMultiViewSpecialReplacements('POST /api/loginINFO: Some message'))); console.log('Тест 9 (INFO: в начале):', JSON.stringify(processMultiViewSpecialReplacements('INFO: Some message'))); console.log('Тест 10 (несколько INFO:):', JSON.stringify(processMultiViewSpecialReplacements('200 OKINFO: First INFO: Second'))); console.log('Тест 11 (пустая строка):', JSON.stringify(processMultiViewSpecialReplacements(''))); console.log('Тест 12 (null):', JSON.stringify(processMultiViewSpecialReplacements(null))); console.log('=== Конец теста ==='); } // Комплексная тестовая функция для проверки полного процесса обработки MultiView function testFullMultiViewProcessing() { console.log('=== Тест полного процесса обработки MultiView ==='); const testCases = [ '200 OKINFO: Some message', '404 Not FoundINFO: Another message', '500 Internal Server ErrorINFO: Third message', 'FoundINFO: First FoundINFO: Second', 'GET /api/usersINFO: API call', 'POST /api/loginINFO: Login attempt', 'Short', 'Long message with 200 OKINFO: inside' ]; testCases.forEach((testCase, index) => { console.log(`\nТест ${index + 1}: "${testCase}"`); // 1. Нормализация пробелов const normalized = normalizeSpaces(testCase); console.log(' 1. Нормализация пробелов:', JSON.stringify(normalized)); // 2. Специальные замены const specialProcessed = processMultiViewSpecialReplacements(normalized); console.log(' 2. Специальные замены:', JSON.stringify(specialProcessed)); // 3. Обработка переноса строк const finalProcessed = processMultiViewLineBreaks(specialProcessed); console.log(' 3. Перенос строк:', JSON.stringify(finalProcessed)); console.log(' Результат:', finalProcessed); }); console.log('\n=== Конец комплексного теста ==='); } // Быстрая функция для тестирования замены INFO: function quickTestINFO() { console.log('=== Быстрый тест замены INFO: ==='); const testStrings = [ 'INFO: 127.0.0.1:53352 - "GET /health HTTP/1.1" 200 OKINFO: 127.0.0.1:53352 - "GET /health HTTP/1.1" 200 OK', 'INFO: 127.0.0.1:48988 - "GET /health HTTP/1.1" 200 OKINFO: 127.0.0.1:48988 - "GET /health HTTP/1.1" 200 OK', 'INFO: 127.0.0.1:44606 - "GET /health HTTP/1.1" 200 OKINFO: 127.0.0.1:44606 - "GET /health HTTP/1.1" 200 OK', '200 OKINFO:', '404 Not FoundINFO:', '500 Internal Server ErrorINFO:', 'FoundINFO:', 'INFO:' ]; testStrings.forEach((str, index) => { const result = processMultiViewSpecialReplacements(str); console.log(`Тест ${index + 1}: "${str}" -> "${result}"`); }); console.log('=== Конец быстрого теста ==='); } // Функция для тестирования регулярного выражения function testRegex() { console.log('=== Тест регулярного выражения ==='); const testString = 'INFO: 172.22.0.10:33860 - "POST /api/v1/auth/verify HTTP/1.1" 404 Not FoundINFO: 172.22.0.10:33860 - "POST /api/v1/auth/verify HTTP/1.1" 404 Not Found'; console.log('Исходная строка:', testString); // Тестируем наше регулярное выражение const result = testString.replace(/([A-Za-z0-9\s]+)INFO:/g, '$1\nINFO:'); console.log('Результат замены:', result); // Проверяем, есть ли совпадения const matches = testString.match(/([A-Za-z0-9\s]+)INFO:/g); console.log('Найденные совпадения:', matches); console.log('=== Конец теста регулярного выражения ==='); } // Функция для проверки HTML в MultiView на наличие FoundINFO: function checkMultiViewHTML() { console.log('=== Проверка HTML в MultiView ==='); const multiViewLogs = document.querySelectorAll('.multi-view-log'); console.log('Найдено MultiView логов:', multiViewLogs.length); multiViewLogs.forEach((log, index) => { const containerId = log.getAttribute('data-container-id'); console.log(`MultiView ${index + 1} (${containerId}):`); // Проверяем весь HTML const html = log.innerHTML; if (html.includes('FoundINFO:')) { console.log('🚨 НАЙДЕНО FoundINFO: в HTML!'); console.log('HTML:', html); } else { console.log('✅ FoundINFO: не найдено в HTML'); } // Проверяем текстовое содержимое const textContent = log.textContent; if (textContent.includes('FoundINFO:')) { console.log('🚨 НАЙДЕНО FoundINFO: в тексте!'); console.log('Текст:', textContent); } else { console.log('✅ FoundINFO: не найдено в тексте'); } }); console.log('=== Конец проверки HTML ==='); } /** * Основная функция обработки строк логов * Классифицирует, фильтрует и отображает строки логов в зависимости от режима * @param {string} id - ID контейнера * @param {string} line - Строка лога для обработки */ function handleLine(id, line){ const obj = state.open[id]; if (!obj) { console.error(`handleLine: Object not found for container ${id}, available containers:`, Object.keys(state.open)); return; } // Отладочная информация для первых нескольких строк if (!obj.counters) { console.error(`handleLine: Counters not initialized for container ${id}`); obj.counters = {dbg:0, info:0, warn:0, err:0, other:0}; } // Фильтруем сообщение "Connected to container" для всех режимов // Это сообщение отправляется сервером при установке WebSocket соединения if (line.includes('Connected to container:')) { return; // Пропускаем это сообщение во всех режимах } // Нормализуем пробелы в строке лога const normalizedLine = normalizeSpaces(line); const cls = classify(normalizedLine); // Обновляем счетчики только для отображаемых логов // Проверяем фильтры для отображения в зависимости от режима let shouldShow; if (state.multiViewMode && state.selectedContainers.includes(id)) { // Для multi-view используем настройки конкретного контейнера shouldShow = allowedByContainerLevel(cls, id) && applyFilter(normalizedLine); } else { // Для single-view используем глобальные настройки shouldShow = allowedByLevel(cls) && applyFilter(normalizedLine); } // Обновляем счетчики только если строка будет отображаться if (obj.counters && shouldShow) { if (cls==='dbg') obj.counters.dbg++; if (cls==='ok') obj.counters.info++; if (cls==='warn') obj.counters.warn++; if (cls==='err') obj.counters.err++; if (cls==='other') obj.counters.other++; } // Обновляем счетчики в кнопках заголовков updateHeaderCounters(id, obj.counters); // Для Single View НЕ добавляем перенос строки после каждой строки лога const html = `${ansiToHtml(normalizedLine)}`; // Сохраняем все логи в буфере (всегда) if (!obj.allLogs) obj.allLogs = []; // Для Single View сохраняем обработанную строку, для MultiView - оригинальную const processedLine = !state.multiViewMode ? processSingleViewSpecialReplacements(normalizedLine) : normalizedLine; const processedHtml = `${ansiToHtml(processedLine)}`; obj.allLogs.push({html: processedHtml, line: processedLine, cls: cls}); // Ограничиваем размер буфера if (obj.allLogs.length > 10000) { obj.allLogs = obj.allLogs.slice(-5000); } // Добавляем логи в отображение (обычный просмотр) - только если НЕ в multi-view режиме if (shouldShow && obj.logEl && !state.multiViewMode) { // Обрабатываем строку для Single View (без лишних переносов строк) const singleViewProcessedLine = processSingleViewSpecialReplacements(normalizedLine); // Проверяем на дублирование в Single View логах const existingLines = Array.from(obj.logEl.querySelectorAll('.line')); const lastLine = existingLines[existingLines.length - 1]; if (lastLine && lastLine.textContent === singleViewProcessedLine) { return; // Пропускаем дублированную строку } // Создаем HTML с обработанной строкой для Single View (без переноса строки) const singleViewHtml = `${ansiToHtml(singleViewProcessedLine)}`; obj.logEl.insertAdjacentHTML('beforeend', singleViewHtml); // Очищаем лишние пустые строки в Single View cleanSingleViewEmptyLines(obj.logEl); if (els.autoscroll && els.autoscroll.checked && obj.wrapEl) { obj.wrapEl.scrollTop = obj.wrapEl.scrollHeight; } // Update modern interface if (state.current && state.current.id === id && els.logContent) { // Добавляем новую строку напрямую в современный интерфейс els.logContent.insertAdjacentHTML('beforeend', singleViewHtml); // Очищаем лишние пустые строки в современном интерфейсе cleanSingleViewEmptyLines(els.logContent); if (els.autoscroll && els.autoscroll.checked) { els.logContent.scrollTop = els.logContent.scrollHeight; } } } // Update multi-view interface if (state.multiViewMode && state.selectedContainers.includes(id)) { const multiViewLog = document.querySelector(`.multi-view-log[data-container-id="${id}"]`); if (multiViewLog) { // Проверяем фильтры для конкретного контейнера const shouldShowInMultiView = allowedByContainerLevel(cls, id) && applyFilter(normalizedLine); if (shouldShowInMultiView) { // Применяем ограничение tail lines в multi view const tailLines = parseInt(els.tail.value) || 50; // Порядок обработки строк для MultiView: // 1. Нормализация пробелов (уже выполнена выше) // 2. Специальные замены (например, "FoundINFO:" -> "Found\nINFO:") // 3. Обработка переноса строк const specialProcessedLine = processMultiViewSpecialReplacements(normalizedLine); // Обрабатываем перенос строк для multi view // Если символов больше 5, то перенос строк работает // Если меньше 5, то переноса строк нет const processedLine = processMultiViewLineBreaks(specialProcessedLine); // Проверяем на дублирование в multi-view логах const existingLines = Array.from(multiViewLog.querySelectorAll('.line')); const lastLine = existingLines[existingLines.length - 1]; if (lastLine && lastLine.textContent === processedLine) { return; // Пропускаем дублированную строку } const multiViewHtml = `${ansiToHtml(processedLine)}`; // Добавляем новую строку multiViewLog.insertAdjacentHTML('beforeend', multiViewHtml); // Очищаем пустые строки в multi view cleanMultiViewEmptyLines(multiViewLog); // Очищаем дублированные строки в multi view cleanMultiViewDuplicateLines(multiViewLog); // Ограничиваем количество отображаемых строк const logLines = Array.from(multiViewLog.querySelectorAll('.line')); if (logLines.length > tailLines) { // Удаляем лишние строки с начала const linesToRemove = logLines.length - tailLines; // Удаляем первые N строк logLines.slice(0, linesToRemove).forEach(line => { line.remove(); }); } if (els.autoscroll && els.autoscroll.checked) { multiViewLog.scrollTop = multiViewLog.scrollHeight; } } } else { console.error(`handleLine: Multi-view log element not found for container ${id}`); } // Обновляем счетчики в multi view периодически (каждые 10 строк) if (!state.multiViewCounterUpdateTimer) { state.multiViewCounterUpdateTimer = setTimeout(() => { updateMultiViewCounters(); // Периодически очищаем дублированные строки во всех multi-view логах state.selectedContainers.forEach(containerId => { const multiViewLog = document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`); if (multiViewLog) { cleanMultiViewDuplicateLines(multiViewLog); } }); state.multiViewCounterUpdateTimer = null; }, 1000); // Обновляем каждую секунду } } } function ensurePanel(svc){ let panel = els.grid.querySelector(`.panel[data-cid="${svc.id}"]`); if (!panel){ panel = panelTemplate(svc); els.grid.appendChild(panel); panel.querySelector('.t-reconnect').onclick = ()=>{ const id = svc.id; const o = state.open[id]; if (o){ o.logEl.textContent=''; closeWs(id); } openWs(svc, panel); }; panel.querySelector('.t-close').onclick = ()=>{ closeWs(svc.id); panel.remove(); if (!Object.keys(state.open).length) setWsState('off'); }; panel.querySelector('.t-snapshot').onclick = ()=> sendSnapshot(svc.id); } return panel; } /** * Переключает интерфейс в режим одиночного просмотра (single view) * Закрывает мультипросмотр, открывает WebSocket для выбранного контейнера * @param {Object} svc - Объект сервиса/контейнера для просмотра */ async function switchToSingle(svc){ try { // Всегда очищаем мультипросмотр при переключении в single view console.log('Clearing multi-view mode'); state.multiViewMode = false; // Закрываем WebSocket соединения для мультипросмотра // Closing WebSocket connections for multi-view state.selectedContainers.forEach(containerId => { closeWs(containerId); }); // Очищаем область логов if (els.logContent) { els.logContent.innerHTML = ''; } // Воссоздаем single-view-panel если его нет const logContent = document.querySelector('.log-content'); const singleViewPanel = document.getElementById('singleViewPanel'); if (logContent && !singleViewPanel) { logContent.innerHTML = `

${svc.name} (${svc.service || svc.name})

Connecting...
`; // Обновляем ссылки на элементы els.singleViewPanel = document.getElementById('singleViewPanel'); els.singleViewTitle = document.getElementById('singleViewTitle'); els.logContent = document.getElementById('logContent'); } // Удаляем мультипросмотр из DOM const multiViewGrid = document.getElementById('multiViewGrid'); if (multiViewGrid) { console.log('Removing multi-view grid from DOM'); multiViewGrid.remove(); } // Legacy functionality (скрытая) setLayout('tabs'); els.grid.innerHTML=''; const panel = ensurePanel(svc); panel.querySelector('.log').textContent=''; closeWs(svc.id); openWs(svc, panel); state.current = svc; buildTabs(); for (const p of [...els.grid.children]) if (p!==panel) p.remove(); // Обновляем состояние выбранных контейнеров для корректного отображения заголовка state.selectedContainers = [svc.id]; // Сохраняем режим просмотра в localStorage saveViewMode(false, [svc.id]); // Обновляем активное состояние в UI updateActiveContainerUI(svc.id); // Сохраняем состояние кнопок loglevels в localStorage saveLogLevelsState(); if (els.multiViewPanelTitle) { els.multiViewPanelTitle.textContent = `${svc.name} (${svc.service || svc.name})`; } if (els.singleViewTitle) { els.singleViewTitle.textContent = `${svc.name} (${svc.service || svc.name})`; } if (els.logContent) { els.logContent.innerHTML = 'Connecting...'; } // Обновляем logEl для современного интерфейса const obj = state.open[svc.id]; if (obj && els.logContent) { obj.logEl = els.logContent; obj.wrapEl = els.logContent.parentElement; // Если у нас уже есть логи в буфере, отображаем их if (obj.allLogs && obj.allLogs.length > 0) { els.logContent.innerHTML = ''; obj.allLogs.forEach(logEntry => { if (allowedByLevel(logEntry.cls) && applyFilter(logEntry.line)) { els.logContent.insertAdjacentHTML('beforeend', logEntry.html); } }); // Очищаем лишние пустые строки после восстановления логов cleanSingleViewEmptyLines(els.logContent); cleanDuplicateLines(els.logContent); if (els.autoscroll && els.autoscroll.checked) { els.logContent.scrollTop = els.logContent.scrollHeight; } } } // Update active state in container list document.querySelectorAll('.container-item').forEach(item => { item.classList.remove('active'); }); const activeItem = document.querySelector(`.container-item[data-cid="${svc.id}"]`); if (activeItem) { activeItem.classList.add('active'); } // Обновляем состояние чекбоксов после переключения контейнера updateContainerSelectionUI(); // Обновляем счетчики для нового контейнера setTimeout(() => { recalculateCounters(); // Применяем настройки wrap text после переключения контейнера applyWrapSettings(); }, 500); // Небольшая задержка для завершения загрузки логов await updateCounters(svc.id); // Добавляем обработчики для счетчиков после переключения контейнера addCounterClickHandlers(); // Обновляем состояние кнопок уровней логирования setTimeout(() => { initializeLevelButtons(); }, 100); } catch (error) { console.error('switchToSingle: Error occurred:', error); console.error('switchToSingle: Error stack:', error.stack); } } async function openMulti(ids){ els.grid.innerHTML=''; const chosen = state.services.filter(s=> ids.includes(s.id)); const n = chosen.length; if (n<=1){ if (n===1) await switchToSingle(chosen[0]); return; } setLayout(n>=4 ? 'grid4' : n===3 ? 'grid3' : 'grid2'); for (const svc of chosen){ const panel = ensurePanel(svc); panel.querySelector('.log').textContent=''; closeWs(svc.id); openWs(svc, panel); } // Добавляем обработчики для счетчиков после открытия мульти-контейнеров addCounterClickHandlers(); // Применяем настройки wrap text после открытия мульти-контейнеров applyWrapSettings(); } // ----- Copy on selection ----- function getSelectionText(){ const sel = window.getSelection(); return sel && sel.rangeCount ? sel.toString() : ""; } function showCopyFabNearSelection(){ const sel = window.getSelection(); if (!sel || sel.rangeCount===0) return hideCopyFab(); const text = sel.toString(); if (!text.trim()) return hideCopyFab(); // Only show if selection inside a .log or .logwrap const range = sel.getRangeAt(0); const common = range.commonAncestorContainer; const el = common.nodeType===1 ? common : common.parentElement; if (!el || !el.closest('.logwrap')) return hideCopyFab(); const rect = range.getBoundingClientRect(); const top = rect.bottom + 8 + window.scrollY; const left = rect.right + 8 + window.scrollX; els.copyFab.style.top = top + 'px'; els.copyFab.style.left = left + 'px'; els.copyFab.classList.add('show'); } function hideCopyFab(){ els.copyFab.classList.remove('show'); } document.addEventListener('selectionchange', ()=>{ // throttle-ish using requestAnimationFrame window.requestAnimationFrame(showCopyFabNearSelection); }); document.addEventListener('scroll', hideCopyFab, true); els.copyFab.addEventListener('click', async ()=>{ const text = getSelectionText(); if (!text) return; try { await navigator.clipboard.writeText(text); const old = els.copyFab.textContent; els.copyFab.textContent = 'скопировано'; setTimeout(()=> els.copyFab.textContent = old, 1000); hideCopyFab(); window.getSelection()?.removeAllRanges(); } catch(e){ alert('не удалось скопировать: ' + e); } }); function fanGroupUrl(servicesCsv, project){ const proto = location.protocol === 'https:' ? 'wss' : 'ws'; const tail = els.tail.value || '500'; const token = encodeURIComponent(localStorage.getItem('access_token') || ''); const pj = project?`&project=${encodeURIComponent(project)}`:''; return `${proto}://${location.host}/api/websocket/fan_group?services=${encodeURIComponent(servicesCsv)}&tail=${tail}&token=${token}${pj}`; } function openFanGroup(services){ // Build a special panel named after the group els.grid.innerHTML=''; const fake = { id: 'group-'+services.join(','), name:'group', service: 'group', project: (localStorage.lb_project||'') }; const panel = ensurePanel(fake); panel.querySelector('.log').textContent=''; closeWs(fake.id); // Override ws creation to fan_group const id = fake.id; const logEl = panel.querySelector('.log'); const wrapEl = panel.querySelector('.logwrap'); const cdbg = panel.querySelector('.cdbg') || document.querySelector('.cdbg'); const cinfo = panel.querySelector('.cinfo') || document.querySelector('.cinfo'); const cwarn = panel.querySelector('.cwarn') || document.querySelector('.cwarn'); const cerr = panel.querySelector('.cerr') || document.querySelector('.cerr'); const cother = panel.querySelector('.cother') || document.querySelector('.cother'); const counters = {dbg:0,info:0,warn:0,err:0,other:0}; const ws = new WebSocket(fanGroupUrl(services.join(','), fake.project||'')); state.open[id] = {ws, logEl, wrapEl, counters, pausedBuffer:[], serviceName: ('group:'+services.join(','))}; ws.onopen = ()=> { setWsState('on'); }; ws.onclose = ()=> { setWsState(determineWsState()); // Принудительно проверяем состояние через AJAX через 500мс setTimeout(() => { checkWebSocketStatus(); }, 500); }; ws.onerror = (error)=> { setWsState('err'); }; ws.onmessage = (ev)=>{ // Устанавливаем состояние 'on' при получении сообщений setWsState('on'); const parts = (ev.data||'').split(/\r?\n/); for (let i=0;i{ const list = state.services.map(s=> `${s.service}`).filter((v,i,a)=> a.indexOf(v)===i).join(', '); const ans = prompt('Введите имена сервисов через запятую:\n'+list); if (ans){ const services = ans.split(',').map(x=>x.trim()).filter(Boolean); if (services.length) openFanGroup(services); } }; } // Функция для обновления счетчиков через Ajax async function updateCounters(containerId) { try { const token = localStorage.getItem('access_token'); if (!token) { console.error('No access token found'); return; } const response = await fetch(`/api/logs/stats/${containerId}`, { headers: { 'Authorization': `Bearer ${token}` } }); if (response.ok) { const stats = await response.json(); const cdbg = document.querySelector('.cdbg'); const cinfo = document.querySelector('.cinfo'); const cwarn = document.querySelector('.cwarn'); const cerr = document.querySelector('.cerr'); const cother = document.querySelector('.cother'); if (cdbg) cdbg.textContent = stats.debug || 0; if (cinfo) cinfo.textContent = stats.info || 0; if (cwarn) cwarn.textContent = stats.warn || 0; if (cerr) cerr.textContent = stats.error || 0; if (cother) cother.textContent = stats.other || 0; // Обновляем видимость счетчиков updateCounterVisibility(); // Добавляем обработчики для счетчиков addCounterClickHandlers(); // Очищаем дублированные строки в Single View режиме if (!state.multiViewMode && state.current && state.current.id === containerId) { const logContent = document.querySelector('.log-content'); if (logContent) { cleanDuplicateLines(logContent); cleanSingleViewEmptyLines(logContent); } // Также очищаем в legacy панели const obj = state.open[containerId]; if (obj && obj.logEl) { cleanDuplicateLines(obj.logEl); cleanSingleViewEmptyLines(obj.logEl); } } } } catch (error) { console.error('Error updating counters:', error); } } // Функция для обновления счетчиков в multi view (суммирует статистику всех контейнеров) // Эта функция теперь использует пересчет на основе отображаемых логов async function updateMultiViewCounters() { if (!state.multiViewMode || state.selectedContainers.length === 0) { return; } try { // Используем новую функцию пересчета счетчиков recalculateMultiViewCounters(); // Добавляем обработчики для счетчиков addCounterClickHandlers(); } catch (error) { console.error('Error updating multi-view counters:', error); } } // Функция для пересчета счетчиков на основе отображаемых логов (Single View) function recalculateCounters() { if (!state.current) return; const containerId = state.current.id; const obj = state.open[containerId]; if (!obj || !obj.allLogs) return; // Получаем значение Tail Lines const tailLines = parseInt(els.tail.value) || 50; // Берем только последние N логов в соответствии с Tail Lines const visibleLogs = obj.allLogs.slice(-tailLines); // Сбрасываем счетчики obj.counters = {dbg: 0, info: 0, warn: 0, err: 0, other: 0}; // Пересчитываем счетчики только для отображаемых логов visibleLogs.forEach(logEntry => { const shouldShow = allowedByLevel(logEntry.cls) && applyFilter(logEntry.line); if (shouldShow) { if (logEntry.cls === 'dbg') obj.counters.dbg++; if (logEntry.cls === 'ok') obj.counters.info++; if (logEntry.cls === 'warn') obj.counters.warn++; if (logEntry.cls === 'err') obj.counters.err++; if (logEntry.cls === 'other') obj.counters.other++; } }); // Обновляем отображение счетчиков const cdbg = document.querySelector('.cdbg'); const cinfo = document.querySelector('.cinfo'); const cwarn = document.querySelector('.cwarn'); const cerr = document.querySelector('.cerr'); const cother = document.querySelector('.cother'); if (cdbg) cdbg.textContent = obj.counters.dbg; if (cinfo) cinfo.textContent = obj.counters.info; if (cwarn) cwarn.textContent = obj.counters.warn; if (cerr) cerr.textContent = obj.counters.err; if (cother) cother.textContent = obj.counters.other; // Обновляем счетчики в кнопках заголовка single-view updateHeaderCounters(containerId, obj.counters); console.log(`Counters recalculated for container ${containerId} (tail: ${tailLines}):`, obj.counters); } // Функция для пересчета счетчиков в MultiView на основе отображаемых логов function recalculateMultiViewCounters() { if (!state.multiViewMode || state.selectedContainers.length === 0) { return; } // Recalculating multi-view counters for containers // Получаем значение Tail Lines const tailLines = parseInt(els.tail.value) || 50; // Суммируем статистику всех выбранных контейнеров let totalDebug = 0; let totalInfo = 0; let totalWarn = 0; let totalError = 0; let totalOther = 0; // Пересчитываем счетчики для каждого контейнера for (const containerId of state.selectedContainers) { const obj = state.open[containerId]; if (!obj || !obj.allLogs) continue; // Берем только последние N логов в соответствии с Tail Lines const visibleLogs = obj.allLogs.slice(-tailLines); // Сбрасываем счетчики для этого контейнера obj.counters = {dbg: 0, info: 0, warn: 0, err: 0, other: 0}; // Пересчитываем счетчики только для отображаемых логов visibleLogs.forEach(logEntry => { const shouldShow = allowedByContainerLevel(logEntry.cls, containerId) && applyFilter(logEntry.line); if (shouldShow) { if (logEntry.cls === 'dbg') obj.counters.dbg++; if (logEntry.cls === 'ok') obj.counters.info++; if (logEntry.cls === 'warn') obj.counters.warn++; if (logEntry.cls === 'err') obj.counters.err++; if (logEntry.cls === 'other') obj.counters.other++; } }); // Обновляем счетчики в кнопках заголовка для этого контейнера updateHeaderCounters(containerId, obj.counters); // Добавляем к общим счетчикам totalDebug += obj.counters.dbg; totalInfo += obj.counters.info; totalWarn += obj.counters.warn; totalError += obj.counters.err; totalOther += obj.counters.other; } // Обновляем отображение счетчиков const cdbg = document.querySelector('.cdbg'); const cinfo = document.querySelector('.cinfo'); const cwarn = document.querySelector('.cwarn'); const cerr = document.querySelector('.cerr'); const cother = document.querySelector('.cother'); if (cdbg) cdbg.textContent = totalDebug; if (cinfo) cinfo.textContent = totalInfo; if (cwarn) cwarn.textContent = totalWarn; if (cerr) cerr.textContent = totalError; if (cother) cother.textContent = totalOther; // Multi-view counters recalculated } // Функция для обновления видимости счетчиков function updateCounterVisibility() { // Обновляем старые кнопки счетчиков (только для legacy интерфейса) const debugBtn = document.querySelector('.debug-btn'); const infoBtn = document.querySelector('.info-btn'); const warnBtn = document.querySelector('.warn-btn'); const errorBtn = document.querySelector('.error-btn'); const otherBtn = document.querySelector('.other-btn'); if (debugBtn) { debugBtn.classList.toggle('disabled', !state.levels.debug); } if (infoBtn) { infoBtn.classList.toggle('disabled', !state.levels.info); } if (warnBtn) { warnBtn.classList.toggle('disabled', !state.levels.warn); } if (errorBtn) { errorBtn.classList.toggle('disabled', !state.levels.err); } if (otherBtn) { otherBtn.classList.toggle('disabled', !state.levels.other); } } // Функция для управления видимостью кнопок LogLevels function updateLogLevelsVisibility() { const singleViewLevels = document.querySelector('.single-view-levels'); const multiViewLevels = document.querySelectorAll('.multi-view-levels'); // Проверяем, есть ли выбранные контейнеры const hasSelectedContainers = state.selectedContainers.length > 0 || state.current; // Управляем видимостью кнопок в single view if (singleViewLevels) { if (hasSelectedContainers) { singleViewLevels.style.display = 'flex'; } else { singleViewLevels.style.display = 'none'; } } // Управляем видимостью кнопок в multi view multiViewLevels.forEach(levelsContainer => { if (hasSelectedContainers) { levelsContainer.style.display = 'flex'; } else { levelsContainer.style.display = 'none'; } }); } // Функция для обновления логов и счетчиков /** * Автоматически прокручивает логи к самому низу (последние логи) * Работает как для single-view, так и для multi-view режимов */ function scrollToBottom() { if (state.multiViewMode && state.selectedContainers.length > 0) { // Для multi-view прокручиваем все панели state.selectedContainers.forEach(containerId => { const multiViewLog = document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`); if (multiViewLog) { multiViewLog.scrollTop = multiViewLog.scrollHeight; } }); } else if (els.logContent) { // Для single-view прокручиваем основной контент els.logContent.scrollTop = els.logContent.scrollHeight; } } async function refreshLogsAndCounters() { if (state.multiViewMode && state.selectedContainers.length > 0) { // Обновляем мультипросмотр console.log('Refreshing multi-view for containers:', state.selectedContainers); // Очищаем логи в мультипросмотре перед обновлением state.selectedContainers.forEach(containerId => { const multiViewLog = document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`); if (multiViewLog) { multiViewLog.textContent = 'Refreshing...'; } // Очищаем буфер логов для мультипросмотра const obj = state.open[containerId]; if (obj && obj.allLogs) { obj.allLogs = []; } }); // Перезапускаем WebSocket соединения для всех выбранных контейнеров state.selectedContainers.forEach(containerId => { closeWs(containerId); const service = state.services.find(s => s.id === containerId); if (service) { openMultiViewWs(service); } }); // Пересчитываем счетчики на основе отображаемых логов setTimeout(() => { recalculateMultiViewCounters(); // Применяем настройки wrap text после обновления applyWrapSettings(); // Прокручиваем к последним логам scrollToBottom(); }, 1000); // Небольшая задержка для завершения переподключения } else if (state.current) { // Обычный режим просмотра console.log('Refreshing logs and counters for:', state.current.id); // Очищаем логи перед обновлением if (els.logContent) { els.logContent.textContent = 'Refreshing...'; } // Перезапускаем WebSocket соединение для получения свежих логов const currentId = state.current.id; closeWs(currentId); // Находим обновленный контейнер в списке const updatedContainer = state.services.find(s => s.id === currentId); if (updatedContainer) { // Переключаемся на обновленный контейнер await switchToSingle(updatedContainer); // Очищаем лишние пустые строки после переключения if (els.logContent) { cleanSingleViewEmptyLines(els.logContent); cleanDuplicateLines(els.logContent); } } // Пересчитываем счетчики на основе отображаемых логов setTimeout(() => { recalculateCounters(); // Применяем настройки wrap text после обновления applyWrapSettings(); // Прокручиваем к последним логам scrollToBottom(); }, 1000); // Небольшая задержка для завершения переподключения } else { console.log('No container selected'); } } // Controls els.clearBtn.onclick = ()=> { // Очищаем обычный просмотр Object.values(state.open).forEach(o => { if (o.logEl) o.logEl.textContent = ''; if (o.allLogs) o.allLogs = []; // Очищаем буфер логов }); // Очищаем современный интерфейс if (els.logContent) { els.logContent.textContent = ''; // Очищаем лишние пустые строки после очистки cleanSingleViewEmptyLines(els.logContent); cleanDuplicateLines(els.logContent); } // Очищаем мультипросмотр if (state.multiViewMode) { state.selectedContainers.forEach(containerId => { const multiViewLog = document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`); if (multiViewLog) { multiViewLog.textContent = ''; } // Очищаем буфер логов для мультипросмотра const obj = state.open[containerId]; if (obj && obj.allLogs) { obj.allLogs = []; } }); } // Сбрасываем счетчики document.querySelectorAll('.cdbg, .cinfo, .cwarn, .cerr').forEach(el => { el.textContent = '0'; }); // Сбрасываем счетчики в объектах состояния Object.values(state.open).forEach(obj => { if (obj.counters) { obj.counters = {dbg: 0, info: 0, warn: 0, err: 0}; } }); }; els.refreshBtn.onclick = async () => { console.log('Refreshing services...'); await fetchServices(); if (state.multiViewMode && state.selectedContainers.length > 0) { // В Multi View режиме обновляем все выбранные контейнеры console.log('Refreshing Multi View mode with containers:', state.selectedContainers); // Закрываем все текущие соединения state.selectedContainers.forEach(containerId => { closeWs(containerId); }); // Перезапускаем соединения для всех выбранных контейнеров state.selectedContainers.forEach(containerId => { const service = state.services.find(s => s.id === containerId); if (service) { openMultiViewWs(service); } }); // Очищаем логи в мультипросмотре state.selectedContainers.forEach(containerId => { const multiViewLog = document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`); if (multiViewLog) { multiViewLog.textContent = 'Refreshing...'; } }); } else if (state.current) { // Обычный режим просмотра console.log('Reconnecting to current container:', state.current.id); const currentId = state.current.id; // Закрываем текущее соединение closeWs(currentId); // Находим обновленный контейнер в списке const updatedContainer = state.services.find(s => s.id === currentId); if (updatedContainer) { // Переключаемся на обновленный контейнер await switchToSingle(updatedContainer); } else { // Если контейнер больше не существует, не открываем автоматически первый доступный // Пользователь сам выберет нужный контейнер console.log('Container no longer exists, not auto-opening first available container'); } } }; // Обработчик для кнопок refresh логов (в log-header и в header) document.querySelectorAll('.log-refresh-btn').forEach(btn=>{ btn.addEventListener('click', refreshLogsAndCounters); }); // Обработчик для кнопки update (AJAX autoupdate toggle) if (els.ajaxUpdateBtn) { els.ajaxUpdateBtn.addEventListener('click', () => { toggleAjaxLogUpdate(); }); } else { console.error('Кнопка ajaxUpdateBtn не найдена при инициализации обработчика!'); } // Обработчики для счетчиков function addCounterClickHandlers() { // Назначаем обработчики на все дубликаты кнопок (и в log-header, и в header-compact-controls) const debugButtons = document.querySelectorAll('.debug-btn'); const infoButtons = document.querySelectorAll('.info-btn'); const warnButtons = document.querySelectorAll('.warn-btn'); const errorButtons = document.querySelectorAll('.error-btn'); const otherButtons = document.querySelectorAll('.other-btn'); debugButtons.forEach(debugBtn => debugBtn.onclick = () => { state.levels.debug = !state.levels.debug; updateCounterVisibility(); refreshAllLogs(); // Добавляем refresh для обновления логов if (state.current) { refreshLogsAndCounters(); } // Обновляем multi-view если он активен if (state.multiViewMode) { refreshAllLogs(); } }); infoButtons.forEach(infoBtn => infoBtn.onclick = () => { state.levels.info = !state.levels.info; updateCounterVisibility(); refreshAllLogs(); // Добавляем refresh для обновления логов if (state.current) { refreshLogsAndCounters(); } // Обновляем multi-view если он активен if (state.multiViewMode) { refreshAllLogs(); } }); warnButtons.forEach(warnBtn => warnBtn.onclick = () => { state.levels.warn = !state.levels.warn; updateCounterVisibility(); refreshAllLogs(); // Добавляем refresh для обновления логов if (state.current) { refreshLogsAndCounters(); } // Обновляем multi-view если он активен if (state.multiViewMode) { refreshAllLogs(); } }); errorButtons.forEach(errorBtn => errorBtn.onclick = () => { state.levels.err = !state.levels.err; updateCounterVisibility(); refreshAllLogs(); // Добавляем refresh для обновления логов if (state.current) { refreshLogsAndCounters(); } // Обновляем multi-view если он активен if (state.multiViewMode) { refreshAllLogs(); } }); otherButtons.forEach(otherBtn => otherBtn.onclick = () => { state.levels.other = !state.levels.other; updateCounterVisibility(); refreshAllLogs(); // Добавляем refresh для обновления логов if (state.current) { refreshLogsAndCounters(); } // Обновляем multi-view если он активен if (state.multiViewMode) { refreshAllLogs(); } }); } // Функция для добавления обработчиков мультивыбора проектов function addMultiSelectHandlers() { const display = document.getElementById('projectSelectDisplay'); const dropdown = document.getElementById('projectSelectDropdown'); console.log('Adding multi-select handlers, elements found:', {display: !!display, dropdown: !!dropdown}); if (display && dropdown) { // Обработчик клика по дисплею display.addEventListener('click', (e) => { e.stopPropagation(); const isOpen = dropdown.style.display !== 'none'; if (isOpen) { dropdown.style.display = 'none'; display.classList.remove('active'); } else { dropdown.style.display = 'block'; display.classList.add('active'); } }); // Обработчики кликов по опциям dropdown.addEventListener('click', async (e) => { e.stopPropagation(); const option = e.target.closest('.multi-select-option'); if (!option) return; const checkbox = option.querySelector('input[type="checkbox"]'); if (!checkbox) return; const value = option.getAttribute('data-value'); // Специальная логика для "All Projects" if (value === 'all') { if (checkbox.checked) { // Если "All Projects" выбран, снимаем все остальные dropdown.querySelectorAll('.multi-select-option input[type="checkbox"]').forEach(cb => { if (cb !== checkbox) cb.checked = false; }); } } else { // Если выбран конкретный проект, снимаем "All Projects" const allCheckbox = dropdown.querySelector('#project-all'); if (allCheckbox) allCheckbox.checked = false; } // Обновляем отображение и загружаем сервисы const selectedProjects = getSelectedProjects(); updateMultiSelect(selectedProjects); await fetchServices(); }); // Закрытие dropdown при клике вне его document.addEventListener('click', (e) => { if (!display.contains(e.target) && !dropdown.contains(e.target)) { dropdown.style.display = 'none'; display.classList.remove('active'); } }); } } // Функция для показа уведомления о горячих клавишах function showHotkeysNotification() { // Создаем уведомление const notification = document.createElement('div'); notification.className = 'hotkeys-notification'; notification.innerHTML = `

Горячие клавиши

  • [ ] - Навигация между контейнерами
  • Ctrl + R или Ctrl + K - Обновить логи
  • Ctrl + B - Свернуть/развернуть панель
  • Кнопка - управление панелью
`; document.body.appendChild(notification); // Автоматически скрываем через 8 секунд setTimeout(() => { if (notification.parentElement) { notification.remove(); } }, 8000); } // Функция для сворачивания/разворачивания sidebar и header function toggleSidebar() { if (els.sidebar) { const isCollapsed = els.sidebar.classList.contains('collapsed'); if (isCollapsed) { // Разворачиваем sidebar els.sidebar.classList.remove('collapsed'); els.sidebarToggle.innerHTML = ''; els.sidebarToggle.title = 'Свернуть панель (Ctrl+B / Ctrl+И)'; localStorage.setItem('lb_sidebar_collapsed', 'false'); } else { // Сворачиваем sidebar els.sidebar.classList.add('collapsed'); els.sidebarToggle.innerHTML = ''; els.sidebarToggle.title = 'Развернуть панель (Ctrl+B / Ctrl+И)'; localStorage.setItem('lb_sidebar_collapsed', 'true'); } // Принудительно обновляем стили логов после переключения sidebar setTimeout(() => { updateLogStyles(); // Дополнительная проверка для multi-view логов if (state.multiViewMode) { forceFixMultiViewStyles(); } }, 100); } } // Функция для принудительного исправления стилей multi-view логов function forceFixMultiViewStyles() { const multiViewLogs = document.querySelectorAll('.multi-view-content .multi-view-log'); multiViewLogs.forEach((log, index) => { const containerId = log.getAttribute('data-container-id'); // Универсальное исправление для всех контейнеров log.style.setProperty('height', '100%', 'important'); log.style.setProperty('overflow', 'auto', 'important'); log.style.setProperty('max-height', 'none', 'important'); log.style.setProperty('display', 'block', 'important'); log.style.setProperty('min-height', '200px', 'important'); log.style.setProperty('position', 'relative', 'important'); log.style.setProperty('flex', '1', 'important'); log.style.setProperty('min-height', '0', 'important'); log.style.setProperty('width', '100%', 'important'); log.style.setProperty('box-sizing', 'border-box', 'important'); // Принудительно вызываем пересчет layout log.style.setProperty('transform', 'translateZ(0)', 'important'); // Устанавливаем универсальные inline стили для всех контейнеров const currentStyle = log.getAttribute('style') || ''; const newStyle = currentStyle + '; height: 100% !important; overflow: auto !important; flex: 1 !important; min-height: 0 !important; width: 100% !important; box-sizing: border-box !important; display: block !important; position: relative !important;'; log.setAttribute('style', newStyle); // Проверяем и исправляем родительские элементы для всех контейнеров const parentContent = log.closest('.multi-view-content'); if (parentContent) { parentContent.style.setProperty('display', 'flex', 'important'); parentContent.style.setProperty('flex-direction', 'column', 'important'); parentContent.style.setProperty('overflow', 'hidden', 'important'); parentContent.style.setProperty('height', '100%', 'important'); } const parentPanel = log.closest('.multi-view-panel'); if (parentPanel) { parentPanel.style.setProperty('display', 'flex', 'important'); parentPanel.style.setProperty('flex-direction', 'column', 'important'); parentPanel.style.setProperty('overflow', 'hidden', 'important'); parentPanel.style.setProperty('height', '100%', 'important'); } }); // Также исправляем стили для multi-view-content контейнеров const multiViewContents = document.querySelectorAll('.multi-view-content'); multiViewContents.forEach(content => { content.style.setProperty('display', 'flex', 'important'); content.style.setProperty('flex-direction', 'column', 'important'); content.style.setProperty('overflow', 'hidden', 'important'); content.style.setProperty('height', '100%', 'important'); }); // Универсальное исправление для всех контейнеров multiViewLogs.forEach(log => { // Принудительно устанавливаем все стили заново для всех контейнеров log.style.cssText = 'height: 100% !important; overflow: auto !important; max-height: none !important; display: block !important; min-height: 200px !important; position: relative !important; flex: 1 !important; min-height: 0 !important; width: 100% !important; box-sizing: border-box !important;'; // Принудительно вызываем пересчет layout log.style.setProperty('transform', 'translateZ(0)', 'important'); }); // Применяем настройки wrap text после исправления стилей applyWrapSettings(); } // Функция для обновления стилей логов function updateLogStyles() { const isCollapsed = els.sidebar && els.sidebar.classList.contains('collapsed'); // Обновляем стили для single-view логов const singleViewLogs = document.querySelectorAll('.single-view-content .log'); singleViewLogs.forEach(log => { if (isCollapsed) { log.style.height = 'calc(100vh - var(--header-height))'; log.style.overflow = 'auto'; } else { log.style.height = '100%'; log.style.overflow = 'auto'; } }); // Обновляем стили для multi-view логов (более агрессивно) const multiViewLogs = document.querySelectorAll('.multi-view-content .multi-view-log'); multiViewLogs.forEach((log, index) => { const containerId = log.getAttribute('data-container-id'); // Принудительно устанавливаем правильные стили независимо от состояния sidebar log.style.setProperty('height', '100%', 'important'); log.style.setProperty('overflow', 'auto', 'important'); log.style.setProperty('max-height', 'none', 'important'); log.style.setProperty('display', 'block', 'important'); log.style.setProperty('min-height', '200px', 'important'); log.style.setProperty('position', 'relative', 'important'); log.style.setProperty('flex', '1', 'important'); log.style.setProperty('min-height', '0', 'important'); // Принудительно вызываем пересчет layout log.style.setProperty('transform', 'translateZ(0)', 'important'); }); // Также обновляем стили для multi-view-content контейнеров const multiViewContents = document.querySelectorAll('.multi-view-content'); multiViewContents.forEach(content => { if (isCollapsed) { // В свернутом состоянии multi-view-content должен иметь правильную высоту content.style.setProperty('height', 'calc(100vh - var(--header-height) - 60px)', 'important'); } else { content.style.setProperty('height', '100%', 'important'); } content.style.setProperty('overflow', 'hidden', 'important'); content.style.setProperty('display', 'flex', 'important'); content.style.setProperty('flex-direction', 'column', 'important'); }); // Применяем настройки wrap text applyWrapSettings(); // Принудительно исправляем стили multi-view логов forceFixMultiViewStyles(); // Дополнительная проверка через 500ms для multi view логов if (multiViewLogs.length > 0) { setTimeout(() => { forceFixMultiViewStyles(); }, 500); } } // Mobile menu toggle if (els.mobileToggle) { els.mobileToggle.onclick = () => { const sidebar = document.querySelector('.sidebar'); if (sidebar) { sidebar.classList.toggle('open'); } }; } // Функция для показа/скрытия модального окна с горячими клавишами function toggleHotkeysModal() { if (els.hotkeysModal) { const isVisible = els.hotkeysModal.classList.contains('show'); if (isVisible) { els.hotkeysModal.classList.remove('show'); } else { els.hotkeysModal.classList.add('show'); } } } // Sidebar toggle button if (els.sidebarToggle) { els.sidebarToggle.onclick = toggleSidebar; } // Modal close button if (els.hotkeysModalClose) { els.hotkeysModalClose.onclick = toggleHotkeysModal; } // Close modal on background click if (els.hotkeysModal) { els.hotkeysModal.onclick = (e) => { if (e.target === els.hotkeysModal) { toggleHotkeysModal(); } }; } // Collapsible sections document.addEventListener('DOMContentLoaded', () => { // Обработчики для сворачивания секций document.querySelectorAll('.control-header').forEach(header => { header.addEventListener('click', (e) => { if (e.target.closest('.collapse-btn')) return; // Не сворачиваем при клике на кнопку const group = header.closest('.control-group'); // Если секция минимизирована, сначала разворачиваем if (group.classList.contains('minimized')) { group.classList.remove('minimized'); group.classList.add('collapsed'); const section = group.dataset.section; localStorage.setItem(`lb_minimized_${section}`, 'false'); localStorage.setItem(`lb_collapsed_${section}`, 'true'); } else { // Обычное сворачивание/разворачивание group.classList.toggle('collapsed'); const section = group.dataset.section; localStorage.setItem(`lb_collapsed_${section}`, group.classList.contains('collapsed')); localStorage.setItem(`lb_minimized_${section}`, 'false'); } }); }); // Обработчики для кнопок сворачивания document.querySelectorAll('.collapse-btn').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const group = btn.closest('.control-group'); // Если секция минимизирована, сначала разворачиваем if (group.classList.contains('minimized')) { group.classList.remove('minimized'); group.classList.add('collapsed'); const section = group.dataset.section; localStorage.setItem(`lb_minimized_${section}`, 'false'); localStorage.setItem(`lb_collapsed_${section}`, 'true'); } else { // Обычное сворачивание/разворачивание group.classList.toggle('collapsed'); const section = group.dataset.section; localStorage.setItem(`lb_collapsed_${section}`, group.classList.contains('collapsed')); localStorage.setItem(`lb_minimized_${section}`, 'false'); } }); }); // Восстанавливаем состояние секций из localStorage или сворачиваем по умолчанию document.querySelectorAll('.control-group.collapsible').forEach(group => { const section = group.dataset.section; const savedCollapsed = localStorage.getItem(`lb_collapsed_${section}`); const savedMinimized = localStorage.getItem(`lb_minimized_${section}`); // Если состояние не сохранено, сворачиваем по умолчанию if (savedCollapsed === null && savedMinimized === null) { group.classList.add('collapsed'); localStorage.setItem(`lb_collapsed_${section}`, 'true'); localStorage.setItem(`lb_minimized_${section}`, 'false'); } else if (savedMinimized === 'true') { group.classList.add('minimized'); group.classList.remove('collapsed'); } else if (savedCollapsed === 'true') { group.classList.add('collapsed'); group.classList.remove('minimized'); } }); // Обработчик для кнопки Options if (els.optionsBtn) { els.optionsBtn.addEventListener('click', () => { const sidebarControls = document.querySelector('.sidebar-controls'); const isHidden = sidebarControls.classList.contains('hidden'); if (isHidden) { // Если сайдбар свернут, сначала разворачиваем его if (els.sidebar.classList.contains('collapsed')) { toggleSidebar(); } // Показываем настройки sidebarControls.classList.remove('hidden'); els.optionsBtn.classList.remove('active'); els.optionsBtn.title = 'Скрыть настройки'; localStorage.setItem('lb_options_hidden', 'false'); } else { // Скрываем настройки sidebarControls.classList.add('hidden'); els.optionsBtn.classList.add('active'); els.optionsBtn.title = 'Показать настройки'; localStorage.setItem('lb_options_hidden', 'true'); } }); // Восстанавливаем состояние кнопки Options (по умолчанию скрыто) const optionsHidden = localStorage.getItem('lb_options_hidden'); if (optionsHidden === null || optionsHidden === 'true') { document.querySelector('.sidebar-controls').classList.add('hidden'); els.optionsBtn.classList.add('active'); els.optionsBtn.title = 'Показать настройки'; localStorage.setItem('lb_options_hidden', 'true'); } // Инициализируем состояние кнопок уровней логирования initializeLevelButtons(); } // Обработчик для кнопки выхода if (els.logoutBtn) { els.logoutBtn.addEventListener('click', async () => { if (confirm('Вы уверены, что хотите выйти?')) { try { // Вызываем API для выхода await fetch('/api/auth/logout', { method: 'POST', headers: { 'Authorization': `Bearer ${localStorage.getItem('access_token')}` } }); } catch (error) { console.error('Logout error:', error); } finally { // Останавливаем автоматическую проверку WebSocket stopWebSocketStatusCheck(); // Очищаем localStorage localStorage.removeItem('access_token'); // Перенаправляем на страницу входа window.location.href = '/login'; } } }); } // Инициализируем стили логов при загрузке страницы updateLogStyles(); // Применяем настройки wrap text при загрузке applyWrapSettings(); // Дополнительная проверка для multi-view логов при загрузке setTimeout(() => { if (state.multiViewMode) { forceFixMultiViewStyles(); } }, 1000); // Обработчик для кнопки помощи if (els.helpBtn) { const helpTooltip = document.getElementById('helpTooltip'); let tooltipTimeout; // Показ модального окна при клике els.helpBtn.addEventListener('click', () => { showHelpTooltip(); }); // Кнопка закрытия модального окна const helpTooltipClose = document.getElementById('helpTooltipClose'); if (helpTooltipClose) { helpTooltipClose.addEventListener('click', () => { hideHelpTooltip(); }); } // Закрытие по Escape document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { hideHelpTooltip(); } }); // Закрытие по клику вне модального окна helpTooltip.addEventListener('click', (e) => { if (e.target === helpTooltip) { hideHelpTooltip(); } }); } }); if (els.snapshotBtn) { els.snapshotBtn.onclick = ()=>{ if (state.multiViewMode && state.selectedContainers.length > 0) { // В Multi View режиме используем первый выбранный контейнер как ID для sendSnapshot // Функция sendSnapshot сама определит, что нужно скачать логи всех контейнеров sendSnapshot(state.selectedContainers[0]); } else if (state.current) { sendSnapshot(state.current.id); } else { alert('No container selected'); } }; } if (els.tail) { els.tail.onchange = async ()=> { // Single View: не переподключаем WS, просто пересчитываем отображение и счетчики if (!state.multiViewMode) { if (els.logContent) { updateLogVisibility(els.logContent); } else { refreshAllLogs(); } // Обновляем счетчики и прокрутку setTimeout(() => { recalculateCounters(); scrollToBottom(); }, 50); return; } for (const id of Object.keys(state.open)){ const svc = state.services.find(s=> s.id===id); if (!svc) continue; // В multi view режиме используем openMultiViewWs if (state.multiViewMode && state.selectedContainers.includes(id)) { closeWs(id); openMultiViewWs(svc); } else { // В обычном режиме используем openWs const panel = els.grid.querySelector(`.panel[data-cid="${id}"]`); if (!panel) { // В современном интерфейсе панели может не быть — переподключаем через switchToSingle только для текущего контейнера if (state.current && state.current.id === id) { closeWs(id); // Переключаемся на тот же контейнер, чтобы пересоздать WS с новым tail await switchToSingle(svc); } continue; } if (state.open[id] && state.open[id].logEl) { state.open[id].logEl.textContent=''; } closeWs(id); openWs(svc, panel); } } // MultiView: не трогаем здесь UI — trimming делается ниже в делегированном обработчике // Пересчитываем счетчики после изменения Tail Lines setTimeout(() => { if (state.multiViewMode) { recalculateMultiViewCounters(); } else { recalculateCounters(); } }, 1000); // Небольшая задержка для завершения переподключения }; } if (els.wrapToggle) { els.wrapToggle.onchange = ()=> { applyWrapSettings(); }; } // Добавляем обработчики для autoscroll и pause if (els.autoscroll) { els.autoscroll.onchange = ()=> { // Обновляем настройку автопрокрутки для всех открытых логов Object.keys(state.open).forEach(id => { const obj = state.open[id]; if (obj && obj.wrapEl) { if (els.autoscroll.checked) { obj.wrapEl.scrollTop = obj.wrapEl.scrollHeight; } } }); // Обновляем современный интерфейс if (state.current && els.logContent) { const logContent = document.querySelector('.log-content'); if (logContent && els.autoscroll.checked) { logContent.scrollTop = logContent.scrollHeight; } } // Обновляем мультипросмотр if (state.multiViewMode) { state.selectedContainers.forEach(containerId => { const multiViewLog = document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`); if (multiViewLog && els.autoscroll.checked) { multiViewLog.scrollTop = multiViewLog.scrollHeight; } }); } }; } // Обработчик для фильтра (если элемент существует) if (els.filter) { els.filter.oninput = ()=> { state.filter = els.filter.value.trim(); refreshAllLogs(); // Пересчитываем счетчики в зависимости от режима setTimeout(() => { if (state.multiViewMode) { recalculateMultiViewCounters(); } else { recalculateCounters(); } }, 100); }; } // Обработчики для LogLevels (если элементы существуют) if (els.lvlDebug) { els.lvlDebug.onchange = ()=> { state.levels.debug = els.lvlDebug.checked; updateCounterVisibility(); refreshAllLogs(); // Обновляем multi-view если он активен if (state.multiViewMode) { refreshAllLogs(); setTimeout(() => { recalculateMultiViewCounters(); }, 100); } else { // Пересчитываем счетчики для Single View setTimeout(() => { recalculateCounters(); }, 100); } }; } if (els.lvlInfo) { els.lvlInfo.onchange = ()=> { state.levels.info = els.lvlInfo.checked; updateCounterVisibility(); refreshAllLogs(); // Обновляем multi-view если он активен if (state.multiViewMode) { refreshAllLogs(); setTimeout(() => { recalculateMultiViewCounters(); }, 100); } else { // Пересчитываем счетчики для Single View setTimeout(() => { recalculateCounters(); }, 100); } }; } if (els.lvlWarn) { els.lvlWarn.onchange = ()=> { state.levels.warn = els.lvlWarn.checked; updateCounterVisibility(); refreshAllLogs(); // Обновляем multi-view если он активен if (state.multiViewMode) { refreshAllLogs(); setTimeout(() => { recalculateMultiViewCounters(); }, 100); } else { // Пересчитываем счетчики для Single View setTimeout(() => { recalculateCounters(); }, 100); } }; } if (els.lvlErr) { els.lvlErr.onchange = ()=> { state.levels.err = els.lvlErr.checked; updateCounterVisibility(); refreshAllLogs(); // Обновляем multi-view если он активен if (state.multiViewMode) { refreshAllLogs(); setTimeout(() => { recalculateMultiViewCounters(); }, 100); } else { // Пересчитываем счетчики для Single View setTimeout(() => { recalculateCounters(); }, 100); } }; } if (els.lvlOther) { els.lvlOther.onchange = ()=> { state.levels.other = els.lvlOther.checked; updateCounterVisibility(); refreshAllLogs(); // Обновляем multi-view если он активен if (state.multiViewMode) { refreshAllLogs(); setTimeout(() => { recalculateMultiViewCounters(); }, 100); } else { // Пересчитываем счетчики для Single View setTimeout(() => { recalculateCounters(); }, 100); } }; } // Обработчик изменения размера окна для обновления стилей multi-view логов window.addEventListener('resize', () => { if (state.multiViewMode) { setTimeout(() => { forceFixMultiViewStyles(); // Дополнительно исправляем все контейнеры if (window.fixAllContainers) { window.fixAllContainers(); } }, 100); } }); // Hotkeys: [ ] х ъ — navigation between containers, Ctrl/Cmd+R/K — refresh logs, Ctrl/Cmd+B/И — toggle sidebar window.addEventListener('keydown', async (e)=>{ // Проверяем, не находится ли фокус в поле ввода const activeElement = document.activeElement; const isInputActive = activeElement && ( activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA' || activeElement.contentEditable === 'true' ); // Если фокус в поле ввода, не обрабатываем горячие клавиши if (isInputActive) { return; } // Навигация между контейнерами по [ ] х ъ if (e.key==='[' || e.key==='х'){ e.preventDefault(); const idx = state.services.findIndex(s=> s.id===state.current?.id); if (idx>0) await switchToSingle(state.services[idx-1]); } if (e.key===']' || e.key==='ъ'){ e.preventDefault(); const idx = state.services.findIndex(s=> s.id===state.current?.id); if (idx>=0 && idx { // Сначала попробуем использовать els.filter let filterElement = els.filter; // Если els.filter не найден, попробуем найти элемент напрямую if (!filterElement) { console.log('els.filter not found, searching directly...'); filterElement = document.getElementById('filter'); } // Если элемент найден, фокусируемся на нем if (filterElement) { console.log('Focusing on filter element:', filterElement); try { filterElement.focus(); filterElement.select(); console.log('Filter focused successfully'); } catch (error) { console.error('Error focusing filter:', error); } } else { console.error('Filter element not found anywhere!'); // Попробуем еще раз через небольшую задержку setTimeout(() => { const retryElement = document.getElementById('filter'); if (retryElement) { console.log('Filter found on retry, focusing...'); retryElement.focus(); retryElement.select(); } }, 100); } }; // Вызываем функцию фокусировки focusFilter(); } }); // Функция для переинициализации элементов function reinitializeElements() { // Переинициализируем элементы, которые могут быть не найдены при первой загрузке els.filter = document.getElementById('filter'); els.containerList = document.getElementById('containerList'); els.logContent = document.getElementById('logContent'); els.mobileToggle = document.getElementById('mobileToggle'); els.optionsBtn = document.getElementById('optionsBtn'); els.helpBtn = document.getElementById('helpBtn'); els.logoutBtn = document.getElementById('logoutBtn'); els.sidebar = document.getElementById('sidebar'); els.sidebarToggle = document.getElementById('sidebarToggle'); els.header = document.getElementById('header'); els.autoRefreshOnRestore = document.getElementById('autoRefreshOnRestore'); console.log('Elements reinitialized:', { filter: !!els.filter, containerList: !!els.containerList, logContent: !!els.logContent, sidebar: !!els.sidebar, sidebarToggle: !!els.sidebarToggle }); } // Инициализация (async function init() { console.log('Initializing LogBoard+...'); // Переинициализируем элементы reinitializeElements(); // Инициализируем состояние WebSocket setWsState('off'); // Дополнительно инициализируем элементы после полной загрузки DOM if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', reinitializeElements); } // Инициализируем элементы после полной загрузки страницы window.addEventListener('load', reinitializeElements); // Обработчик для правильной очистки при перезагрузке страницы window.addEventListener('beforeunload', () => { // Останавливаем автоматическую проверку WebSocket stopWebSocketStatusCheck(); // Закрываем все WebSocket соединения Object.keys(state.open).forEach(id => { const obj = state.open[id]; if (obj && obj.ws) { try { obj.ws.close(); } catch (e) { // Игнорируем ошибки при закрытии } } }); // Очищаем состояние state.open = {}; }); // Проверяем авторизацию const token = localStorage.getItem('access_token'); if (!token) { console.log('No access token found, redirecting to login'); window.location.href = '/login'; return; } // Проверяем валидность токена try { const response = await fetch('/api/auth/me', { headers: { 'Authorization': `Bearer ${token}` } }); if (!response.ok) { console.log('Invalid token, redirecting to login'); localStorage.removeItem('access_token'); window.location.href = '/login'; return; } } catch (error) { console.error('Error checking auth:', error); localStorage.removeItem('access_token'); window.location.href = '/login'; return; } console.log('Elements found:', { containerList: !!els.containerList, logContent: !!els.logContent, mobileToggle: !!els.mobileToggle, themeSwitch: !!els.themeSwitch }); // Проверяем header project select const headerSelect = document.getElementById('projectSelectHeader'); console.log('Header project select found during init:', !!headerSelect); await fetchProjects(); await fetchServices(); // Проверяем состояние WebSocket после загрузки сервисов setTimeout(() => { setWsState(determineWsState()); }, 1000); // Запускаем автоматическую проверку состояния WebSocket startWebSocketStatusCheck(); // Добавляем обработчик клика для кнопки WebSocket статуса if (els.wsstate) { els.wsstate.addEventListener('click', () => { checkWebSocketStatus(); }); } // Проверяем, есть ли сохраненный контейнер в localStorage const savedContainerId = getSelectedContainerFromStorage(); if (savedContainerId) { const savedService = state.services.find(s => s.id === savedContainerId); if (savedService) { // Добавляем контейнер в выбранные state.selectedContainers = [savedContainerId]; // Переключаемся на сохраненный контейнер await switchToSingle(savedService); // Очищаем сохраненный контейнер из localStorage saveSelectedContainer(null); } else { console.log('Saved container not found in services, clearing localStorage'); saveSelectedContainer(null); } } // Инициализируем видимость счетчиков updateCounterVisibility(); // Обновляем состояние чекбоксов после загрузки сервисов updateContainerSelectionUI(); // Восстанавливаем состояние sidebar const sidebarCollapsed = localStorage.getItem('lb_sidebar_collapsed'); if (sidebarCollapsed === 'true' && els.sidebar && els.sidebarToggle) { els.sidebar.classList.add('collapsed'); els.sidebarToggle.innerHTML = ''; els.sidebarToggle.title = 'Развернуть панель (Ctrl+B / Ctrl+И)'; } // Показываем подсказку о горячих клавишах при первом запуске const hotkeysShown = localStorage.getItem('lb_hotkeys_shown'); if (!hotkeysShown) { setTimeout(() => { showHotkeysNotification(); localStorage.setItem('lb_hotkeys_shown', 'true'); }, 2000); } // Добавляем обработчики для счетчиков addCounterClickHandlers(); // Добавляем обработчик для выпадающего списка проектов в заголовке addMultiSelectHandlers(); // Загружаем и отображаем исключенные контейнеры loadExcludedContainers().then(containers => { renderExcludedContainers(containers); }); // Добавляем обработчики для исключенных контейнеров const addExcludedBtn = document.getElementById('addExcludedContainer'); const newExcludedInput = document.getElementById('newExcludedContainer'); if (addExcludedBtn) { addExcludedBtn.onclick = addExcludedContainer; } if (newExcludedInput) { newExcludedInput.onkeypress = (e) => { if (e.key === 'Enter') { addExcludedContainer(); } }; } // Добавляем обработчики для чекбоксов контейнеров document.addEventListener('change', (e) => { if (e.target.classList.contains('container-checkbox')) { const containerId = e.target.getAttribute('data-container-id'); toggleContainerSelection(containerId); } // Обработчик изменения tail lines if (e.target.id === 'tail') { console.log('Tail lines changed to:', e.target.value); if (state.multiViewMode) { // В multi view применяем новое ограничение к уже отображаемым логам const tailLines = parseInt(e.target.value) || 50; console.log(`Applying tail lines limit ${tailLines} to ${state.selectedContainers.length} containers:`, state.selectedContainers); // Проверяем все элементы multi-view-log на странице const allMultiViewLogs = document.querySelectorAll('.multi-view-log'); state.selectedContainers.forEach(containerId => { // Ищем элемент несколькими способами let multiViewLog = document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`); if (!multiViewLog) { console.warn(`Container ${containerId} not found with data-container-id, trying alternative search...`); // Попробуем найти по другому селектору multiViewLog = document.querySelector(`[data-container-id="${containerId}"]`); } if (multiViewLog) { // Получаем все строки логов const logLines = Array.from(multiViewLog.querySelectorAll('.line')); console.log(`Container ${containerId}: ${logLines.length} log lines found`); if (logLines.length > tailLines) { // Удаляем лишние строки с начала const linesToRemove = logLines.length - tailLines; console.log(`Removing ${linesToRemove} lines from container ${containerId}`); // Удаляем первые N строк logLines.slice(0, linesToRemove).forEach(line => { line.remove(); }); const remainingLines = multiViewLog.querySelectorAll('.line').length; console.log(`Container ${containerId} now has ${remainingLines} lines after trimming`); } else { console.log(`Container ${containerId} has ${logLines.length} lines, no trimming needed (limit: ${tailLines})`); } } else { console.error(`Multi-view log element not found for container ${containerId}`); console.error(`Available multi-view-log elements:`, Array.from(document.querySelectorAll('.multi-view-log')).map(el => ({ containerId: el.getAttribute('data-container-id'), className: el.className, parent: el.parentElement?.className }))); } }); } } }); // Добавляем обработчики кликов на label чекбоксов document.addEventListener('click', (e) => { if (e.target.classList.contains('container-checkbox-label')) { e.preventDefault(); e.stopPropagation(); const label = e.target; const checkbox = label.previousElementSibling; if (checkbox && checkbox.classList.contains('container-checkbox')) { checkbox.checked = !checkbox.checked; const containerId = checkbox.getAttribute('data-container-id'); toggleContainerSelection(containerId); } } }); // Обработчики для кнопок уровней логирования в заголовках console.log('Setting up level button click handler...'); // Удаляем предыдущий обработчик, если он существует if (window.levelButtonClickHandler) { document.removeEventListener('click', window.levelButtonClickHandler); console.log('Removed previous level button click handler'); } // Создаем новый обработчик window.levelButtonClickHandler = (e) => { // Проверяем, что клик произошел на кнопке уровня логирования или на ее дочернем элементе let levelBtn = null; // Если клик произошел на самой кнопке if (e.target.classList.contains('level-btn')) { levelBtn = e.target; console.log('Click on button itself'); } // Если клик произошел на дочернем элементе кнопки else if (e.target.closest('.level-btn')) { levelBtn = e.target.closest('.level-btn'); console.log('Click on child element of button'); } if (levelBtn) { const level = levelBtn.getAttribute('data-level'); const containerId = levelBtn.getAttribute('data-container-id'); console.log(`Клик по кнопке уровня логирования: level=${level}, containerId=${containerId}, multiViewMode=${state.multiViewMode}`); console.log(`Кнопка найдена:`, levelBtn); console.log(`Текущие классы кнопки:`, levelBtn.className); // Переключаем состояние кнопки const isActive = levelBtn.classList.contains('active'); levelBtn.classList.toggle('active'); // Обновляем состояние уровней логирования if (containerId) { // Для multi-view: конкретный контейнер if (!state.containerLevels) { state.containerLevels = {}; } if (!state.containerLevels[containerId]) { state.containerLevels[containerId] = {debug: true, info: true, warn: true, err: true, other: true}; } state.containerLevels[containerId][level] = !isActive; // Сохраняем состояние кнопок loglevels в localStorage saveLogLevelsState(); // Обновляем видимость логов только для этого контейнера updateContainerLogVisibility(containerId); // Пересчитываем счетчики только для этого контейнера setTimeout(() => { updateContainerCounters(containerId); }, 100); // Обновляем видимость логов для всех контейнеров в multi-view // чтобы убедиться, что изменения применились только к нужному контейнеру state.selectedContainers.forEach(id => { if (id !== containerId) { updateContainerLogVisibility(id); } }); } else { // Для single-view: глобальные настройки state.levels[level] = !isActive; // Сохраняем состояние кнопок loglevels в localStorage saveLogLevelsState(); // Обновляем видимость логов только для текущего контейнера if (state.current) { updateLogVisibility(els.logContent); } // Пересчитываем счетчики только для текущего контейнера setTimeout(() => { recalculateCounters(); }, 100); } } }; // Добавляем тестовые функции в глобальную область для отладки window.testDuplicateRemoval = testDuplicateRemoval; window.testSingleViewDuplicateRemoval = testSingleViewDuplicateRemoval; window.testSingleViewEmptyLinesRemoval = testSingleViewEmptyLinesRemoval; window.testSingleViewLineBreaks = testSingleViewLineBreaks; window.testMultiViewLineBreaks = testMultiViewLineBreaks; window.testMultiViewSpecialReplacements = testMultiViewSpecialReplacements; window.testFullMultiViewProcessing = testFullMultiViewProcessing; window.quickTestINFO = quickTestINFO; window.testRegex = testRegex; window.checkMultiViewHTML = checkMultiViewHTML; window.cleanMultiViewDuplicateLines = cleanMultiViewDuplicateLines; window.cleanDuplicateLines = cleanDuplicateLines; window.cleanSingleViewEmptyLines = cleanSingleViewEmptyLines; // Добавляем функции для исправления стилей в глобальную область window.forceFixMultiViewStyles = forceFixMultiViewStyles; window.updateLogStyles = updateLogStyles; // Универсальная функция для исправления всех контейнеров window.fixAllContainers = function() { const allLogs = document.querySelectorAll('.multi-view-content .multi-view-log'); allLogs.forEach(log => { const containerId = log.getAttribute('data-container-id'); // Принудительно устанавливаем все стили заново log.style.cssText = 'height: 100% !important; overflow: auto !important; max-height: none !important; display: block !important; min-height: 200px !important; position: relative !important; flex: 1 !important; min-height: 0 !important; width: 100% !important; box-sizing: border-box !important;'; // Принудительно вызываем пересчет layout log.style.setProperty('transform', 'translateZ(0)', 'important'); // Проверяем родительские элементы const parentContent = log.closest('.multi-view-content'); if (parentContent) { parentContent.style.cssText = 'display: flex !important; flex-direction: column !important; overflow: hidden !important; height: 100% !important;'; } const parentPanel = log.closest('.multi-view-panel'); if (parentPanel) { parentPanel.style.cssText = 'display: flex !important; flex-direction: column !important; overflow: hidden !important; height: 100% !important;'; } }); // Применяем настройки wrap text после исправления всех контейнеров applyWrapSettings(); }; // Оставляем старую функцию для обратной совместимости window.fixProblematicContainers = function() { window.fixAllContainers(); }; // LogBoard+ инициализирован с исправлениями дублирования строк (логи убраны для снижения шума в консоли) // Запускаем первоначальную очистку пустых строк setTimeout(() => { if (!state.multiViewMode && els.logContent) { cleanSingleViewEmptyLines(els.logContent); cleanDuplicateLines(els.logContent); } }, 1000); // Инициализируем видимость кнопок LogLevels updateLogLevelsVisibility(); // ======================================== // AJAX ОБНОВЛЕНИЕ ЛОГОВ // ======================================== // Глобальные переменные для AJAX обновления let ajaxUpdateInterval = null; let ajaxUpdateEnabled = true; // По умолчанию включен let ajaxUpdateIntervalMs = 2000; // 2 секунды по умолчанию (будет переопределено из env) // Состояние для каждого контейнера (для multi-view) let containerStates = new Map(); // containerId -> {lastTimestamp, lastSecondCount} /** * Включить периодическое обновление логов через AJAX * @param {number} intervalMs - Интервал обновления в миллисекундах */ function enableAjaxLogUpdate(intervalMs = null) { if (ajaxUpdateInterval) { clearInterval(ajaxUpdateInterval); } // Используем переданный интервал или значение по умолчанию if (intervalMs === null) { intervalMs = ajaxUpdateIntervalMs; } ajaxUpdateEnabled = true; ajaxUpdateIntervalMs = intervalMs; // Запускаем первое обновление сразу performAjaxLogUpdate(); // Устанавливаем интервал ajaxUpdateInterval = setInterval(performAjaxLogUpdate, intervalMs); // Обновляем UI updateAjaxUpdateCheckbox(); // Обновляем видимость кнопки refresh и состояние кнопки update updateRefreshButtonVisibility(); } /** * Отключить периодическое обновление логов через AJAX */ function disableAjaxLogUpdate() { if (ajaxUpdateInterval) { clearInterval(ajaxUpdateInterval); ajaxUpdateInterval = null; } ajaxUpdateEnabled = false; // Обновляем UI updateAjaxUpdateCheckbox(); // Обновляем видимость кнопки refresh и состояние кнопки update updateRefreshButtonVisibility(); } /** * Переключить состояние AJAX обновления */ function toggleAjaxLogUpdate() { if (ajaxUpdateEnabled) { disableAjaxLogUpdate(); } else { enableAjaxLogUpdate(ajaxUpdateIntervalMs); } // Обновляем видимость кнопки refresh и состояние кнопки update при переключении updateRefreshButtonVisibility(); } /** * Выполнить обновление логов через AJAX */ async function performAjaxLogUpdate() { if (!ajaxUpdateEnabled) { return; } // Получаем значение tail, учитывая опцию "all" let tailLines = els.tail.value; if (tailLines === 'all') { tailLines = 'all'; // Оставляем как строку для API } else { tailLines = parseInt(tailLines) || 50; } try { const token = localStorage.getItem('access_token'); if (!token) { console.error('AJAX Update: No access token found'); return; } // Определяем контейнеры для обновления let containersToUpdate = []; if (state.multiViewMode && state.selectedContainers.length > 0) { // Multi-view режим: обновляем все выбранные контейнеры containersToUpdate = state.selectedContainers; } else if (state.current) { // Single-view режим: обновляем текущий контейнер containersToUpdate = [state.current.id]; } else { return; } // Обновляем каждый контейнер for (const containerId of containersToUpdate) { await updateContainerLogs(containerId, tailLines, token); } } catch (error) { console.error('AJAX Update Error:', error); // Не отключаем обновление при ошибке, просто логируем } } /** * Обновить логи для конкретного контейнера */ async function updateContainerLogs(containerId, tailLines, token) { try { // Формируем URL с параметрами const url = new URL(`/api/logs/${containerId}`, window.location.origin); // Передаем tail параметр как строку (для поддержки "all") url.searchParams.set('tail', String(tailLines)); // Получаем состояние контейнера const containerState = containerStates.get(containerId) || { lastTimestamp: null, lastSecondCount: 0 }; // Если у нас есть временная метка последнего обновления, используем её if (containerState.lastTimestamp) { url.searchParams.set('since', containerState.lastTimestamp); } // Формируем заголовки запроса const headers = { 'Authorization': `Bearer ${token}`, 'Cache-Control': 'no-cache' }; const response = await fetch(url.toString(), { headers }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const data = await response.json(); if (data.error) { throw new Error(data.error); } // Клиентская дедупликация: пропускаем уже учтённые строки в ту же секунду let newPortion = data.logs || []; // Извлекаем секундную часть из timestamp ответа сервера const serverTs = (data.timestamp || '').split('.')[0]; // отбрасываем миллисекунды if (!containerState.lastTimestamp || serverTs !== containerState.lastTimestamp) { // Новая секунда — сбрасываем счётчик containerState.lastTimestamp = serverTs; containerState.lastSecondCount = 0; } if (newPortion.length > 0) { // Обрезаем уже учтённые строки в той же секунде if (containerState.lastSecondCount > 0 && newPortion.length > containerState.lastSecondCount) { newPortion = newPortion.slice(containerState.lastSecondCount); } else if (containerState.lastSecondCount >= newPortion.length) { newPortion = []; } if (newPortion.length > 0) { appendNewLogsForContainer(containerId, newPortion); containerState.lastSecondCount += newPortion.length; } } // Обновляем состояние контейнера containerStates.set(containerId, containerState); } catch (error) { console.error(`AJAX Update Error for ${containerId}:`, error); } } /** * Добавить новые логи в конец существующих (универсальная функция для single и multi view) * @param {string} containerId - ID контейнера * @param {Array} newLogs - Массив новых логов */ function appendNewLogsForContainer(containerId, newLogs) { let obj = state.open[containerId]; if (!obj) { // Лениво инициализируем объект для AJAX-обновлений, когда WS ещё не открыт const multiViewLog = document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`); const logEl = els.logContent || multiViewLog || null; state.open[containerId] = { ws: null, logEl: logEl, wrapEl: logEl ? logEl.parentElement : null, counters: {dbg:0, info:0, warn:0, err:0, other:0}, pausedBuffer: [], serviceName: containerId, allLogs: [], ajaxOnly: true }; obj = state.open[containerId]; console.warn(`AJAX Update: Created ajaxOnly state for container ${containerId}`); } // Обрабатываем каждую новую строку лога через handleLine let addedCount = 0; newLogs.forEach(log => { const message = log.message || log.raw || ''; if (message.trim()) { // Используем существующую функцию handleLine для правильной обработки handleLine(containerId, message); addedCount++; } }); // Прокручиваем к концу, если включена автопрокрутка if (state.autoScroll) { if (state.multiViewMode) { // Для multi-view прокручиваем все контейнеры const multiViewLog = document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`); if (multiViewLog) { multiViewLog.scrollTop = multiViewLog.scrollHeight; } } else if (els.logContent) { // Для single-view прокручиваем основной контент els.logContent.scrollTop = els.logContent.scrollHeight; } } // Обновляем счетчики if (state.multiViewMode) { // Для multi-view обновляем счетчики конкретного контейнера updateContainerCounters(containerId); } else { // Для single-view обновляем общие счетчики recalculateCounters(); } // Очищаем дублированные строки if (state.multiViewMode) { const multiViewLog = document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`); if (multiViewLog) { cleanMultiViewDuplicateLines(multiViewLog); cleanMultiViewEmptyLines(multiViewLog); } } else { cleanDuplicateLines(els.logContent); cleanSingleViewEmptyLines(els.logContent); } } /** * Добавить новые логи в конец существующих (для обратной совместимости) * @param {Array} newLogs - Массив новых логов */ function appendNewLogs(newLogs) { if (!state.current || !els.logContent) { return; } const containerId = state.current.id; appendNewLogsForContainer(containerId, newLogs); } /** * Обновить чекбокс AJAX обновления в UI */ function updateAjaxUpdateCheckbox() { const checkbox = document.getElementById('autoupdate'); if (checkbox) { checkbox.checked = ajaxUpdateEnabled; } // Обновляем видимость кнопки refresh в зависимости от состояния ajax autoupdate updateRefreshButtonVisibility(); } /** * Обновить видимость кнопки refresh в header и состояние кнопки update */ function updateRefreshButtonVisibility() { const refreshButtons = document.querySelectorAll('.log-refresh-btn'); refreshButtons.forEach(btn => { if (ajaxUpdateEnabled) { // Если ajax autoupdate включен, скрываем кнопку refresh btn.style.display = 'none'; } else { // Если ajax autoupdate выключен, показываем кнопку refresh btn.style.display = 'inline-flex'; } }); // Обновляем состояние кнопки update setAjaxUpdateState(ajaxUpdateEnabled); } /** * Инициализировать чекбокс AJAX обновления */ function initAjaxUpdateCheckbox() { const checkbox = document.getElementById('autoupdate'); if (!checkbox) { console.error('AJAX Update Checkbox not found in HTML'); return; } // Настраиваем чекбокс checkbox.title = 'Автоматическое обновление логов через AJAX'; // Добавляем обработчик изменения checkbox.addEventListener('change', function() { if (this.checked) { enableAjaxLogUpdate(); } else { disableAjaxLogUpdate(); } // Обновляем видимость кнопки refresh и состояние кнопки update при изменении состояния чекбокса updateRefreshButtonVisibility(); }); // Устанавливаем начальное состояние (включен по умолчанию) checkbox.checked = true; ajaxUpdateEnabled = true; // Инициализируем чекбокс автообновления при восстановлении панелей const autoRefreshCheckbox = els.autoRefreshOnRestore; if (autoRefreshCheckbox) { // Восстанавливаем состояние из localStorage const savedState = localStorage.getItem('lb_auto_refresh_on_restore'); autoRefreshCheckbox.checked = savedState === 'true'; // Добавляем обработчик изменения autoRefreshCheckbox.addEventListener('change', function() { localStorage.setItem('lb_auto_refresh_on_restore', this.checked ? 'true' : 'false'); console.log('Auto-refresh on restore setting changed:', this.checked); }); autoRefreshCheckbox.title = 'Автоматически обновлять логи панелей при восстановлении из localStorage'; } // Обновляем видимость кнопки refresh и состояние кнопки update при инициализации updateRefreshButtonVisibility(); } /** * Инициализация AJAX обновления */ async function initAjaxUpdate() { initAjaxUpdateCheckbox(); // Получаем настройки с сервера try { const token = localStorage.getItem('access_token'); if (token) { const response = await fetch('/api/settings', { headers: { 'Authorization': `Bearer ${token}` } }); if (response.ok) { const settings = await response.json(); ajaxUpdateIntervalMs = settings.ajax_update_interval || 2000; // AJAX Update: Интервал обновления получен с сервера } else { console.warn('AJAX Update: Не удалось получить настройки с сервера, используем значение по умолчанию'); ajaxUpdateIntervalMs = 2000; // 2 секунды по умолчанию } } else { console.warn('AJAX Update: Токен не найден, используем значение по умолчанию'); ajaxUpdateIntervalMs = 2000; // 2 секунды по умолчанию } } catch (error) { console.error('AJAX Update: Ошибка получения настроек:', error); ajaxUpdateIntervalMs = 2000; // 2 секунды по умолчанию } // AJAX Update: Интервал обновления установлен // НЕ останавливаем AJAX обновление при смене контейнера const originalSwitchToSingle = window.switchToSingle; window.switchToSingle = function(containerId) { // Очищаем состояние для всех контейнеров containerStates.clear(); return originalSwitchToSingle.call(this, containerId); }; // НЕ останавливаем AJAX обновление при переключении в multi-view const originalSwitchToMultiView = window.switchToMultiView; window.switchToMultiView = function() { // Очищаем состояние для всех контейнеров containerStates.clear(); return originalSwitchToMultiView.call(this); }; console.log('AJAX обновление логов инициализировано'); // Обновляем видимость кнопки refresh и состояние кнопки update после инициализации updateRefreshButtonVisibility(); } // Запускаем инициализацию AJAX обновления initAjaxUpdate().then(() => { // Автоматически запускаем AJAX обновление (так как чекбокс включен по умолчанию) setTimeout(() => { if (ajaxUpdateEnabled) { enableAjaxLogUpdate(); } }, 1000); // Запускаем через 1 секунду после инициализации }); // Экспортируем функции в глобальную область для отладки window.enableAjaxLogUpdate = enableAjaxLogUpdate; window.disableAjaxLogUpdate = disableAjaxLogUpdate; window.toggleAjaxLogUpdate = toggleAjaxLogUpdate; window.performAjaxLogUpdate = performAjaxLogUpdate; window.updateContainerLogs = updateContainerLogs; // Добавляем обработчик изменения выбранных контейнеров в multi-view const originalToggleContainerSelection = window.toggleContainerSelection; window.toggleContainerSelection = function(containerId) { const result = originalToggleContainerSelection.call(this, containerId); // Если AJAX обновление активно, очищаем состояние для измененных контейнеров if (ajaxUpdateEnabled) { // Очищаем состояние для всех контейнеров, чтобы избежать проблем с синхронизацией containerStates.clear(); // AJAX Update: Очищено состояние контейнеров после изменения выбора } return result; }; // ============================================================================ // DRAG & DROP ФУНКЦИОНАЛЬНОСТЬ ДЛЯ MULTI-VIEW ПАНЕЛЕЙ // ============================================================================ /** * Находит целевую панель для drop с расширенной зоной поиска * @param {number} x - X координата курсора * @param {number} y - Y координата курсора * @param {HTMLElement} draggedElement - Перетаскиваемый элемент * @returns {HTMLElement|null} Найденная панель или null */ function findTargetPanel(x, y, draggedElement) { // Сначала пробуем найти панель точно под курсором let elementBelow = document.elementFromPoint(x, y); let targetPanel = elementBelow?.closest('.multi-view-panel'); if (targetPanel && targetPanel !== draggedElement) { const targetId = targetPanel.getAttribute('data-container-id'); return targetPanel; } // Если не нашли, расширяем зону поиска в радиусе 50px const searchRadius = 50; const searchPoints = [ { x: x - searchRadius, y: y }, { x: x + searchRadius, y: y }, { x: x, y: y - searchRadius }, { x: x, y: y + searchRadius }, { x: x - searchRadius/2, y: y - searchRadius/2 }, { x: x + searchRadius/2, y: y - searchRadius/2 }, { x: x - searchRadius/2, y: y + searchRadius/2 }, { x: x + searchRadius/2, y: y + searchRadius/2 } ]; for (const point of searchPoints) { elementBelow = document.elementFromPoint(point.x, point.y); targetPanel = elementBelow?.closest('.multi-view-panel'); if (targetPanel && targetPanel !== draggedElement) { return targetPanel; } } // Если все еще не нашли, проверяем все панели на пересечение с курсором const allPanels = document.querySelectorAll('.multi-view-panel'); for (const panel of allPanels) { if (panel === draggedElement) continue; const rect = panel.getBoundingClientRect(); if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) { return panel; } } return null; } /** * Настраивает drag & drop функциональность для панели multi-view * @param {HTMLElement} panel - Панель для настройки drag & drop */ function setupDragAndDrop(panel) { const header = panel.querySelector('.multi-view-header'); if (!header) return; let isDragging = false; let dragStartX = 0; let dragStartY = 0; let draggedElement = null; let draggedIndex = -1; let dropTarget = null; // Обработчик начала перетаскивания header.addEventListener('mousedown', (e) => { // Проверяем, что клик по заголовку, а не по кнопкам уровней if (e.target.closest('.level-btn')) return; isDragging = true; draggedElement = panel; draggedIndex = Array.from(panel.parentNode.children).indexOf(panel); dragStartX = e.clientX; dragStartY = e.clientY; // Добавляем класс для визуального эффекта panel.classList.add('dragging'); // Предотвращаем выделение текста e.preventDefault(); console.log(`Drag started for panel: ${panel.getAttribute('data-container-id')}`); }); // Обработчик движения мыши document.addEventListener('mousemove', (e) => { if (!isDragging || !draggedElement) return; const deltaX = e.clientX - dragStartX; const deltaY = e.clientY - dragStartY; // Минимальное расстояние для начала перетаскивания if (Math.abs(deltaX) < 5 && Math.abs(deltaY) < 5) return; // Обновляем позицию элемента draggedElement.style.transform = `translate(${deltaX}px, ${deltaY}px)`; // Находим элемент под курсором с расширенной зоной поиска const targetPanel = findTargetPanel(e.clientX, e.clientY, draggedElement); // Убираем подсветку с предыдущей цели if (dropTarget && dropTarget !== targetPanel) { dropTarget.classList.remove('drop-target'); } // Подсвечиваем новую цель (без перестановки во время перетаскивания) if (targetPanel && targetPanel !== draggedElement) { // Убираем подсветку с предыдущей цели if (dropTarget && dropTarget !== targetPanel) { dropTarget.classList.remove('drop-target'); } dropTarget = targetPanel; targetPanel.classList.add('drop-target'); } else { if (dropTarget) { dropTarget.classList.remove('drop-target'); } dropTarget = null; } }); // Обработчик окончания перетаскивания document.addEventListener('mouseup', (e) => { if (!isDragging || !draggedElement) return; isDragging = false; // Убираем визуальные эффекты draggedElement.classList.remove('dragging'); draggedElement.style.transform = ''; // Если есть целевая панель, выполняем перестановку if (dropTarget) { const containerId = draggedElement.getAttribute('data-container-id'); const targetContainerId = dropTarget.getAttribute('data-container-id'); console.log(`Dropping panel ${containerId} at position of ${targetContainerId}`); // Сохраняем ссылку на элемент для setTimeout const targetElement = dropTarget; // Добавляем класс для анимации перестановки targetElement.classList.add('swapping'); // Выполняем перестановку swapPanels(draggedElement, targetElement); // Убираем класс анимации через короткое время setTimeout(() => { if (targetElement && targetElement.classList) { targetElement.classList.remove('swapping'); } // Автоматически прокручиваем к последним логам после перестановки setTimeout(() => { scrollToBottom(); }, 100); }, 300); // Убираем подсветку targetElement.classList.remove('drop-target'); // Сохраняем новый порядок в localStorage savePanelOrder(); } // Сбрасываем переменные draggedElement = null; draggedIndex = -1; dropTarget = null; }); } /** * Мгновенно меняет местами две панели в DOM и обновляет массив selectedContainers * @param {HTMLElement} panel1 - Первая панель * @param {HTMLElement} panel2 - Вторая панель */ function swapPanels(panel1, panel2) { if (!panel1 || !panel2 || panel1 === panel2) return; const containerId1 = panel1.getAttribute('data-container-id'); const containerId2 = panel2.getAttribute('data-container-id'); if (!containerId1 || !containerId2) { console.error('Missing container IDs:', { containerId1, containerId2 }); return; } console.log(`Before swap - Panel1: ${containerId1}, Panel2: ${containerId2}`); console.log('Before swap - selectedContainers:', [...state.selectedContainers]); // Меняем местами панели в DOM const parent = panel1.parentNode; // Получаем позиции панелей в DOM const panel1NextSibling = panel1.nextSibling; const panel2NextSibling = panel2.nextSibling; // Вставляем panel1 на место panel2 parent.insertBefore(panel1, panel2NextSibling); // Вставляем panel2 на место panel1 parent.insertBefore(panel2, panel1NextSibling); // Обновляем массив selectedContainers const index1 = state.selectedContainers.indexOf(containerId1); const index2 = state.selectedContainers.indexOf(containerId2); if (index1 !== -1 && index2 !== -1) { // Меняем местами элементы в массиве state.selectedContainers[index1] = containerId2; state.selectedContainers[index2] = containerId1; console.log(`After swap - Panel1: ${containerId1}, Panel2: ${containerId2}`); console.log('After swap - selectedContainers:', [...state.selectedContainers]); // Проверяем, что атрибуты data-container-id остались правильными const newContainerId1 = panel1.getAttribute('data-container-id'); const newContainerId2 = panel2.getAttribute('data-container-id'); console.log(`After swap - Panel1 data-container-id: ${newContainerId1}, Panel2 data-container-id: ${newContainerId2}`); } else { console.error('Could not find container IDs in selectedContainers:', { index1, index2, containerId1, containerId2 }); } } /** * Переставляет контейнеры в массиве selectedContainers * @param {string} draggedId - ID перетаскиваемого контейнера * @param {string} targetId - ID целевого контейнера */ function reorderContainers(draggedId, targetId) { const draggedIndex = state.selectedContainers.indexOf(draggedId); const targetIndex = state.selectedContainers.indexOf(targetId); if (draggedIndex === -1 || targetIndex === -1) return; // Удаляем перетаскиваемый элемент state.selectedContainers.splice(draggedIndex, 1); // Вставляем его в новую позицию const newTargetIndex = draggedIndex < targetIndex ? targetIndex - 1 : targetIndex; state.selectedContainers.splice(newTargetIndex, 0, draggedId); console.log('Reordered containers:', state.selectedContainers); } /** * Сохраняет порядок панелей в localStorage */ function savePanelOrder() { const panelOrder = Array.from(document.querySelectorAll('.multi-view-panel')) .map(panel => panel.getAttribute('data-container-id')) .filter(id => id); // Удаляем дубликаты, сохраняя порядок первого вхождения const uniquePanelOrder = [...new Set(panelOrder)]; localStorage.setItem('lb_panel_order', JSON.stringify(uniquePanelOrder)); console.log('Panel order saved:', uniquePanelOrder); // Автоматически прокручиваем к последним логам после сохранения порядка setTimeout(() => { scrollToBottom(); }, 200); } /** * Загружает порядок панелей из localStorage * @returns {Array} Массив ID контейнеров в сохраненном порядке */ function loadPanelOrder() { try { const savedOrder = localStorage.getItem('lb_panel_order'); console.log('loadPanelOrder: Raw savedOrder from localStorage:', savedOrder); if (savedOrder) { const order = JSON.parse(savedOrder); console.log('loadPanelOrder: Parsed order:', order); // Удаляем дубликаты из загруженного порядка const uniqueOrder = [...new Set(order)]; console.log('loadPanelOrder: Unique order:', uniqueOrder); return uniqueOrder; } else { console.log('loadPanelOrder: No saved order found in localStorage'); } } catch (error) { console.error('loadPanelOrder: Error loading panel order:', error); } return null; } /** * Применяет сохраненный порядок панелей */ function applyPanelOrder() { if (!state.multiViewMode) { return; } const savedOrder = loadPanelOrder(); if (!savedOrder || savedOrder.length === 0) { return; } const grid = document.getElementById('multiViewGrid'); if (!grid) { return; } // Создаем карту панелей по ID контейнера const panels = Array.from(grid.children); const panelMap = {}; panels.forEach(panel => { const containerId = panel.getAttribute('data-container-id'); if (containerId) { panelMap[containerId] = panel; } }); // Переставляем панели согласно сохраненному порядку savedOrder.forEach(containerId => { const panel = panelMap[containerId]; if (panel && panel.parentNode === grid) { grid.appendChild(panel); // Убеждаемся, что WebSocket соединение установлено для переставленной панели const service = state.services.find(s => s.id === containerId); if (service) { // Проверяем, есть ли уже WebSocket соединение const existingWs = state.wsConnections && state.wsConnections[containerId]; if (!existingWs || existingWs.readyState !== WebSocket.OPEN) { setTimeout(() => { console.log(`Re-establishing WebSocket for reordered panel: ${service.name} (${containerId})`); openMultiViewWs(service); }, 100); } } } }); // Добавляем панели для новых контейнеров, которых нет в сохраненном порядке const currentContainers = [...state.selectedContainers]; const newContainers = currentContainers.filter(id => !savedOrder.includes(id)); newContainers.forEach(containerId => { // Проверяем, что панель для этого контейнера еще не существует const existingPanel = grid.querySelector(`[data-container-id="${containerId}"]`); if (!existingPanel) { const service = state.services.find(s => s.id === containerId); if (service) { console.log(`Creating new panel for container: ${service.name} (${containerId})`); const panel = createMultiViewPanel(service); grid.appendChild(panel); // Создаем WebSocket соединение для новой панели setTimeout(() => { console.log(`Setting up WebSocket for new panel: ${service.name} (${containerId})`); openMultiViewWs(service); }, 100); } } else { console.log(`Panel for container ${containerId} already exists, skipping creation`); } }); // Обновляем массив selectedContainers, сохраняя все текущие контейнеры // но применяя сохраненный порядок для тех, которые есть в сохраненном порядке const orderedContainers = savedOrder.filter(id => currentContainers.includes(id)); state.selectedContainers = [...orderedContainers, ...newContainers]; // Обновляем grid template columns для нового количества панелей const totalPanels = state.selectedContainers.length; let columns = 1; if (totalPanels === 1) columns = 1; else if (totalPanels === 2) columns = 2; else if (totalPanels <= 4) columns = 2; else if (totalPanels <= 6) columns = 3; else columns = 4; grid.style.gridTemplateColumns = `repeat(${columns}, 1fr)`; console.log(`Updated grid template columns to: repeat(${columns}, 1fr) for ${totalPanels} panels`); console.log('Applied panel order:', state.selectedContainers); // Инициализируем кнопки уровней логирования для восстановленных панелей setTimeout(() => { console.log('applyPanelOrder: Initializing level buttons for restored panels'); initializeLevelButtons(); // Обновляем логи для восстановленных панелей setTimeout(() => { console.log('applyPanelOrder: Refreshing logs for restored panels'); refreshLogsAndCounters(); // Дополнительная прокрутка к последним логам setTimeout(() => { scrollToBottom(); }, 1300); }, 300); // Проверяем, работают ли обработчики событий корректно // Если нет, обновляем страницу для полной инициализации setTimeout(() => { const testButton = document.querySelector('.level-btn'); if (testButton) { console.log('applyPanelOrder: Testing event handlers functionality'); // Создаем тестовое событие для проверки const testEvent = new MouseEvent('click', { bubbles: true, cancelable: true, view: window }); // Проверяем, есть ли обработчик на кнопке const hasHandler = testButton.onclick !== null || testButton.getAttribute('onclick') !== null || (window.levelButtonClickHandler && document.addEventListener); if (!hasHandler) { console.log('applyPanelOrder: Event handlers not working properly, refreshing logs'); // Обновляем логи панелей вместо обновления страницы refreshLogsAndCounters(); } else { console.log('applyPanelOrder: Event handlers working correctly'); } } }, 500); }, 200); } /** * Очищает дубликаты из localStorage и обновляет порядок панелей */ function cleanupDuplicatePanels() { try { const savedOrder = localStorage.getItem('lb_panel_order'); if (savedOrder) { const order = JSON.parse(savedOrder); const uniqueOrder = [...new Set(order)]; // Если были найдены дубликаты, обновляем localStorage if (order.length !== uniqueOrder.length) { localStorage.setItem('lb_panel_order', JSON.stringify(uniqueOrder)); // Если мы в multiview режиме, пересоздаем панели if (state.multiViewMode) { console.log('Recreating panels to remove duplicates'); // Очищаем текущий grid const grid = document.getElementById('multiViewGrid'); if (grid) { grid.innerHTML = ''; } // Пересоздаем панели в правильном порядке setTimeout(() => { applyPanelOrder(); }, 100); } } } } catch (error) { console.error('Error cleaning up duplicate panels:', error); } } // Экспортируем функции для использования в других частях кода window.setupDragAndDrop = setupDragAndDrop; window.savePanelOrder = savePanelOrder; window.loadPanelOrder = loadPanelOrder; window.applyPanelOrder = applyPanelOrder; window.cleanupDuplicatePanels = cleanupDuplicatePanels; window.swapPanels = swapPanels; })();