logboard/app/static/js/index.js
2025-08-20 17:32:07 +03:00

5813 lines
244 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/**
* LogBoard+ - Веб-панель для просмотра логов микросервисов
* Автор: Сергей Антропов
* Сайт: https://devops.org.ru
* Версия: 2.0
*/
console.log('LogBoard+ script loaded - VERSION 2');
/**
* Глобальное состояние приложения
* Содержит все данные о контейнерах, настройках и режимах отображения
*/
const state = {
services: [], // Список всех доступных сервисов
current: null, // Текущий выбранный контейнер для single view
open: {}, // Открытые WebSocket соединения: id -> {ws, logEl, wrapEl, counters, pausedBuffer:[], serviceName}
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'), // Переключатель переноса строк
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){
console.log('setWsState: Устанавливаем состояние', s);
console.log('setWsState: Текущие соединения:', Object.keys(state.open));
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);
console.log('determineWsState: Проверяем', openConnections.length, 'соединений');
console.log('determineWsState: Все соединения:', openConnections);
// Если нет открытых соединений, проверяем сервер через AJAX
if (openConnections.length === 0) {
console.log('determineWsState: Нет соединений, проверяем сервер');
// Асинхронно проверяем сервер, но возвращаем '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) {
console.log(`determineWsState: Соединение ${id}, readyState:`, obj.ws.readyState, 'WebSocket:', obj.ws);
if (obj.ws.readyState === WebSocket.OPEN) {
hasActiveConnection = true;
console.log(`determineWsState: Соединение ${id} активно`);
} else if (obj.ws.readyState === WebSocket.CONNECTING) {
hasConnecting = true;
console.log(`determineWsState: Соединение ${id} подключается`);
} else if (obj.ws.readyState === WebSocket.CLOSED || obj.ws.readyState === WebSocket.CLOSING) {
closedConnections.push(id);
console.log(`determineWsState: Соединение ${id} закрыто/закрывается`);
}
} else {
console.log(`determineWsState: Соединение ${id} не найдено или нет WebSocket, obj:`, obj);
closedConnections.push(id);
}
}
// Удаляем закрытые соединения
closedConnections.forEach(id => {
console.log(`determineWsState: Удаляем закрытое соединение ${id}`);
delete state.open[id];
});
// Если есть активные соединения или есть соединения в процессе установки
if (hasActiveConnection || hasConnecting) {
console.log('determineWsState: Есть активные/подключающиеся соединения, возвращаем on');
return 'on';
} else {
console.log('determineWsState: Нет активных соединений, проверяем сервер');
// Асинхронно проверяем сервер, но возвращаем 'off' для немедленного отображения
// Если сервер доступен, checkWebSocketStatus установит 'on'
setTimeout(() => {
checkWebSocketStatus();
}, 100);
return 'off';
}
}
// Функция для проверки состояния WebSocket через AJAX
async function checkWebSocketStatus() {
try {
const token = localStorage.getItem('access_token');
if (!token) {
console.log('checkWebSocketStatus: Нет токена, устанавливаем off');
setWsState('off');
return;
}
console.log('checkWebSocketStatus: Отправляем запрос к /api/websocket/status');
const response = await fetch('/api/websocket/status', {
headers: {
'Authorization': `Bearer ${token}`
}
});
console.log('checkWebSocketStatus: Получен ответ, статус:', response.status, response.statusText);
if (response.ok) {
const data = await response.json();
console.log('checkWebSocketStatus: Получен ответ от сервера:', data);
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'
console.log('checkWebSocketStatus: Сервер доступен, устанавливаем on');
setWsState('on');
} else if (data.status === 'no_containers') {
console.log('checkWebSocketStatus: Нет контейнеров, устанавливаем off');
setWsState('off');
} else {
console.log('checkWebSocketStatus: Ошибка сервера, устанавливаем err');
setWsState('err');
}
} else {
console.log('checkWebSocketStatus: HTTP ошибка, устанавливаем err');
setWsState('err');
}
} catch (error) {
console.error('checkWebSocketStatus: Ошибка запроса:', error);
setWsState('err');
}
}
// Интервал для автоматической проверки состояния WebSocket
let wsStatusInterval = null;
// Функция для запуска автоматической проверки состояния WebSocket
function startWebSocketStatusCheck() {
if (wsStatusInterval) {
clearInterval(wsStatusInterval);
}
// Проверяем каждые 3 секунды
wsStatusInterval = setInterval(() => {
console.log('Автоматическая проверка состояния WebSocket');
checkWebSocketStatus();
}, 3000);
console.log('Запущена автоматическая проверка состояния WebSocket');
}
// Функция для остановки автоматической проверки
function stopWebSocketStatusCheck() {
if (wsStatusInterval) {
clearInterval(wsStatusInterval);
wsStatusInterval = null;
console.log('Остановлена автоматическая проверка состояния WebSocket');
}
}
/**
* Устанавливает визуальное состояние кнопки AJAX обновления
* @param {boolean} enabled - Включено ли AJAX обновление
*/
function setAjaxUpdateState(enabled) {
console.log('setAjaxUpdateState: enabled =', enabled, 'els.ajaxUpdateBtn =', !!els.ajaxUpdateBtn);
if (els.ajaxUpdateBtn) {
// Удаляем все классы состояний
els.ajaxUpdateBtn.classList.remove('ajax-on', 'ajax-off');
// Добавляем соответствующий класс
if (enabled) {
els.ajaxUpdateBtn.classList.add('ajax-on');
els.ajaxUpdateBtn.textContent = 'update';
console.log('setAjaxUpdateState: Устанавливаем зеленый цвет (ajax-on)');
} else {
els.ajaxUpdateBtn.classList.add('ajax-off');
els.ajaxUpdateBtn.textContent = 'update';
console.log('setAjaxUpdateState: Устанавливаем красный цвет (ajax-off)');
}
} else {
console.error('setAjaxUpdateState: Кнопка ajaxUpdateBtn не найдена!');
}
}
/**
* Обновляет отображение всех логов при изменении фильтров
* Перерисовывает логи с учетом текущих настроек фильтрации и уровней
*/
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();
}
}, 100);
}
/**
* Экранирует HTML символы для безопасного отображения
* @param {string} s - Строка для экранирования
* @returns {string} Экранированная строка
*/
function escapeHtml(s){ return s.replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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';
}
// WARNING - ищем точное совпадение уровня логирования
if (/\s- WARNING -|\s\[warn\]|level=warn/.test(l)) {
return 'warn';
}
// INFO - ищем точное совпадение уровня логирования
if (/\s- INFO -|\s\[info\]|level=info/.test(l)) {
return 'ok';
}
// Дополнительные проверки для других форматов (только если не найдены точные совпадения)
if (/\bdebug\b/i.test(l)) return 'dbg';
if (/\berror\b/i.test(l)) return 'err';
if (/\bwarning\b/i.test(l)) return 'warn';
if (/\binfo\b/i.test(l)) return 'ok';
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;
console.log(`allowedByContainerLevel: containerId=${containerId}, cls=${cls}, result=${result}, levels=`, containerLevels);
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;
console.log(`updateContainerLogVisibility: Обновляем видимость логов для контейнера ${containerId}`);
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) {
console.log('Restoring log levels state from localStorage');
// Восстанавливаем глобальные настройки для 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 => {
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();
}
/**
* Применяет фильтр к строке лога
* Проверяет, соответствует ли строка текущему фильтру (безопасный 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<id8.length;i++){ h = (h*31 + id8.charCodeAt(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<parts.length;i++){
const seg = parts[i];
const m = seg.match(/^([0-9;]+)m(.*)$/s);
if(!m){ html += escapeHtml(seg); continue; }
const codes = m[1].split(';').map(Number);
let rest = m[2];
for(const c of codes){
if (c===0) classes = [];
else if (c===1) classes.push('ansi-bold');
else if (c===3) classes.push('ansi-italic');
else if (c===4) classes.push('ansi-underline');
else if (c>=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 += `<span class="${classes.join(' ')}">` + escapeHtml(rest) + `</span>`;
else html += escapeHtml(rest);
}
return html;
}
function panelTemplate(svc){
const div = document.createElement('div'); div.className='panel'; div.dataset.cid = svc.id;
div.innerHTML = `
<div class="title">
<span>${escapeHtml((svc.project?`[${svc.project}] `:'')+(svc.service||svc.name))}</span>
<span class="counter">dbg:<b class="cdbg">0</b> info:<b class="cinfo">0</b> warn:<b class="cwarn">0</b> err:<b class="cerr">0</b></span>
<div class="toolbar">
<button class="primary t-reconnect">reconnect</button>
<button class="t-snapshot">snapshot</button>
<button class="t-close">close</button>
</div>
</div>
<div class="logwrap"><pre class="log"></pre></div>`;
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 = `
<div class="container-name">
<i class="fas fa-cube"></i>
${escapeHtml(svc.name)}
</div>
<div class="container-service">
${escapeHtml(svc.service || svc.name)}
${escapeHtml(svc.project || 'standalone')}
</div>
<div class="container-status">
<span class="status-indicator ${statusClass}"></span>
${escapeHtml(svc.status)}
${svc.status === 'running' && svc.host_port ? ` on ${svc.host_port} port` : ''}
${svc.url ? `<a href="${svc.url}" target="_blank" class="container-link" title="Открыть сайт"><i class="fas fa-external-link-alt"></i></a>` : ''}
</div>
<div class="container-select">
<input type="checkbox" id="select-${svc.id}" class="container-checkbox" data-container-id="${svc.id}" ${state.selectedContainers.includes(svc.id) ? 'checked' : ''}>
<label for="select-${svc.id}" class="container-checkbox-label" title="Выбрать для мультипросмотра"></label>
</div>
`;
// Устанавливаем состояние 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 = `
<div class="mini-container-icon">
<i class="fas fa-cube"></i>
</div>
<div class="mini-container-name">${escapeHtml(shortName)}</div>
<div class="mini-container-status ${statusClass}"></div>
`;
// Добавляем обработчики для всплывающих подсказок
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+клик с предыдущим выбором - диапазонный выбор
console.log('Shift+клик для диапазонного выбора:', lastSelectedContainerId, 'to', svc.id);
selectContainerRange(lastSelectedContainerId, svc.id);
} else if (e.shiftKey) {
// Shift+клик - добавляем/убираем из мультивыбора
console.log('Shift+клик на миникарточку:', svc.name);
toggleContainerSelection(svc.id);
lastSelectedContainerId = svc.id;
} else if (e.ctrlKey || e.metaKey) {
// Ctrl/Cmd+клик - добавляем/убираем из мультивыбора
console.log('Ctrl+клик на миникарточку:', svc.name);
toggleContainerSelection(svc.id);
lastSelectedContainerId = svc.id;
} else {
// Обычный клик - переключаемся в single view
console.log('Обычный клик на миникарточку:', svc.name);
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 = `
<input type="checkbox" id="project-all" checked>
<label for="project-all">All Projects</label>
`;
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 = `
<input type="checkbox" id="project-${project}">
<label for="project-${project}">${escapeHtml(project)}</label>
`;
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 = '<div class="excluded-container-item"><span class="excluded-container-name">Нет исключенных контейнеров</span></div>';
return;
}
containers.forEach(container => {
const item = document.createElement('div');
item.className = 'excluded-container-item';
item.innerHTML = `
<span class="excluded-container-name">${escapeHtml(container)}</span>
<button class="remove-excluded-btn" onclick="removeExcludedContainer('${escapeHtml(container)}')">×</button>
`;
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');
console.log('Found checkboxes:', checkboxes.length);
checkboxes.forEach(checkbox => {
const containerId = checkbox.getAttribute('data-container-id');
const containerItem = checkbox.closest('.container-item');
console.log('Processing checkbox for container:', containerId, 'checked:', checkbox.checked, 'should be:', state.selectedContainers.includes(containerId));
if (state.selectedContainers.includes(containerId)) {
checkbox.checked = true;
containerItem.classList.add('selected');
console.log('Container selected:', containerId);
} else {
checkbox.checked = false;
containerItem.classList.remove('selected');
console.log('Container deselected:', containerId);
}
});
// Обновляем миникарточки контейнеров
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 = `
<div class="mini-container-tooltip-header">
<i class="fas fa-cube mini-container-tooltip-icon"></i>
<span>Контейнер</span>
</div>
<div class="mini-container-tooltip-name">${escapeHtml(service.name)}</div>
<div class="mini-container-tooltip-service">${escapeHtml(service.service || service.name)}${escapeHtml(service.project || 'standalone')}</div>
<div class="mini-container-tooltip-status">
<span class="mini-container-tooltip-status-indicator ${statusClass}"></span>
<span>${escapeHtml(service.status)}</span>
</div>
${service.status === 'running' && service.host_port ? `<div class="mini-container-tooltip-port">Порт: ${escapeHtml(service.host_port)}</div>` : ''}
${service.url ? `<a href="${service.url}" target="_blank" class="mini-container-tooltip-url"><i class="fas fa-external-link-alt"></i> Открыть сайт</a>` : ''}
`;
// Добавляем подсказку в 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) {
console.log('Switching to single view for:', selectedService.name);
console.log('updateMultiViewMode: About to call switchToSingle - VERSION 2');
// Сохраняем режим просмотра в 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 = '<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: var(--muted); font-size: 14px;">Выберите контейнер для просмотра логов</div>';
}
}
// Очищаем активное состояние всех контейнеров
updateActiveContainerUI(null);
}
console.log(`Multi-view mode updated: multiViewMode = ${state.multiViewMode}`);
// Сохраняем состояние кнопок loglevels при переключении режимов
saveLogLevelsState();
// Обновляем состояние кнопок уровней логирования при переключении режимов
setTimeout(() => {
initializeLevelButtons();
}, 100);
// Обновляем видимость кнопок LogLevels
updateLogLevelsVisibility();
}
/**
* Настраивает интерфейс для режима мультипросмотра (multi-view)
* Создает сетку панелей для одновременного просмотра нескольких контейнеров
* Открывает WebSocket соединения для всех выбранных контейнеров
*/
async function setupMultiView() {
console.log('setupMultiView called');
// Проверяем, что у нас действительно больше одного контейнера
if (state.selectedContainers.length <= 1) {
console.log('setupMultiView: Not enough containers for multi-view, switching to single view');
if (state.selectedContainers.length === 1) {
const selectedService = state.services.find(s => s.id === state.selectedContainers[0]);
if (selectedService) {
console.log('setupMultiView: Calling switchToSingle for:', selectedService.name);
await switchToSingle(selectedService);
}
} else {
console.log('setupMultiView: No containers selected, clearing log area');
clearLogArea();
}
return;
}
// Дополнительная проверка - если уже есть мультипросмотр, удаляем его для пересоздания
const existingMultiView = document.getElementById('multiViewGrid');
if (existingMultiView) {
console.log('setupMultiView: Multi-view already exists, removing for recreation');
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;
console.log(`setupMultiView: Creating grid with ${columns} columns for ${state.selectedContainers.length} containers`);
gridContainer.style.gridTemplateColumns = `repeat(${columns}, 1fr)`;
console.log(`setupMultiView: Grid template columns set to: repeat(${columns}, 1fr)`);
// Создаем панели для каждого выбранного контейнера
console.log(`setupMultiView: Creating panels for ${state.selectedContainers.length} containers:`, state.selectedContainers);
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}`);
return;
}
console.log(`setupMultiView: Creating panel ${index + 1} for service: ${service.name} (${containerId})`);
const panel = createMultiViewPanel(service);
gridContainer.appendChild(panel);
console.log(`setupMultiView: Panel ${index + 1} added to grid, total children: ${gridContainer.children.length}`);
});
if (logContent) {
logContent.appendChild(gridContainer);
console.log(`setupMultiView: Grid added to log content, grid children: ${gridContainer.children.length}`);
// Проверяем, что все панели созданы правильно
const panels = gridContainer.querySelectorAll('.multi-view-panel');
console.log(`setupMultiView: Total panels found in grid: ${panels.length}`);
panels.forEach((panel, index) => {
const containerId = panel.getAttribute('data-container-id');
const title = panel.querySelector('.multi-view-title');
console.log(`setupMultiView: Panel ${index + 1}: containerId=${containerId}, title="${title?.textContent}"`);
});
} else {
console.error('setupMultiView: logContent not found');
}
// Применяем настройки wrap lines
applyWrapSettings();
// Очищаем активное состояние всех контейнеров в мультипросмотре
updateActiveContainerUI(null);
// Принудительно обновляем стили логов для multi-view
setTimeout(() => {
updateLogStyles();
// Дополнительная проверка для multi-view логов
console.log('setupMultiView: Force fixing multi-view styles');
forceFixMultiViewStyles();
}, 200);
// Подключаем WebSocket для каждого контейнера
console.log(`setupMultiView: Setting up WebSockets for ${state.selectedContainers.length} containers`);
state.selectedContainers.forEach((containerId, index) => {
const service = state.services.find(s => s.id === containerId);
if (service) {
console.log(`setupMultiView: Setting up WebSocket ${index + 1} for multi-view container: ${service.name} (${containerId})`);
openMultiViewWs(service);
} else {
console.error(`setupMultiView: Service not found for container ID: ${containerId}`);
}
});
console.log(`setupMultiView: Multi-view setup completed for ${state.selectedContainers.length} containers`);
// Обновляем счетчики для 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);
console.log(`createMultiViewPanel: Panel element created with data-container-id: ${service.id}`);
panel.innerHTML = `
<div class="multi-view-header">
<h4 class="multi-view-title">${escapeHtml(service.name)}</h4>
<div class="multi-view-levels">
<button class="level-btn debug-btn" data-level="debug" data-container-id="${service.id}" title="DEBUG">
<span class="level-label">DEBUG</span>
<span class="level-value" data-container="${service.id}">0</span>
</button>
<button class="level-btn info-btn" data-level="info" data-container-id="${service.id}" title="INFO">
<span class="level-label">INFO</span>
<span class="level-value" data-container="${service.id}">0</span>
</button>
<button class="level-btn warn-btn" data-level="warn" data-container-id="${service.id}" title="WARN">
<span class="level-label">WARN</span>
<span class="level-value" data-container="${service.id}">0</span>
</button>
<button class="level-btn error-btn" data-level="err" data-container-id="${service.id}" title="ERROR">
<span class="level-label">ERROR</span>
<span class="level-value" data-container="${service.id}">0</span>
</button>
<button class="level-btn other-btn" data-level="other" data-container-id="${service.id}" title="OTHER">
<span class="level-label">OTHER</span>
<span class="level-value" data-container="${service.id}">0</span>
</button>
</div>
</div>
<div class="multi-view-content">
<div class="multi-view-log" data-container-id="${service.id}"></div>
</div>
`;
// Проверяем, что элемент создался правильно
const logElement = panel.querySelector(`.multi-view-log[data-container-id="${service.id}"]`);
if (logElement) {
console.log(`Multi-view log element created successfully for ${service.name}`);
// Очищаем пустые строки после создания панели
cleanMultiViewEmptyLines(logElement);
// Очищаем дублированные строки после создания панели
cleanMultiViewDuplicateLines(logElement);
} else {
console.error(`Failed to create multi-view log element for ${service.name}`);
}
// Инициализируем состояние кнопок уровней логирования для этого контейнера
setTimeout(() => {
const levelBtns = panel.querySelectorAll('.level-btn');
levelBtns.forEach(btn => {
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);
});
}, 100);
console.log(`Multi-view panel created for ${service.name}`);
// Применяем стили к новой панели
setTimeout(() => {
updateLogStyles();
}, 200);
return panel;
}
/**
* Открывает WebSocket соединение для контейнера в режиме мультипросмотра
* Настраивает обработчики сообщений и управляет отображением логов
* @param {Object} service - Объект сервиса/контейнера
*/
function openMultiViewWs(service) {
const containerId = service.id;
console.log(`openMultiViewWs: Starting WebSocket setup for ${service.name} (${containerId})`);
console.log(`openMultiViewWs: Current multiViewMode: ${state.multiViewMode}`);
console.log(`openMultiViewWs: Selected containers: ${state.selectedContainers.join(', ')}`);
// Закрываем существующее соединение
closeWs(containerId);
// Создаем новое WebSocket соединение
const ws = new WebSocket(wsUrl(containerId, service.service, service.project));
ws.onopen = () => {
console.log(`Multi-view WebSocket connected for ${service.name}`);
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) => {
console.log(`Multi-view WebSocket received message for ${service.name}: ${event.data.substring(0, 100)}...`);
// Устанавливаем состояние 'on' при получении сообщений
setWsState('on');
const parts = (event.data||'').split(/\r?\n/);
// Проверяем на дублирование в исходных данных
if (event.data.includes('FoundINFO:')) {
console.log('🚨 WebSocket: ОБНАРУЖЕНЫ данные с FoundINFO:');
console.log('🚨 WebSocket: Полные данные:', event.data);
}
// Проверяем на дублирование строк и убираем дубликаты
const lines = event.data.split(/\r?\n/).filter(line => line.trim().length > 0);
const uniqueLines = [...new Set(lines)];
if (lines.length !== uniqueLines.length) {
console.log('🚨 WebSocket: ОБНАРУЖЕНО ДУБЛИРОВАНИЕ строк!');
console.log('🚨 WebSocket: Всего строк:', lines.length);
console.log('🚨 WebSocket: Уникальных строк:', uniqueLines.length);
console.log('🚨 WebSocket: Дублированные строки:', lines.filter((line, index) => lines.indexOf(line) !== index));
// Используем только уникальные строки
const uniqueParts = uniqueLines.map(line => line.trim()).filter(line => line.length > 0);
for (let i=0;i<uniqueParts.length;i++){
// Проверяем каждую часть на FoundINFO:
if (uniqueParts[i].includes('FoundINFO:')) {
console.log('🚨 WebSocket: Часть с FoundINFO:', uniqueParts[i]);
}
// harvest instance ids if present
const pr = parsePrefixAndStrip(uniqueParts[i]);
if (pr){ if (!(pr.id8 in inst.filters)) { inst.filters[pr.id8] = true; updateIdFiltersBar(); } }
handleLine(containerId, uniqueParts[i]);
}
} else {
// Если дублирования нет, обрабатываем как обычно
for (let i=0;i<parts.length;i++){
if (parts[i].length===0 && i===parts.length-1) continue;
// Проверяем каждую часть на FoundINFO:
if (parts[i].includes('FoundINFO:')) {
console.log('🚨 WebSocket: Часть с FoundINFO:', parts[i]);
}
// harvest instance ids if present
const pr = parsePrefixAndStrip(parts[i]);
if (pr){ if (!(pr.id8 in inst.filters)) { inst.filters[pr.id8] = true; updateIdFiltersBar(); } }
handleLine(containerId, parts[i]);
}
}
};
ws.onclose = () => {
console.log(`Multi-view WebSocket closed for ${service.name}`);
};
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}"]`);
console.log(`openMultiViewWs: Found log element for ${service.name}:`, !!logEl);
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: [] // Добавляем буфер для логов
};
console.log(`openMultiViewWs: WebSocket setup completed for ${service.name} (${containerId})`);
}
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 = `
<div class="single-view-panel" id="singleViewPanel">
<div class="single-view-header">
<h4 class="single-view-title" id="singleViewTitle">No container selected</h4>
</div>
<div class="single-view-content">
<pre class="log" id="logContent">No container selected</pre>
</div>
</div>
`;
}
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();
// Восстанавливаем режим просмотра из 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();
} 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;
console.log(`openWs: Called for ${svc.name} (${id}) in multiViewMode: ${state.multiViewMode}`);
console.log(`openWs: Selected containers: ${state.selectedContainers.join(', ')}`);
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)};
console.log(`openWs: Created state.open[${id}] with logEl:`, !!logEl, 'wrapEl:', !!wrapEl);
ws.onopen = ()=> {
console.log(`WebSocket ${id}: Соединение открыто`);
setWsState('on');
// Очищаем сообщение "Connecting..." когда соединение установлено
if (state.current && state.current.id === id && els.logContent) {
els.logContent.innerHTML = '';
}
// Также очищаем legacy элемент лога
if (obj.logEl) {
obj.logEl.innerHTML = '';
}
// Принудительно проверяем состояние через AJAX через 500мс и 1 секунду
setTimeout(() => {
console.log(`WebSocket ${id}: Принудительная проверка состояния после открытия (500мс)`);
checkWebSocketStatus();
}, 500);
setTimeout(() => {
console.log(`WebSocket ${id}: Принудительная проверка состояния после открытия (1с)`);
checkWebSocketStatus();
}, 1000);
};
ws.onclose = ()=> {
console.log(`WebSocket ${id}: Соединение закрыто`);
setWsState(determineWsState());
// Принудительно проверяем состояние через AJAX через 500мс
setTimeout(() => {
console.log(`WebSocket ${id}: Принудительная проверка состояния после закрытия`);
checkWebSocketStatus();
}, 500);
};
ws.onerror = (error)=> {
console.log(`WebSocket ${id}: Ошибка соединения:`, error);
setWsState('err');
};
ws.onmessage = (ev)=>{
console.log(`Received WebSocket message: ${ev.data.substring(0, 100)}...`);
// Устанавливаем состояние 'on' при получении сообщений
setWsState('on');
const parts = (ev.data||'').split(/\r?\n/);
console.log(`openWs: Processing ${parts.length} lines for container ${id}`);
// Проверяем на дублирование в исходных данных для Single View
if (ev.data.includes('FoundINFO:')) {
console.log('🚨 Single View WebSocket: ОБНАРУЖЕНЫ данные с FoundINFO:');
console.log('🚨 Single View WebSocket: Полные данные:', ev.data);
}
// Проверяем на дублирование строк и убираем дубликаты
const lines = ev.data.split(/\r?\n/).filter(line => line.trim().length > 0);
const uniqueLines = [...new Set(lines)];
if (lines.length !== uniqueLines.length) {
console.log('🚨 Single View WebSocket: ОБНАРУЖЕНО ДУБЛИРОВАНИЕ строк!');
console.log('🚨 Single View WebSocket: Всего строк:', lines.length);
console.log('🚨 Single View WebSocket: Уникальных строк:', uniqueLines.length);
console.log('🚨 Single View WebSocket: Дублированные строки:', lines.filter((line, index) => lines.indexOf(line) !== index));
// Используем только уникальные строки
const uniqueParts = uniqueLines.map(line => line.trim()).filter(line => line.length > 0);
for (let i=0;i<uniqueParts.length;i++){
// Проверяем каждую часть на FoundINFO:
if (uniqueParts[i].includes('FoundINFO:')) {
console.log('🚨 Single View WebSocket: Часть с FoundINFO:', uniqueParts[i]);
}
// harvest instance ids if present
const pr = parsePrefixAndStrip(uniqueParts[i]);
if (pr){ if (!(pr.id8 in inst.filters)) { inst.filters[pr.id8] = true; updateIdFiltersBar(); } }
console.log(`openWs: Calling handleLine for container ${id}, line: "${uniqueParts[i].substring(0, 50)}..."`);
handleLine(id, uniqueParts[i]);
}
} else {
// Если дублирования нет, обрабатываем как обычно
for (let i=0;i<parts.length;i++){
if (parts[i].length===0 && i===parts.length-1) continue;
// Проверяем каждую часть на FoundINFO:
if (parts[i].includes('FoundINFO:')) {
console.log('🚨 Single View WebSocket: Часть с FoundINFO:', parts[i]);
}
// harvest instance ids if present
const pr = parsePrefixAndStrip(parts[i]);
if (pr){ if (!(pr.id8 in inst.filters)) { inst.filters[pr.id8] = true; updateIdFiltersBar(); } }
console.log(`openWs: Calling handleLine for container ${id}, line: "${parts[i].substring(0, 50)}..."`);
handleLine(id, parts[i]);
}
}
// Обновляем счетчики после обработки всех строк
cdbg.textContent = counters.dbg;
cinfo.textContent = counters.info;
cwarn.textContent = counters.warn;
cerr.textContent = counters.err;
if (cother) cother.textContent = counters.other;
};
// Убираем автоматический refresh - теперь только по кнопке
}
/**
* Функция для обработки переноса строк в multi view
* Если символов больше 5, то перенос строк работает
* Если меньше 5, то переноса строк нет
* @param {string} text - исходный текст
* @returns {string} - обработанный текст с правильными переносами
*/
function processMultiViewLineBreaks(text) {
// Если символов меньше или равно 5, возвращаем без переносов
if (text.length <= 5) {
return text;
}
// Если символов больше 5, добавляем перенос строки в конце
return text + '\n';
}
/**
* Функция для радикальной очистки пустых строк в multi view
* Удаляет все пустые строки и лишние переносы строк
* @param {HTMLElement} multiViewLog - элемент лога multi view
*/
function cleanMultiViewEmptyLines(multiViewLog) {
if (!multiViewLog) return;
let removedCount = 0;
// Удаляем все пустые строки (элементы .line без текста)
const lines = Array.from(multiViewLog.querySelectorAll('.line'));
lines.forEach(line => {
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()) {
console.log(`cleanDuplicateLines: Удаляем дублированную строку: ${currentText.substring(0, 50)}...`);
currentLine.remove();
removedCount++;
}
}
}
// После удаления дубликатов очищаем лишние пустые строки
if (logElement.classList.contains('multi-view-log')) {
cleanMultiViewEmptyLines(logElement);
} else {
cleanSingleViewEmptyLines(logElement);
}
if (removedCount > 0) {
console.log(`cleanDuplicateLines: Удалено ${removedCount} дублированных строк`);
}
}
/**
* Функция для радикальной очистки пустых строк в 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) {
console.log('Periodic check: Force fixing multi-view styles');
forceFixMultiViewStyles();
// Дополнительно исправляем все контейнеры
console.log('Periodic check: Fixing all containers');
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:')) {
console.log('🔍 processMultiViewSpecialReplacements: Найдена строка с FoundINFO:', text);
}
// Проверяем на дублирование строк в исходном тексте
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:')) {
console.log('🔍 processSingleViewSpecialReplacements: Найдена строка с FoundINFO:', text);
}
// Проверяем на дублирование строк в исходном тексте
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() {
console.log('=== Тест функции processMultiViewLineBreaks ===');
console.log('Тест 1 (1 символ):', JSON.stringify(processMultiViewLineBreaks('a')));
console.log('Тест 2 (3 символа):', JSON.stringify(processMultiViewLineBreaks('abc')));
console.log('Тест 3 (5 символов):', JSON.stringify(processMultiViewLineBreaks('abcde')));
console.log('Тест 4 (6 символов):', JSON.stringify(processMultiViewLineBreaks('abcdef')));
console.log('Тест 5 (с переносами):', JSON.stringify(processMultiViewLineBreaks('a\nb\nc')));
console.log('Тест 6 (длинная строка):', JSON.stringify(processMultiViewLineBreaks('Это длинная строка с текстом')));
console.log('=== Конец теста ===');
}
// Функция для тестирования исправлений дублирования
function testDuplicateRemoval() {
console.log('=== Тест исправлений дублирования ===');
// Создаем тестовый элемент
const testElement = document.createElement('div');
testElement.innerHTML = `
<span class="line">Первая строка</span>
<span class="line">Вторая строка</span>
<span class="line">Вторая строка</span>
<span class="line">Третья строка</span>
<span class="line">Третья строка</span>
<span class="line">Четвертая строка</span>
`;
console.log('До очистки дубликатов:', testElement.querySelectorAll('.line').length, 'строк');
console.log('HTML до очистки:', testElement.innerHTML);
cleanMultiViewDuplicateLines(testElement);
console.log('После очистки дубликатов:', testElement.querySelectorAll('.line').length, 'строк');
console.log('HTML после очистки:', testElement.innerHTML);
console.log('=== Конец теста дублирования ===');
}
// Функция для тестирования Single View дублирования
function testSingleViewDuplicateRemoval() {
console.log('=== Тест Single View дублирования ===');
// Создаем тестовый элемент для Single View
const testElement = document.createElement('div');
testElement.innerHTML = `
<span class="line">INFO: 172.22.0.1:59030 - "GET /api/logs/stats/b816b3f326b9 HTTP/1.1" 200 OK</span>
<span class="line">INFO: 172.22.0.1:61392 - "GET / HTTP/1.1" 401 Unauthorized</span>
<span class="line">INFO: 172.22.0.1:61392 - "GET / HTTP/1.1" 401 Unauthorized</span>
<span class="line">INFO: 172.22.0.1:61392 - "GET / HTTP/1.1" 200 OK</span>
<span class="line">INFO: 172.22.0.1:61392 - "GET / HTTP/1.1" 200 OK</span>
<span class="line">INFO: 172.22.0.1:61392 - "GET /api/projects HTTP/1.1" 200 OK</span>
`;
console.log('До очистки Single View дубликатов:', testElement.querySelectorAll('.line').length, 'строк');
console.log('HTML до очистки:', testElement.innerHTML);
cleanDuplicateLines(testElement);
console.log('После очистки Single View дубликатов:', testElement.querySelectorAll('.line').length, 'строк');
console.log('HTML после очистки:', testElement.innerHTML);
console.log('=== Конец теста Single View дублирования ===');
}
// Функция для тестирования очистки пустых строк в Single View
function testSingleViewEmptyLinesRemoval() {
console.log('=== Тест очистки пустых строк в Single View ===');
// Создаем тестовый элемент с пустыми строками
const testElement = document.createElement('div');
testElement.innerHTML = `
<span class="line">INFO: 172.22.0.1:59030 - "GET /api/logs/stats/b816b3f326b9 HTTP/1.1" 200 OK</span>
<span class="line"> </span>
<span class="line"></span>
<span class="line">INFO: 172.22.0.1:61392 - "GET / HTTP/1.1" 401 Unauthorized</span>
<span class="line"> </span>
<span class="line">INFO: 172.22.0.1:61392 - "GET / HTTP/1.1" 200 OK</span>
<span class="line"></span>
<span class="line">INFO: 172.22.0.1:61392 - "GET /api/projects HTTP/1.1" 200 OK</span>
`;
console.log('До очистки пустых строк:', testElement.querySelectorAll('.line').length, 'строк');
console.log('HTML до очистки:', testElement.innerHTML);
cleanSingleViewEmptyLines(testElement);
console.log('После очистки пустых строк:', testElement.querySelectorAll('.line').length, 'строк');
console.log('HTML после очистки:', testElement.innerHTML);
console.log('=== Конец теста очистки пустых строк ===');
}
// Функция для тестирования правильного отображения переносов строк
function testSingleViewLineBreaks() {
console.log('=== Тест правильного отображения переносов строк в Single View ===');
// Создаем тестовый элемент с правильными переносами строк
const testElement = document.createElement('div');
testElement.innerHTML = `
<span class="line">INFO: 172.22.0.1:59030 - "GET /api/logs/stats/b816b3f326b9 HTTP/1.1" 200 OK</span>\n
<span class="line">INFO: 172.22.0.1:61392 - "GET / HTTP/1.1" 401 Unauthorized</span>\n
<span class="line">INFO: 172.22.0.1:61392 - "GET / HTTP/1.1" 200 OK</span>\n
<span class="line">INFO: 172.22.0.1:61392 - "GET /api/projects HTTP/1.1" 200 OK</span>\n
`;
console.log('Тестовый элемент с переносами строк:');
console.log('Количество строк:', testElement.querySelectorAll('.line').length);
console.log('HTML:', testElement.innerHTML);
console.log('Текстовое содержимое:', testElement.textContent);
// Проверяем, что переносы строк присутствуют
const textContent = testElement.textContent;
const lineBreaks = (textContent.match(/\n/g) || []).length;
console.log('Количество переносов строк в тексте:', lineBreaks);
console.log('=== Конец теста переносов строк ===');
}
// Тестовая функция для проверки работы cleanMultiViewEmptyLines
function testCleanMultiViewEmptyLines() {
console.log('=== Тест функции cleanMultiViewEmptyLines ===');
// Создаем тестовый элемент
const testElement = document.createElement('div');
testElement.innerHTML = `
<span class="line">Первая строка лога</span>
<span class="line"> </span>
<span class="line"></span>
<span class="line">Вторая строка лога</span>
<span class="line"> </span>
<span class="line">Третья строка лога</span>
`;
console.log('До очистки:', testElement.innerHTML);
cleanMultiViewEmptyLines(testElement);
console.log('После очистки:', testElement.innerHTML);
console.log('=== Конец теста ===');
}
// Тестовая функция для проверки работы normalizeSpaces
function testNormalizeSpaces() {
console.log('=== Тест функции normalizeSpaces ===');
console.log('Тест 1 (обычная строка):', JSON.stringify(normalizeSpaces('Hello World')));
console.log('Тест 2 (двойные пробелы):', JSON.stringify(normalizeSpaces('Hello World')));
console.log('Тест 3 (множественные пробелы):', JSON.stringify(normalizeSpaces('Hello World')));
console.log('Тест 4 (пробелы в начале и конце):', JSON.stringify(normalizeSpaces(' Hello World ')));
console.log('Тест 5 (табуляция и пробелы):', JSON.stringify(normalizeSpaces('Hello\t\tWorld')));
console.log('Тест 6 (смешанные пробелы):', JSON.stringify(normalizeSpaces('Hello \t World')));
console.log('Тест 7 (пустая строка):', JSON.stringify(normalizeSpaces('')));
console.log('Тест 8 (null):', JSON.stringify(normalizeSpaces(null)));
console.log('=== Конец теста ===');
}
// Тестовая функция для проверки работы processMultiViewSpecialReplacements
function testMultiViewSpecialReplacements() {
console.log('=== Тест функции processMultiViewSpecialReplacements ===');
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:')) {
console.log(`handleLine: Фильтруем сообщение "Connected to container" для контейнера ${id}`);
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 = `<span class="line ${cls}">${ansiToHtml(normalizedLine)}</span>`;
// Сохраняем все логи в буфере (всегда)
if (!obj.allLogs) obj.allLogs = [];
// Для Single View сохраняем обработанную строку, для MultiView - оригинальную
const processedLine = !state.multiViewMode ? processSingleViewSpecialReplacements(normalizedLine) : normalizedLine;
const processedHtml = `<span class="line ${cls}">${ansiToHtml(processedLine)}</span>`;
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) {
console.log(`handleLine: Пропускаем дублированную строку для Single View контейнера ${id}:`, singleViewProcessedLine.substring(0, 50));
return; // Пропускаем дублированную строку
}
// Создаем HTML с обработанной строкой для Single View (без переноса строки)
const singleViewHtml = `<span class="line ${cls}">${ansiToHtml(singleViewProcessedLine)}</span>`;
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
console.log(`handleLine: Checking modern interface update - state.current:`, state.current, `id:`, id, `els.logContent:`, !!els.logContent);
if (state.current && state.current.id === id && els.logContent) {
console.log(`handleLine: Updating modern interface for container ${id} with html:`, singleViewHtml.substring(0, 100));
// Добавляем новую строку напрямую в современный интерфейс
els.logContent.insertAdjacentHTML('beforeend', singleViewHtml);
// Очищаем лишние пустые строки в современном интерфейсе
cleanSingleViewEmptyLines(els.logContent);
console.log(`handleLine: Modern interface updated, logContent children count:`, els.logContent.children.length);
if (els.autoscroll && els.autoscroll.checked) {
els.logContent.scrollTop = els.logContent.scrollHeight;
}
} else {
console.log(`handleLine: Modern interface update skipped - state.current:`, state.current?.id, `id:`, id, `logContent exists:`, !!els.logContent);
}
}
// 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) {
console.log(`handleLine: Пропускаем дублированную строку для контейнера ${id}:`, processedLine.substring(0, 50));
return; // Пропускаем дублированную строку
}
const multiViewHtml = `<span class="line ${cls}">${ansiToHtml(processedLine)}</span>`;
// Добавляем новую строку
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;
console.log(`handleLine: Trimming ${linesToRemove} lines from container ${id} (tail: ${tailLines})`);
// Удаляем первые N строк
logLines.slice(0, linesToRemove).forEach(line => {
line.remove();
});
}
if (els.autoscroll && els.autoscroll.checked) {
multiViewLog.scrollTop = multiViewLog.scrollHeight;
}
console.log(`handleLine: Updated multi-view for container ${id}, log element found: true, tail lines: ${tailLines}`);
}
} 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){
console.log('switchToSingle: ENTRY POINT - function called - VERSION 2');
console.log('switchToSingle: svc parameter:', svc);
try {
console.log('switchToSingle called for:', svc.name);
console.log('switchToSingle: Starting function execution');
console.log('switchToSingle: svc name:', svc.name, 'id:', svc.id);
console.log('switchToSingle: state.current:', state.current?.name, 'multiViewMode:', state.multiViewMode);
// Всегда очищаем мультипросмотр при переключении в single view
console.log('Clearing multi-view mode');
state.multiViewMode = false;
// Закрываем WebSocket соединения для мультипросмотра
console.log('switchToSingle: Closing WebSocket connections for multi-view');
state.selectedContainers.forEach(containerId => {
closeWs(containerId);
});
// Очищаем область логов
console.log('switchToSingle: Clearing log content');
if (els.logContent) {
els.logContent.innerHTML = '';
}
// Воссоздаем single-view-panel если его нет
const logContent = document.querySelector('.log-content');
const singleViewPanel = document.getElementById('singleViewPanel');
if (logContent && !singleViewPanel) {
console.log('switchToSingle: Recreating single-view-panel');
logContent.innerHTML = `
<div class="single-view-panel" id="singleViewPanel">
<div class="single-view-header">
<h4 class="single-view-title" id="singleViewTitle">${svc.name} (${svc.service || svc.name})</h4>
<div class="single-view-levels">
<button class="level-btn debug-btn" data-level="debug" title="DEBUG">
<span class="level-label">DEBUG</span>
<span class="level-value" data-container="single">0</span>
</button>
<button class="level-btn info-btn" data-level="info" title="INFO">
<span class="level-label">INFO</span>
<span class="level-value" data-container="single">0</span>
</button>
<button class="level-btn warn-btn" data-level="warn" title="WARN">
<span class="level-label">WARN</span>
<span class="level-value" data-container="single">0</span>
</button>
<button class="level-btn error-btn" data-level="err" title="ERROR">
<span class="level-label">ERROR</span>
<span class="level-value" data-container="single">0</span>
</button>
<button class="level-btn other-btn" data-level="other" title="OTHER">
<span class="level-label">OTHER</span>
<span class="level-value" data-container="single">0</span>
</button>
</div>
</div>
<div class="single-view-content">
<pre class="log" id="logContent">Connecting...</pre>
</div>
</div>
`;
// Обновляем ссылки на элементы
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();
} else {
console.log('Multi-view grid not found in DOM');
}
// Legacy functionality (скрытая)
console.log('switchToSingle: Setting up legacy functionality');
setLayout('tabs');
els.grid.innerHTML='';
const panel = ensurePanel(svc);
panel.querySelector('.log').textContent='';
closeWs(svc.id);
console.log('switchToSingle: Calling openWs for:', svc.name, 'id:', svc.id);
openWs(svc, panel);
state.current = svc;
console.log('switchToSingle: Set state.current to:', svc.name, 'id:', svc.id);
console.log('switchToSingle: state.current after setting:', state.current);
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 = '<span class="line info">Connecting...</span>';
}
// Обновляем logEl для современного интерфейса
const obj = state.open[svc.id];
if (obj && els.logContent) {
obj.logEl = els.logContent;
obj.wrapEl = els.logContent.parentElement;
console.log('switchToSingle: Updated obj.logEl and obj.wrapEl for modern interface');
// Если у нас уже есть логи в буфере, отображаем их
if (obj.allLogs && obj.allLogs.length > 0) {
console.log(`switchToSingle: Restoring ${obj.allLogs.length} buffered log lines`);
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 = ()=> {
console.log(`WebSocket ${id}: Соединение открыто`);
setWsState('on');
};
ws.onclose = ()=> {
console.log(`WebSocket ${id}: Соединение закрыто`);
setWsState(determineWsState());
// Принудительно проверяем состояние через AJAX через 500мс
setTimeout(() => {
console.log(`WebSocket ${id}: Принудительная проверка состояния после закрытия`);
checkWebSocketStatus();
}, 500);
};
ws.onerror = (error)=> {
console.log(`WebSocket ${id}: Ошибка соединения:`, error);
setWsState('err');
};
ws.onmessage = (ev)=>{
// Устанавливаем состояние 'on' при получении сообщений
setWsState('on');
const parts = (ev.data||'').split(/\r?\n/);
for (let i=0;i<parts.length;i++){
if (parts[i].length===0 && i===parts.length-1) continue;
const pr = parsePrefixAndStrip(parts[i]);
if (pr){ if (!(pr.id8 in inst.filters)) { inst.filters[pr.id8] = true; updateIdFiltersBar(); } }
handleLine(id, parts[i]);
}
cdbg.textContent = counters.dbg;
cinfo.textContent = counters.info;
cwarn.textContent = counters.warn;
cerr.textContent = counters.err;
if (cother) cother.textContent = counters.other;
// Обновляем видимость счетчиков
updateCounterVisibility();
// Добавляем обработчики для счетчиков
addCounterClickHandlers();
};
// Show filter bar and clear previous filters
inst.filters = {};
updateIdFiltersBar();
}
if (els.groupBtn && els.groupBtn.onclick !== null) {
els.groupBtn.onclick = ()=>{
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 {
console.log('Updating multi-view counters for containers:', state.selectedContainers);
// Используем новую функцию пересчета счетчиков
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;
}
console.log('Recalculating multi-view counters for containers:', state.selectedContainers);
// Получаем значение 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;
console.log(`Multi-view counters recalculated (tail: ${tailLines}):`, { totalDebug, totalInfo, totalWarn, totalError, totalOther });
}
// Функция для обновления видимости счетчиков
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';
}
});
}
// Функция для обновления логов и счетчиков
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();
}, 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();
}, 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) {
console.log('Инициализация обработчика клика для кнопки update');
els.ajaxUpdateBtn.addEventListener('click', () => {
console.log('Клик по кнопке update - вызываем toggleAjaxLogUpdate()');
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 = `
<div class="notification-content">
<h4><i class="fas fa-keyboard"></i> Горячие клавиши</h4>
<ul>
<li><kbd>[</kbd> <kbd>]</kbd> - Навигация между контейнерами</li>
<li><kbd>Ctrl</kbd> + <kbd>R</kbd> или <kbd>Ctrl</kbd> + <kbd>K</kbd> - Обновить логи</li>
<li><kbd>Ctrl</kbd> + <kbd>B</kbd> - Свернуть/развернуть панель</li>
<li>Кнопка <i class="fas fa-chevron-left"></i> - управление панелью</li>
</ul>
<button class="notification-close" onclick="this.parentElement.parentElement.remove()">
<i class="fas fa-times"></i>
</button>
</div>
`;
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 = '<i class="fas fa-chevron-left"></i>';
els.sidebarToggle.title = 'Свернуть панель (Ctrl+B / Ctrl+И)';
localStorage.setItem('lb_sidebar_collapsed', 'false');
} else {
// Сворачиваем sidebar
els.sidebar.classList.add('collapsed');
els.sidebarToggle.innerHTML = '<i class="fas fa-chevron-right"></i>';
els.sidebarToggle.title = 'Развернуть панель (Ctrl+B / Ctrl+И)';
localStorage.setItem('lb_sidebar_collapsed', 'true');
}
// Принудительно обновляем стили логов после переключения sidebar
setTimeout(() => {
updateLogStyles();
// Дополнительная проверка для multi-view логов
if (state.multiViewMode) {
console.log('Sidebar toggle: Force fixing multi-view styles');
forceFixMultiViewStyles();
}
}, 100);
}
}
// Функция для принудительного исправления стилей multi-view логов
function forceFixMultiViewStyles() {
const multiViewLogs = document.querySelectorAll('.multi-view-content .multi-view-log');
console.log(`Force fixing styles for ${multiViewLogs.length} multi-view logs`);
multiViewLogs.forEach((log, index) => {
const containerId = log.getAttribute('data-container-id');
console.log(`Force fixing multi-view log ${index + 1} for container: ${containerId}`);
// Универсальное исправление для всех контейнеров
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 => {
console.log(`Universal fix for container:`, 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');
});
// Применяем настройки 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');
console.log(`Found ${multiViewLogs.length} multi-view logs to update`);
multiViewLogs.forEach((log, index) => {
const containerId = log.getAttribute('data-container-id');
console.log(`Updating multi-view log ${index + 1}/${multiViewLogs.length} for container: ${containerId}`);
// Принудительно устанавливаем правильные стили независимо от состояния 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();
console.log('Log styles updated, sidebar collapsed:', isCollapsed, 'multi-view logs found:', multiViewLogs.length);
// Принудительно исправляем стили multi-view логов
forceFixMultiViewStyles();
// Дополнительная проверка через 500ms для multi view логов
if (multiViewLogs.length > 0) {
setTimeout(() => {
console.log('Performing delayed update for multi-view logs...');
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) {
console.log('Initialization: Force fixing multi-view styles');
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 = ()=> {
Object.keys(state.open).forEach(id=>{
const svc = state.services.find(s=> s.id===id);
if (!svc) return;
// В multi view режиме используем openMultiViewWs
if (state.multiViewMode && state.selectedContainers.includes(id)) {
console.log(`Refresh: Using openMultiViewWs for ${svc.name} in multi view mode`);
closeWs(id);
openMultiViewWs(svc);
} else {
// В обычном режиме используем openWs
const panel = els.grid.querySelector(`.panel[data-cid="${id}"]`);
if (!panel) return;
state.open[id].logEl.textContent='';
closeWs(id);
openWs(svc, panel);
}
});
// Обновляем современный интерфейс
if (state.current && els.logContent) {
els.logContent.textContent = 'Reconnecting...';
}
// Пересчитываем счетчики после изменения 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) {
console.log('Window resize: Force fixing multi-view styles');
setTimeout(() => {
forceFixMultiViewStyles();
// Дополнительно исправляем все контейнеры
console.log('Window resize: Fixing all containers');
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<state.services.length-1) await switchToSingle(state.services[idx+1]);
}
// Обновление логов по Ctrl/Cmd+R или Ctrl/Cmd+K
if ((e.ctrlKey || e.metaKey) && (e.key==='r' || e.key==='к')){
e.preventDefault();
console.log('Hotkey refresh triggered:', e.key);
await refreshLogsAndCounters();
}
// Сворачивание/разворачивание sidebar по Ctrl/Cmd+B/И
if ((e.ctrlKey || e.metaKey) && (e.key==='b' || e.key==='и' || e.code==='KeyB')){
e.preventDefault();
toggleSidebar();
}
// Фокус на фильтр по Ctrl/Cmd+F
if ((e.ctrlKey || e.metaKey) && (e.key==='f' || e.key==='а')){
e.preventDefault();
console.log('Ctrl+F pressed, els.filter:', els.filter);
// Функция для фокусировки на фильтре
const focusFilter = () => {
// Сначала попробуем использовать 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');
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(() => {
console.log('Проверка состояния WebSocket после загрузки сервисов');
setWsState(determineWsState());
}, 1000);
// Запускаем автоматическую проверку состояния WebSocket
startWebSocketStatusCheck();
// Добавляем обработчик клика для кнопки WebSocket статуса
if (els.wsstate) {
els.wsstate.addEventListener('click', () => {
console.log('Ручная проверка состояния WebSocket');
checkWebSocketStatus();
});
}
// Проверяем, есть ли сохраненный контейнер в localStorage
const savedContainerId = getSelectedContainerFromStorage();
if (savedContainerId) {
console.log('Found saved container, switching to it:', 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 = '<i class="fas fa-chevron-right"></i>';
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');
console.log(`Found ${allMultiViewLogs.length} multi-view-log elements on page:`, Array.from(allMultiViewLogs).map(el => el.getAttribute('data-container-id')));
state.selectedContainers.forEach(containerId => {
console.log(`Processing container ${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) {
console.log(`Found multi-view-log for container ${containerId}, current lines:`, multiViewLog.querySelectorAll('.line').length);
// Получаем все строки логов
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);
}
}
});
// Обработчики для кнопок уровней логирования в заголовках
document.addEventListener('click', (e) => {
if (e.target.closest('.level-btn')) {
const levelBtn = e.target.closest('.level-btn');
const level = levelBtn.getAttribute('data-level');
const containerId = levelBtn.getAttribute('data-container-id');
console.log(`Клик по кнопке уровня логирования: level=${level}, containerId=${containerId}, multiViewMode=${state.multiViewMode}`);
// Переключаем состояние кнопки
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() {
console.log('Fixing all multi-view containers');
const allLogs = document.querySelectorAll('.multi-view-content .multi-view-log');
allLogs.forEach(log => {
const containerId = log.getAttribute('data-container-id');
console.log(`Fixing container:`, containerId);
// Принудительно устанавливаем все стили заново
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() {
console.log('fixProblematicContainers is deprecated, use fixAllContainers instead');
window.fixAllContainers();
};
console.log('LogBoard+ инициализирован с исправлениями дублирования строк и правильными переносами строк в Single View и MultiView режимах');
console.log('Для тестирования используйте: testDuplicateRemoval(), testSingleViewDuplicateRemoval(), testSingleViewEmptyLinesRemoval() или testSingleViewLineBreaks()');
// Запускаем первоначальную очистку пустых строк
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;
console.log(`AJAX обновление логов включено с интервалом ${intervalMs}ms`);
// Запускаем первое обновление сразу
performAjaxLogUpdate();
// Устанавливаем интервал
ajaxUpdateInterval = setInterval(performAjaxLogUpdate, intervalMs);
// Обновляем UI
updateAjaxUpdateCheckbox();
// Обновляем видимость кнопки refresh и состояние кнопки update
updateRefreshButtonVisibility();
}
/**
* Отключить периодическое обновление логов через AJAX
*/
function disableAjaxLogUpdate() {
if (ajaxUpdateInterval) {
clearInterval(ajaxUpdateInterval);
ajaxUpdateInterval = null;
}
ajaxUpdateEnabled = false;
console.log('AJAX обновление логов отключено');
// Обновляем UI
updateAjaxUpdateCheckbox();
// Обновляем видимость кнопки refresh и состояние кнопки update
updateRefreshButtonVisibility();
}
/**
* Переключить состояние AJAX обновления
*/
function toggleAjaxLogUpdate() {
console.log('toggleAjaxLogUpdate: Текущее состояние ajaxUpdateEnabled =', ajaxUpdateEnabled);
if (ajaxUpdateEnabled) {
console.log('toggleAjaxLogUpdate: Отключаем AJAX update');
disableAjaxLogUpdate();
} else {
console.log('toggleAjaxLogUpdate: Включаем AJAX update');
enableAjaxLogUpdate(ajaxUpdateIntervalMs);
}
console.log('toggleAjaxLogUpdate: Новое состояние ajaxUpdateEnabled =', ajaxUpdateEnabled);
// Обновляем видимость кнопки 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 {
console.log('AJAX Update: Нет контейнеров для обновления');
return;
}
console.log(`AJAX Update: Обновляем ${containersToUpdate.length} контейнеров:`, containersToUpdate);
// Обновляем каждый контейнер
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);
}
console.log(`AJAX Update: Запрашиваем логи для ${containerId} с tail=${tailLines}`);
// Формируем заголовки запроса
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) {
console.log(`AJAX Update: К обработке ${newPortion.length} строк для ${containerId} (из ${data.logs.length}), lastSecondCount=${containerState.lastSecondCount}`);
appendNewLogsForContainer(containerId, newPortion);
containerState.lastSecondCount += newPortion.length;
} else {
console.log(`AJAX Update: Новых логов нет для ${containerId} после дедупликации по секундам`);
}
} else {
console.log(`AJAX Update: Логи не пришли для ${containerId}`);
}
// Обновляем состояние контейнера
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) {
const obj = state.open[containerId];
if (!obj) {
console.warn(`AJAX Update: Object not found for container ${containerId}`);
return;
}
// Обрабатываем каждую новую строку лога через 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);
}
console.log(`AJAX Update: Обработано ${addedCount} новых строк логов для ${containerId} через handleLine`);
}
/**
* Добавить новые логи в конец существующих (для обратной совместимости)
* @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() {
console.log('updateRefreshButtonVisibility: ajaxUpdateEnabled =', ajaxUpdateEnabled);
const refreshButtons = document.querySelectorAll('.log-refresh-btn');
console.log('updateRefreshButtonVisibility: Найдено кнопок refresh =', refreshButtons.length);
refreshButtons.forEach(btn => {
if (ajaxUpdateEnabled) {
// Если ajax autoupdate включен, скрываем кнопку refresh
btn.style.display = 'none';
console.log('updateRefreshButtonVisibility: Скрываем кнопку refresh');
} else {
// Если ajax autoupdate выключен, показываем кнопку refresh
btn.style.display = 'inline-flex';
console.log('updateRefreshButtonVisibility: Показываем кнопку refresh');
}
});
// Обновляем состояние кнопки update
console.log('updateRefreshButtonVisibility: Обновляем состояние кнопки 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;
// Обновляем видимость кнопки refresh и состояние кнопки update при инициализации
updateRefreshButtonVisibility();
console.log('AJAX Update Checkbox initialized');
}
/**
* Инициализация 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;
console.log(`AJAX Update: Интервал обновления получен с сервера: ${ajaxUpdateIntervalMs}ms`);
} 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 секунды по умолчанию
}
console.log(`AJAX Update: Интервал обновления установлен на ${ajaxUpdateIntervalMs}ms`);
// НЕ останавливаем 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) {
console.log('AJAX Update: Автоматический запуск обновления логов');
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();
console.log('AJAX Update: Очищено состояние контейнеров после изменения выбора');
}
return result;
};
})();