- Удалены избыточные console.log из JavaScript для чистой консоли браузера - Исправлена проблема с выделением текста в multi-view панелях - Сохранена функциональность drag & drop для перетаскивания панелей - Добавлены CSS правила для корректного выделения текста в логах - Исправлена синтаксическая ошибка в CSS файле Изменения: - app/static/js/index.js: удалены логи WebSocket, setupMultiView, createMultiViewPanel, Processing, AJAX Update, applyPanelOrder, Recalculating, Document click event, Container selected/deselected, switchToSingle, openWs - app/static/css/index.css: добавлены правила user-select для multi-view логов, исправлена синтаксическая ошибка
6161 lines
249 KiB
JavaScript
6161 lines
249 KiB
JavaScript
/**
|
||
* LogBoard+ - Веб-панель для просмотра логов микросервисов
|
||
* Автор: Сергей Антропов
|
||
* Сайт: https://devops.org.ru
|
||
* Версия: 2.0
|
||
*/
|
||
|
||
// LogBoard+ script loaded - VERSION 2
|
||
|
||
/**
|
||
* Глобальное состояние приложения
|
||
* Содержит все данные о контейнерах, настройках и режимах отображения
|
||
*/
|
||
const state = {
|
||
services: [], // Список всех доступных сервисов
|
||
current: null, // Текущий выбранный контейнер для single view
|
||
open: {}, // Открытые WebSocket соединения: id -> {ws, logEl, wrapEl, counters, pausedBuffer:[], serviceName}
|
||
wsConnections: {}, // WebSocket соединения для multiview: id -> WebSocket
|
||
layout: 'tabs', // Режим отображения: 'tabs' | 'grid2' | 'grid3' | 'grid4'
|
||
filter: null, // Текущий фильтр для логов
|
||
levels: {debug:true, info:true, warn:true, err:true, other:true}, // Уровни логирования для отображения
|
||
selectedContainers: [], // Массив ID выбранных контейнеров для мультипросмотра
|
||
multiViewMode: false, // Режим мультипросмотра (true = multi-view, false = single-view)
|
||
};
|
||
|
||
/**
|
||
* Ссылки на DOM элементы интерфейса
|
||
* Содержит все элементы управления и отображения
|
||
*/
|
||
const els = {
|
||
// Legacy elements (старые элементы для обратной совместимости)
|
||
tabs: document.getElementById('tabs'), // Контейнер с вкладками
|
||
grid: document.getElementById('grid'), // Контейнер с сеткой
|
||
tail: document.getElementById('tail'), // Поле ввода количества строк логов
|
||
autoscroll: document.getElementById('autoscroll'), // Чекбокс автопрокрутки
|
||
wrapToggle: document.getElementById('wrap'), // Переключатель переноса строк
|
||
autoRefreshOnRestore: document.getElementById('autoRefreshOnRestore'), // Чекбокс автообновления при восстановлении
|
||
|
||
filter: document.getElementById('filter'), // Поле фильтра логов
|
||
wsstate: document.getElementById('wsstate'), // Индикатор состояния WebSocket
|
||
ajaxUpdateBtn: document.getElementById('ajaxUpdateBtn'), // Кнопка AJAX обновления
|
||
projectBadge: document.getElementById('projectBadge'), // Бейдж текущего проекта
|
||
|
||
clearBtn: document.getElementById('clear'), // Кнопка очистки логов
|
||
refreshBtn: document.getElementById('refresh'), // Кнопка обновления
|
||
snapshotBtn: document.getElementById('snapshot'), // Кнопка создания снимка
|
||
lvlDebug: document.getElementById('lvlDebug'), // Кнопка уровня DEBUG
|
||
lvlInfo: document.getElementById('lvlInfo'), // Кнопка уровня INFO
|
||
lvlWarn: document.getElementById('lvlWarn'), // Кнопка уровня WARN
|
||
lvlErr: document.getElementById('lvlErr'), // Кнопка уровня ERROR
|
||
lvlOther: document.getElementById('lvlOther'), // Кнопка уровня OTHER
|
||
layoutBadge: document.getElementById('layoutBadge') || { textContent: '' }, // Бейдж режима отображения
|
||
aggregate: document.getElementById('aggregate') || { checked: false }, // Чекбокс агрегации
|
||
themeSwitch: document.getElementById('themeSwitch'), // Переключатель темы
|
||
copyFab: document.getElementById('copyFab'), // Кнопка копирования
|
||
groupBtn: document.getElementById('groupBtn') || { onclick: null }, // Кнопка группировки
|
||
|
||
// New modern elements (новые элементы современного интерфейса)
|
||
containerList: document.getElementById('containerList'), // Список контейнеров
|
||
logContent: document.getElementById('logContent'), // Основной контент логов
|
||
mobileToggle: document.getElementById('mobileToggle'), // Переключатель мобильного режима
|
||
optionsBtn: document.getElementById('optionsBtn'), // Кнопка настроек
|
||
helpBtn: document.getElementById('helpBtn'), // Кнопка помощи
|
||
logoutBtn: document.getElementById('logoutBtn'), // Кнопка выхода
|
||
sidebar: document.getElementById('sidebar'), // Боковая панель
|
||
sidebarToggle: document.getElementById('sidebarToggle'), // Переключатель боковой панели
|
||
header: document.getElementById('header'), // Заголовок
|
||
hotkeysModal: document.getElementById('hotkeysModal'), // Модальное окно горячих клавиш
|
||
hotkeysModalClose: document.getElementById('hotkeysModalClose'), // Кнопка закрытия модального окна
|
||
multiViewPanel: document.getElementById('multiViewPanel'), // Панель мультипросмотра
|
||
multiViewPanelTitle: document.getElementById('multiViewPanelTitle'), // Заголовок мультипросмотра
|
||
singleViewPanel: document.getElementById('singleViewPanel'), // Панель одиночного просмотра
|
||
singleViewTitle: document.getElementById('singleViewTitle'), // Заголовок одиночного просмотра
|
||
};
|
||
|
||
/**
|
||
* Инициализация переключателя темы
|
||
* Загружает сохраненную тему из localStorage и настраивает переключатель
|
||
*/
|
||
(function initTheme(){
|
||
const saved = localStorage.lb_theme || 'dark';
|
||
document.documentElement.setAttribute('data-theme', saved);
|
||
els.themeSwitch.checked = (saved==='light');
|
||
els.themeSwitch.addEventListener('change', ()=>{
|
||
const t = els.themeSwitch.checked ? 'light' : 'dark';
|
||
document.documentElement.setAttribute('data-theme', t);
|
||
localStorage.lb_theme = t;
|
||
});
|
||
})();
|
||
|
||
/**
|
||
* Устанавливает состояние WebSocket соединения в интерфейсе
|
||
* @param {string} s - Состояние: 'on', 'off', 'err', 'available'
|
||
*/
|
||
function setWsState(s){
|
||
els.wsstate.textContent = 'ws: ' + s;
|
||
|
||
// Удаляем все классы состояний
|
||
els.wsstate.classList.remove('ws-on', 'ws-off', 'ws-err', 'ws-available');
|
||
|
||
// Добавляем соответствующий класс
|
||
if (s === 'on') {
|
||
els.wsstate.classList.add('ws-on');
|
||
} else if (s === 'off') {
|
||
els.wsstate.classList.add('ws-off');
|
||
} else if (s === 'err') {
|
||
els.wsstate.classList.add('ws-err');
|
||
} else if (s === 'available') {
|
||
els.wsstate.classList.add('ws-available');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Определяет общее состояние WebSocket соединений
|
||
* Проверяет все открытые соединения и устанавливает соответствующее состояние
|
||
*/
|
||
function determineWsState() {
|
||
const openConnections = Object.keys(state.open);
|
||
|
||
// Если нет открытых соединений, проверяем сервер через AJAX
|
||
if (openConnections.length === 0) {
|
||
// Асинхронно проверяем сервер, но возвращаем 'off' для немедленного отображения
|
||
// Если сервер доступен, checkWebSocketStatus установит 'on'
|
||
setTimeout(() => {
|
||
checkWebSocketStatus();
|
||
}, 100);
|
||
return 'off';
|
||
}
|
||
|
||
// Проверяем состояние всех соединений
|
||
let hasActiveConnection = false;
|
||
let hasConnecting = false;
|
||
let closedConnections = [];
|
||
let errorConnections = [];
|
||
|
||
for (const id of openConnections) {
|
||
const obj = state.open[id];
|
||
if (obj && obj.ws) {
|
||
if (obj.ws.readyState === WebSocket.OPEN) {
|
||
hasActiveConnection = true;
|
||
} else if (obj.ws.readyState === WebSocket.CONNECTING) {
|
||
hasConnecting = true;
|
||
} else if (obj.ws.readyState === WebSocket.CLOSED || obj.ws.readyState === WebSocket.CLOSING) {
|
||
closedConnections.push(id);
|
||
}
|
||
} else {
|
||
closedConnections.push(id);
|
||
}
|
||
}
|
||
|
||
// Удаляем закрытые соединения
|
||
closedConnections.forEach(id => {
|
||
delete state.open[id];
|
||
});
|
||
|
||
// Если есть активные соединения или есть соединения в процессе установки
|
||
if (hasActiveConnection || hasConnecting) {
|
||
return 'on';
|
||
} else {
|
||
// Асинхронно проверяем сервер, но возвращаем 'off' для немедленного отображения
|
||
// Если сервер доступен, checkWebSocketStatus установит 'on'
|
||
setTimeout(() => {
|
||
checkWebSocketStatus();
|
||
}, 100);
|
||
return 'off';
|
||
}
|
||
}
|
||
|
||
// Функция для проверки состояния WebSocket через AJAX
|
||
async function checkWebSocketStatus() {
|
||
try {
|
||
const token = localStorage.getItem('access_token');
|
||
if (!token) {
|
||
setWsState('off');
|
||
return;
|
||
}
|
||
const response = await fetch('/api/websocket/status', {
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`
|
||
}
|
||
});
|
||
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
|
||
if (data.status === 'available') {
|
||
// Проверяем активные клиентские соединения
|
||
const openConnections = Object.keys(state.open);
|
||
let hasActiveConnection = false;
|
||
|
||
for (const id of openConnections) {
|
||
const obj = state.open[id];
|
||
if (obj && obj.ws && obj.ws.readyState === WebSocket.OPEN) {
|
||
hasActiveConnection = true;
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Если сервер доступен, всегда показываем 'on'
|
||
setWsState('on');
|
||
} else if (data.status === 'no_containers') {
|
||
setWsState('off');
|
||
} else {
|
||
setWsState('err');
|
||
}
|
||
} else {
|
||
setWsState('err');
|
||
}
|
||
} catch (error) {
|
||
console.error('checkWebSocketStatus: Ошибка запроса:', error);
|
||
setWsState('err');
|
||
}
|
||
}
|
||
|
||
// Интервал для автоматической проверки состояния WebSocket
|
||
let wsStatusInterval = null;
|
||
|
||
// Функция для запуска автоматической проверки состояния WebSocket
|
||
function startWebSocketStatusCheck() {
|
||
if (wsStatusInterval) {
|
||
clearInterval(wsStatusInterval);
|
||
}
|
||
|
||
// Проверяем каждые 3 секунды
|
||
wsStatusInterval = setInterval(() => {
|
||
checkWebSocketStatus();
|
||
}, 3000);
|
||
}
|
||
|
||
// Функция для остановки автоматической проверки
|
||
function stopWebSocketStatusCheck() {
|
||
if (wsStatusInterval) {
|
||
clearInterval(wsStatusInterval);
|
||
wsStatusInterval = null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Устанавливает визуальное состояние кнопки AJAX обновления
|
||
* @param {boolean} enabled - Включено ли AJAX обновление
|
||
*/
|
||
function setAjaxUpdateState(enabled) {
|
||
if (els.ajaxUpdateBtn) {
|
||
// Удаляем все классы состояний
|
||
els.ajaxUpdateBtn.classList.remove('ajax-on', 'ajax-off');
|
||
|
||
// Добавляем соответствующий класс
|
||
if (enabled) {
|
||
els.ajaxUpdateBtn.classList.add('ajax-on');
|
||
els.ajaxUpdateBtn.textContent = 'update';
|
||
} else {
|
||
els.ajaxUpdateBtn.classList.add('ajax-off');
|
||
els.ajaxUpdateBtn.textContent = 'update';
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Обновляет отображение всех логов при изменении фильтров
|
||
* Перерисовывает логи с учетом текущих настроек фильтрации и уровней
|
||
*/
|
||
function refreshAllLogs() {
|
||
// Обновляем обычный просмотр
|
||
Object.keys(state.open).forEach(id => {
|
||
const obj = state.open[id];
|
||
if (!obj || !obj.logEl) return;
|
||
|
||
// Получаем все логи из буфера
|
||
const allLogs = obj.allLogs || [];
|
||
const filteredHtml = [];
|
||
|
||
allLogs.forEach(logEntry => {
|
||
// Проверяем уровень логирования
|
||
if (!allowedByLevel(logEntry.cls)) return;
|
||
|
||
// Проверяем фильтр
|
||
if (!applyFilter(logEntry.line)) return;
|
||
|
||
filteredHtml.push(logEntry.html);
|
||
});
|
||
|
||
// Обновляем отображение
|
||
obj.logEl.innerHTML = filteredHtml.join('');
|
||
|
||
// Сразу очищаем пустые строки в legacy панели
|
||
cleanSingleViewEmptyLines(obj.logEl);
|
||
cleanDuplicateLines(obj.logEl);
|
||
|
||
// Обновляем современный интерфейс
|
||
if (state.current && state.current.id === id && els.logContent) {
|
||
els.logContent.innerHTML = obj.logEl.innerHTML;
|
||
|
||
// Очищаем дублированные строки в Single View после обновления
|
||
cleanSingleViewEmptyLines(els.logContent);
|
||
cleanDuplicateLines(els.logContent);
|
||
}
|
||
});
|
||
|
||
// Обновляем мультипросмотр
|
||
if (state.multiViewMode) {
|
||
state.selectedContainers.forEach(containerId => {
|
||
const obj = state.open[containerId];
|
||
if (!obj || !obj.logEl) return;
|
||
|
||
// Получаем все логи из буфера
|
||
const allLogs = obj.allLogs || [];
|
||
const filteredHtml = [];
|
||
|
||
allLogs.forEach(logEntry => {
|
||
// Проверяем уровень логирования
|
||
if (!allowedByLevel(logEntry.cls)) return;
|
||
|
||
// Проверяем фильтр
|
||
if (!applyFilter(logEntry.line)) return;
|
||
|
||
filteredHtml.push(logEntry.html);
|
||
});
|
||
|
||
// Обновляем отображение в мультипросмотре
|
||
const multiViewLog = document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`);
|
||
if (multiViewLog) {
|
||
multiViewLog.innerHTML = filteredHtml.join('');
|
||
|
||
// Сразу очищаем пустые строки в мультипросмотре
|
||
cleanMultiViewEmptyLines(multiViewLog);
|
||
cleanMultiViewDuplicateLines(multiViewLog);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Пересчитываем счетчики в зависимости от режима после обновления логов
|
||
setTimeout(() => {
|
||
if (state.multiViewMode) {
|
||
recalculateMultiViewCounters();
|
||
} else {
|
||
recalculateCounters();
|
||
}
|
||
// Прокручиваем к последним логам после обновления
|
||
scrollToBottom();
|
||
}, 100);
|
||
}
|
||
/**
|
||
* Экранирует HTML символы для безопасного отображения
|
||
* @param {string} s - Строка для экранирования
|
||
* @returns {string} Экранированная строка
|
||
*/
|
||
function escapeHtml(s){ return s.replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m])); }
|
||
|
||
/**
|
||
* Классифицирует строку лога по уровню логирования
|
||
* Определяет уровень на основе ключевых слов и паттернов в строке
|
||
* @param {string} line - Строка лога для классификации
|
||
* @returns {string} Класс уровня: 'dbg', 'err', 'warn', 'ok', 'other'
|
||
*/
|
||
function classify(line){
|
||
const l = line.toLowerCase();
|
||
|
||
// Проверяем различные форматы уровней логирования (более специфичные сначала)
|
||
|
||
// DEBUG - ищем точное совпадение уровня логирования
|
||
if (/\s- DEBUG -|\s\[debug\]|level=debug|\bdebug\b(?=\s|$)/.test(l)) {
|
||
return 'dbg';
|
||
}
|
||
|
||
// ERROR - ищем точное совпадение уровня логирования
|
||
if (/\s- ERROR -|\s\[error\]|level=error/.test(l)) {
|
||
return 'err';
|
||
}
|
||
|
||
// FATAL - ищем точное совпадение уровня логирования (раскрашиваем как ERROR)
|
||
if (/\s- FATAL -|\s\[fatal\]|level=fatal/.test(l)) {
|
||
return 'err';
|
||
}
|
||
|
||
// PostgreSQL FATAL - специальная проверка для FATAL логов PostgreSQL
|
||
if (/\[\d+\]\s+FATAL:/i.test(l)) {
|
||
return 'err';
|
||
}
|
||
|
||
// Простая проверка для любых строк содержащих "FATAL:"
|
||
if (l.includes('fatal:')) {
|
||
return 'err';
|
||
}
|
||
|
||
// WARNING - ищем точное совпадение уровня логирования
|
||
if (/\s- WARNING -|\s\[warn\]|level=warn/.test(l)) {
|
||
return 'warn';
|
||
}
|
||
|
||
// INFO - ищем точное совпадение уровня логирования
|
||
if (/\s- INFO -|\s\[info\]|level=info/.test(l)) {
|
||
return 'ok';
|
||
}
|
||
|
||
// LOG - ищем точное совпадение уровня логирования (раскрашиваем как INFO)
|
||
if (/\s- LOG -|\s\[log\]|level=log|^log:/i.test(l)) {
|
||
return 'ok';
|
||
}
|
||
|
||
// PostgreSQL LOG - специальная проверка для логов PostgreSQL
|
||
if (/\[\d+\]\s+LOG:/i.test(l)) {
|
||
return 'ok';
|
||
}
|
||
|
||
// Простая проверка для любых строк содержащих "LOG:"
|
||
if (l.includes('log:')) {
|
||
return 'ok';
|
||
}
|
||
|
||
// Дополнительные проверки для других форматов (только если не найдены точные совпадения)
|
||
if (/\bdebug\b/i.test(l)) return 'dbg';
|
||
if (/\berror\b/i.test(l)) return 'err';
|
||
if (/\bfatal\b/i.test(l)) {
|
||
return 'err'; // FATAL также раскрашиваем как ERROR
|
||
}
|
||
if (/\bwarning\b/i.test(l)) return 'warn';
|
||
if (/\binfo\b/i.test(l)) return 'ok';
|
||
if (/\blog\b/i.test(l)) {
|
||
return 'ok'; // LOG также раскрашиваем как INFO
|
||
}
|
||
|
||
// Отладка для неклассифицированных логов (убрано для снижения шума в консоли)
|
||
|
||
return 'other';
|
||
}
|
||
/**
|
||
* Проверяет, разрешен ли отображение лога данного уровня
|
||
* @param {string} cls - Класс уровня лога ('dbg', 'err', 'warn', 'ok', 'other')
|
||
* @returns {boolean} Разрешен ли отображение
|
||
*/
|
||
function allowedByLevel(cls){
|
||
if (cls==='dbg') return state.levels.debug;
|
||
if (cls==='err') return state.levels.err;
|
||
if (cls==='warn') return state.levels.warn;
|
||
if (cls==='ok') return state.levels.info;
|
||
if (cls==='other') return state.levels.other; // Показываем неклассифицированные строки в зависимости от настройки
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Проверяет, разрешен ли отображение лога данного уровня для конкретного контейнера
|
||
* Используется в режиме мультипросмотра для индивидуальных настроек контейнеров
|
||
* @param {string} cls - Класс уровня лога ('dbg', 'err', 'warn', 'ok', 'other')
|
||
* @param {string} containerId - ID контейнера
|
||
* @returns {boolean} Разрешен ли отображение
|
||
*/
|
||
function allowedByContainerLevel(cls, containerId) {
|
||
// Если настройки контейнера не инициализированы, инициализируем их
|
||
if (!state.containerLevels) {
|
||
state.containerLevels = {};
|
||
}
|
||
if (!state.containerLevels[containerId]) {
|
||
state.containerLevels[containerId] = {debug: true, info: true, warn: true, err: true, other: true};
|
||
}
|
||
|
||
const containerLevels = state.containerLevels[containerId];
|
||
let result;
|
||
|
||
if (cls==='dbg') result = containerLevels.debug;
|
||
else if (cls==='err') result = containerLevels.err;
|
||
else if (cls==='warn') result = containerLevels.warn;
|
||
else if (cls==='ok') result = containerLevels.info;
|
||
else if (cls==='other') result = containerLevels.other;
|
||
else result = true;
|
||
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Обновляет видимость логов в Single View режиме
|
||
* Перерисовывает логи с учетом текущих фильтров и настроек уровней
|
||
* @param {HTMLElement} logElement - Элемент для обновления
|
||
*/
|
||
function updateLogVisibility(logElement) {
|
||
if (!logElement || !state.current) return;
|
||
|
||
const containerId = state.current.id;
|
||
const obj = state.open[containerId];
|
||
if (!obj || !obj.allLogs) return;
|
||
|
||
// Пересоздаем содержимое лога с учетом фильтров, сохраняя HTML-разметку
|
||
let visibleHtml = '';
|
||
obj.allLogs.forEach(logEntry => {
|
||
const shouldShow = allowedByLevel(logEntry.cls) && applyFilter(logEntry.line);
|
||
if (shouldShow) {
|
||
visibleHtml += logEntry.html + '\n';
|
||
}
|
||
});
|
||
|
||
logElement.innerHTML = visibleHtml;
|
||
|
||
// Обновляем счетчики
|
||
recalculateCounters();
|
||
|
||
// Обновляем состояние кнопок уровней логирования только для single-view
|
||
const singleLevelBtns = document.querySelectorAll('.single-view-levels .level-btn');
|
||
singleLevelBtns.forEach(btn => {
|
||
const level = btn.getAttribute('data-level');
|
||
const isActive = state.levels[level];
|
||
btn.classList.toggle('active', isActive);
|
||
btn.classList.toggle('disabled', !isActive);
|
||
});
|
||
}
|
||
|
||
// Функция для обновления видимости логов конкретного контейнера в Multi View
|
||
function updateContainerLogVisibility(containerId) {
|
||
if (!state.multiViewMode) return;
|
||
|
||
const logElement = document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`);
|
||
if (!logElement) return;
|
||
|
||
const obj = state.open[containerId];
|
||
if (!obj || !obj.allLogs) return;
|
||
|
||
// Пересоздаем содержимое лога с учетом фильтров контейнера, сохраняя HTML-разметку
|
||
let visibleHtml = '';
|
||
obj.allLogs.forEach(logEntry => {
|
||
const shouldShow = allowedByContainerLevel(logEntry.cls, containerId) && applyFilter(logEntry.line);
|
||
if (shouldShow) {
|
||
visibleHtml += logEntry.html + '\n';
|
||
}
|
||
});
|
||
|
||
logElement.innerHTML = visibleHtml;
|
||
|
||
// Обновляем счетчики для этого контейнера
|
||
updateContainerCounters(containerId);
|
||
|
||
// Обновляем состояние кнопок уровней логирования только для этого контейнера
|
||
const levelBtns = document.querySelectorAll(`.level-btn[data-container-id="${containerId}"]`);
|
||
levelBtns.forEach(btn => {
|
||
const level = btn.getAttribute('data-level');
|
||
// Используем настройки контейнера, если они есть
|
||
const containerLevels = state.containerLevels && state.containerLevels[containerId] ?
|
||
state.containerLevels[containerId] : {debug: true, info: true, warn: true, err: true, other: true};
|
||
const isActive = containerLevels[level];
|
||
btn.classList.toggle('active', isActive);
|
||
btn.classList.toggle('disabled', !isActive);
|
||
});
|
||
}
|
||
|
||
// Функция для обновления счетчиков конкретного контейнера
|
||
function updateContainerCounters(containerId) {
|
||
const obj = state.open[containerId];
|
||
if (!obj || !obj.allLogs) return;
|
||
|
||
// Получаем значение Tail Lines
|
||
const tailLines = parseInt(els.tail.value) || 50;
|
||
|
||
// Берем только последние N логов
|
||
const visibleLogs = obj.allLogs.slice(-tailLines);
|
||
|
||
// Сбрасываем счетчики
|
||
obj.counters = {dbg: 0, info: 0, warn: 0, err: 0, other: 0};
|
||
|
||
// Пересчитываем счетчики только для отображаемых логов
|
||
visibleLogs.forEach(logEntry => {
|
||
const shouldShow = allowedByContainerLevel(logEntry.cls, containerId) && applyFilter(logEntry.line);
|
||
if (shouldShow) {
|
||
if (logEntry.cls === 'dbg') obj.counters.dbg++;
|
||
if (logEntry.cls === 'ok') obj.counters.info++;
|
||
if (logEntry.cls === 'warn') obj.counters.warn++;
|
||
if (logEntry.cls === 'err') obj.counters.err++;
|
||
if (logEntry.cls === 'other') obj.counters.other++;
|
||
}
|
||
});
|
||
|
||
// Обновляем отображение счетчиков в кнопках заголовка
|
||
const levelBtns = document.querySelectorAll(`.level-btn[data-container-id="${containerId}"]`);
|
||
levelBtns.forEach(btn => {
|
||
const level = btn.getAttribute('data-level');
|
||
const valueEl = btn.querySelector('.level-value');
|
||
if (valueEl) {
|
||
switch (level) {
|
||
case 'debug': valueEl.textContent = obj.counters.dbg; break;
|
||
case 'info': valueEl.textContent = obj.counters.info; break;
|
||
case 'warn': valueEl.textContent = obj.counters.warn; break;
|
||
case 'err': valueEl.textContent = obj.counters.err; break;
|
||
case 'other': valueEl.textContent = obj.counters.other; break;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// Функция для обновления счетчиков в кнопках заголовков
|
||
function updateHeaderCounters(containerId, counters) {
|
||
// Обновляем счетчики для single-view (если это текущий контейнер)
|
||
if (state.current && state.current.id === containerId) {
|
||
const singleLevelBtns = document.querySelectorAll('.single-view-levels .level-btn');
|
||
singleLevelBtns.forEach(btn => {
|
||
const level = btn.getAttribute('data-level');
|
||
const valueEl = btn.querySelector('.level-value');
|
||
if (valueEl) {
|
||
switch (level) {
|
||
case 'debug': valueEl.textContent = counters.dbg; break;
|
||
case 'info': valueEl.textContent = counters.info; break;
|
||
case 'warn': valueEl.textContent = counters.warn; break;
|
||
case 'err': valueEl.textContent = counters.err; break;
|
||
case 'other': valueEl.textContent = counters.other; break;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// Обновляем счетчики для multi-view (только для конкретного контейнера)
|
||
if (state.multiViewMode && state.selectedContainers.includes(containerId)) {
|
||
const multiLevelBtns = document.querySelectorAll(`.level-btn[data-container-id="${containerId}"]`);
|
||
multiLevelBtns.forEach(btn => {
|
||
const level = btn.getAttribute('data-level');
|
||
const valueEl = btn.querySelector('.level-value');
|
||
if (valueEl) {
|
||
switch (level) {
|
||
case 'debug': valueEl.textContent = counters.dbg; break;
|
||
case 'info': valueEl.textContent = counters.info; break;
|
||
case 'warn': valueEl.textContent = counters.warn; break;
|
||
case 'err': valueEl.textContent = counters.err; break;
|
||
case 'other': valueEl.textContent = counters.other; break;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// Функция для инициализации состояния кнопок уровней логирования
|
||
function initializeLevelButtons() {
|
||
|
||
// Восстанавливаем состояние кнопок loglevels из localStorage
|
||
const savedLevelsState = getLogLevelsStateFromStorage();
|
||
if (savedLevelsState) {
|
||
// Восстанавливаем глобальные настройки для single-view
|
||
if (savedLevelsState.globalLevels) {
|
||
state.levels = { ...state.levels, ...savedLevelsState.globalLevels };
|
||
}
|
||
|
||
// Восстанавливаем настройки контейнеров для multi-view
|
||
if (savedLevelsState.containerLevels) {
|
||
state.containerLevels = { ...state.containerLevels, ...savedLevelsState.containerLevels };
|
||
}
|
||
}
|
||
|
||
// Инициализируем кнопки для single-view
|
||
const singleLevelBtns = document.querySelectorAll('.single-view-levels .level-btn');
|
||
singleLevelBtns.forEach(btn => {
|
||
const level = btn.getAttribute('data-level');
|
||
const isActive = state.levels[level];
|
||
btn.classList.toggle('active', isActive);
|
||
btn.classList.toggle('disabled', !isActive);
|
||
});
|
||
|
||
// Инициализируем кнопки для multi-view (если есть)
|
||
const multiLevelBtns = document.querySelectorAll('.multi-view-levels .level-btn');
|
||
|
||
multiLevelBtns.forEach((btn, index) => {
|
||
const level = btn.getAttribute('data-level');
|
||
const containerId = btn.getAttribute('data-container-id');
|
||
|
||
// Инициализируем настройки контейнера, если их нет
|
||
if (containerId && (!state.containerLevels || !state.containerLevels[containerId])) {
|
||
if (!state.containerLevels) {
|
||
state.containerLevels = {};
|
||
}
|
||
state.containerLevels[containerId] = {debug: true, info: true, warn: true, err: true, other: true};
|
||
}
|
||
|
||
// Используем настройки контейнера
|
||
const isActive = state.containerLevels && state.containerLevels[containerId] ?
|
||
state.containerLevels[containerId][level] : true;
|
||
|
||
btn.classList.toggle('active', isActive);
|
||
btn.classList.toggle('disabled', !isActive);
|
||
});
|
||
|
||
// Обновляем стили логов после инициализации кнопок
|
||
updateLogStyles();
|
||
|
||
// Применяем настройки wrap text
|
||
applyWrapSettings();
|
||
|
||
// Устанавливаем обработчик событий для кнопок уровней логирования
|
||
if (window.levelButtonClickHandler) {
|
||
document.addEventListener('click', window.levelButtonClickHandler);
|
||
}
|
||
}
|
||
/**
|
||
* Применяет фильтр к строке лога
|
||
* Проверяет, соответствует ли строка текущему фильтру (безопасный regex поиск)
|
||
* @param {string} line - Строка лога для проверки
|
||
* @returns {boolean} Проходит ли строка фильтр
|
||
*/
|
||
function applyFilter(line){
|
||
if(!state.filter) return true;
|
||
try{
|
||
// Экранируем специальные символы regex для безопасного поиска
|
||
const escapedFilter = state.filter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
return new RegExp(escapedFilter, 'i').test(line);
|
||
}catch(e){
|
||
console.error('Filter error:', e);
|
||
return true;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Константы и настройки для работы с ANSI цветами
|
||
* SGR (Select Graphic Rendition): 0/1/3/4, 30-37
|
||
*/
|
||
|
||
/**
|
||
* Настройки экземпляров контейнеров
|
||
* Содержит цвета, фильтры и палитру для визуального различия контейнеров
|
||
*/
|
||
const inst = {
|
||
colors: {}, // Кэш цветов для контейнеров
|
||
filters: {}, // Фильтры для экземпляров
|
||
palette: [ // Палитра цветов для контейнеров
|
||
'#7aa2f7','#9ece6a','#e0af68','#f7768e','#bb9af7','#7dcfff','#c0caf5','#f6bd60',
|
||
'#84cc16','#06b6d4','#fb923c','#ef4444','#22c55e','#a855f7'
|
||
]
|
||
};
|
||
|
||
/**
|
||
* Генерирует уникальный цвет для контейнера на основе его ID
|
||
* Использует хеш-функцию для детерминированного выбора цвета из палитры
|
||
* @param {string} id8 - Первые 8 символов ID контейнера
|
||
* @returns {string} HEX цвет для контейнера
|
||
*/
|
||
function idColor(id8){
|
||
if (inst.colors[id8]) return inst.colors[id8];
|
||
// simple hash to pick from palette
|
||
let h = 0; for (let i=0;i<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+клик с предыдущим выбором - диапазонный выбор
|
||
selectContainerRange(lastSelectedContainerId, svc.id);
|
||
} else if (e.shiftKey) {
|
||
// Shift+клик - добавляем/убираем из мультивыбора
|
||
toggleContainerSelection(svc.id);
|
||
lastSelectedContainerId = svc.id;
|
||
} else if (e.ctrlKey || e.metaKey) {
|
||
// Ctrl/Cmd+клик - добавляем/убираем из мультивыбора
|
||
toggleContainerSelection(svc.id);
|
||
lastSelectedContainerId = svc.id;
|
||
} else {
|
||
// Обычный клик - переключаемся в single view
|
||
lastSelectedContainerId = svc.id;
|
||
await switchToSingle(svc);
|
||
}
|
||
});
|
||
|
||
miniContainerList.appendChild(miniItem);
|
||
}
|
||
});
|
||
}
|
||
|
||
function setLayout(cls){
|
||
state.layout = cls;
|
||
if (els.layoutBadge) {
|
||
els.layoutBadge.textContent = 'view: ' + (cls==='tabs'?'tabs':cls);
|
||
}
|
||
els.grid.className = cls==='tabs' ? 'grid-1' : (cls==='grid2'?'grid-2':cls==='grid3'?'grid-3':'grid-4');
|
||
}
|
||
|
||
async function fetchProjects(){
|
||
try {
|
||
console.log('Fetching projects...');
|
||
const url = new URL(location.origin + '/api/containers/projects');
|
||
const token = localStorage.getItem('access_token');
|
||
if (!token) {
|
||
console.error('No access token found');
|
||
window.location.href = '/login';
|
||
return;
|
||
}
|
||
|
||
const res = await fetch(url, {
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`
|
||
}
|
||
});
|
||
if (!res.ok){
|
||
if (res.status === 401) {
|
||
console.error('Unauthorized, redirecting to login');
|
||
window.location.href = '/login';
|
||
return;
|
||
}
|
||
console.error('Failed to fetch projects:', res.status, res.statusText);
|
||
return;
|
||
}
|
||
const projects = await res.json();
|
||
console.log('Projects loaded:', projects);
|
||
|
||
|
||
|
||
// Обновляем мультивыбор проектов в заголовке
|
||
const dropdown = document.getElementById('projectSelectDropdown');
|
||
const display = document.getElementById('projectSelectDisplay');
|
||
const displayText = display?.querySelector('.multi-select-text');
|
||
|
||
console.log('Multi-select elements found:', {dropdown: !!dropdown, display: !!display, displayText: !!displayText});
|
||
|
||
if (dropdown && displayText) {
|
||
// Очищаем dropdown
|
||
dropdown.innerHTML = '';
|
||
|
||
// Добавляем опцию "All Projects"
|
||
const allOption = document.createElement('div');
|
||
allOption.className = 'multi-select-option';
|
||
allOption.setAttribute('data-value', 'all');
|
||
allOption.innerHTML = `
|
||
<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');
|
||
|
||
checkboxes.forEach(checkbox => {
|
||
const containerId = checkbox.getAttribute('data-container-id');
|
||
const containerItem = checkbox.closest('.container-item');
|
||
|
||
// Processing checkbox for container
|
||
|
||
if (state.selectedContainers.includes(containerId)) {
|
||
checkbox.checked = true;
|
||
containerItem.classList.add('selected');
|
||
} else {
|
||
checkbox.checked = false;
|
||
containerItem.classList.remove('selected');
|
||
}
|
||
});
|
||
|
||
// Обновляем миникарточки контейнеров
|
||
const miniContainerItems = document.querySelectorAll('.mini-container-item');
|
||
miniContainerItems.forEach(miniItem => {
|
||
const containerId = miniItem.getAttribute('data-cid');
|
||
if (state.selectedContainers.includes(containerId)) {
|
||
miniItem.classList.add('selected');
|
||
} else {
|
||
miniItem.classList.remove('selected');
|
||
}
|
||
});
|
||
|
||
// Обновляем single-view-title если он существует
|
||
const singleViewTitle = document.getElementById('singleViewTitle');
|
||
if (singleViewTitle && state.selectedContainers.length === 1) {
|
||
const service = state.services.find(s => s.id === state.selectedContainers[0]);
|
||
if (service) {
|
||
singleViewTitle.textContent = `${service.name} (${service.service || service.name})`;
|
||
}
|
||
} else if (singleViewTitle && state.selectedContainers.length === 0) {
|
||
singleViewTitle.textContent = 'No container selected';
|
||
} else if (singleViewTitle && state.selectedContainers.length > 1) {
|
||
singleViewTitle.textContent = `Multi-view: ${state.selectedContainers.length} containers`;
|
||
}
|
||
|
||
// Если есть сохраненный контейнер, убеждаемся что его чекбокс отмечен
|
||
const savedContainerId = getSelectedContainerFromStorage();
|
||
if (savedContainerId && state.selectedContainers.includes(savedContainerId)) {
|
||
const checkbox = document.querySelector(`.container-checkbox[data-container-id="${savedContainerId}"]`);
|
||
if (checkbox) {
|
||
checkbox.checked = true;
|
||
const containerItem = checkbox.closest('.container-item');
|
||
if (containerItem) {
|
||
containerItem.classList.add('selected');
|
||
}
|
||
}
|
||
}
|
||
|
||
// Обновляем видимость кнопок LogLevels
|
||
updateLogLevelsVisibility();
|
||
}
|
||
|
||
// Функция для обновления активного состояния контейнеров в UI
|
||
function updateActiveContainerUI(activeContainerId) {
|
||
console.log('updateActiveContainerUI called for:', activeContainerId);
|
||
|
||
// Обновляем обычные карточки контейнеров
|
||
const containerItems = document.querySelectorAll('.container-item');
|
||
containerItems.forEach(item => {
|
||
const containerId = item.getAttribute('data-cid');
|
||
if (activeContainerId && containerId === activeContainerId) {
|
||
item.classList.add('active');
|
||
} else {
|
||
item.classList.remove('active');
|
||
}
|
||
});
|
||
|
||
// Обновляем миникарточки контейнеров
|
||
const miniContainerItems = document.querySelectorAll('.mini-container-item');
|
||
miniContainerItems.forEach(miniItem => {
|
||
const containerId = miniItem.getAttribute('data-cid');
|
||
if (activeContainerId && containerId === activeContainerId) {
|
||
miniItem.classList.add('active');
|
||
} else {
|
||
miniItem.classList.remove('active');
|
||
}
|
||
});
|
||
|
||
// Обновляем legacy tabs
|
||
const tabButtons = document.querySelectorAll('.tab');
|
||
tabButtons.forEach(tab => {
|
||
const tabText = tab.textContent;
|
||
const service = state.services.find(s =>
|
||
(s.project ? `[${s.project}] ` : '') + (s.service || s.name) === tabText
|
||
);
|
||
if (service && activeContainerId && service.id === activeContainerId) {
|
||
tab.classList.add('active');
|
||
} else {
|
||
tab.classList.remove('active');
|
||
}
|
||
});
|
||
}
|
||
|
||
// Функции для всплывающих подсказок миникарточек
|
||
function showMiniContainerTooltip(event, service) {
|
||
// Удаляем существующую подсказку
|
||
hideMiniContainerTooltip();
|
||
|
||
// Очищаем все таймеры скрытия
|
||
const existingTooltips = document.querySelectorAll('.mini-container-tooltip');
|
||
existingTooltips.forEach(tooltip => {
|
||
if (tooltip.hideTimer) {
|
||
clearTimeout(tooltip.hideTimer);
|
||
}
|
||
});
|
||
|
||
// Создаем новую подсказку
|
||
const tooltip = document.createElement('div');
|
||
tooltip.className = 'mini-container-tooltip';
|
||
tooltip.id = 'miniContainerTooltip';
|
||
|
||
const statusClass = service.status === 'running' ? 'running' :
|
||
service.status === 'stopped' ? 'stopped' : 'paused';
|
||
|
||
tooltip.innerHTML = `
|
||
<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) {
|
||
|
||
// Сохраняем режим просмотра в 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);
|
||
}
|
||
|
||
// Multi-view mode updated
|
||
|
||
// Сохраняем состояние кнопок loglevels при переключении режимов
|
||
saveLogLevelsState();
|
||
|
||
// Обновляем состояние кнопок уровней логирования при переключении режимов
|
||
setTimeout(() => {
|
||
initializeLevelButtons();
|
||
}, 100);
|
||
|
||
// Обновляем видимость кнопок LogLevels
|
||
updateLogLevelsVisibility();
|
||
}
|
||
|
||
/**
|
||
* Настраивает интерфейс для режима мультипросмотра (multi-view)
|
||
* Создает сетку панелей для одновременного просмотра нескольких контейнеров
|
||
* Открывает WebSocket соединения для всех выбранных контейнеров
|
||
*/
|
||
async function setupMultiView() {
|
||
// Проверяем, что у нас действительно больше одного контейнера
|
||
if (state.selectedContainers.length <= 1) {
|
||
if (state.selectedContainers.length === 1) {
|
||
const selectedService = state.services.find(s => s.id === state.selectedContainers[0]);
|
||
if (selectedService) {
|
||
await switchToSingle(selectedService);
|
||
}
|
||
} else {
|
||
clearLogArea();
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Дополнительная проверка - если уже есть мультипросмотр, удаляем его для пересоздания
|
||
const existingMultiView = document.getElementById('multiViewGrid');
|
||
if (existingMultiView) {
|
||
existingMultiView.remove();
|
||
}
|
||
|
||
const logArea = document.querySelector('.log-area');
|
||
if (!logArea) {
|
||
console.log('Log area not found');
|
||
return;
|
||
}
|
||
|
||
// Очищаем область логов
|
||
const logContent = logArea.querySelector('.log-content');
|
||
if (logContent) {
|
||
logContent.innerHTML = '';
|
||
}
|
||
|
||
// Удаляем single-view-panel если он существует
|
||
const singleViewPanel = document.getElementById('singleViewPanel');
|
||
if (singleViewPanel) {
|
||
singleViewPanel.remove();
|
||
}
|
||
|
||
// Создаем сетку для мультипросмотра
|
||
const gridContainer = document.createElement('div');
|
||
gridContainer.className = 'multi-view-grid';
|
||
gridContainer.id = 'multiViewGrid';
|
||
|
||
// Определяем количество колонок в зависимости от количества контейнеров
|
||
let columns = 1;
|
||
if (state.selectedContainers.length === 1) columns = 1;
|
||
else if (state.selectedContainers.length === 2) columns = 2;
|
||
else if (state.selectedContainers.length <= 4) columns = 2;
|
||
else if (state.selectedContainers.length <= 6) columns = 3;
|
||
else columns = 4;
|
||
|
||
gridContainer.style.gridTemplateColumns = `repeat(${columns}, 1fr)`;
|
||
|
||
// Создаем панели для каждого выбранного контейнера
|
||
|
||
state.selectedContainers.forEach((containerId, index) => {
|
||
const service = state.services.find(s => s.id === containerId);
|
||
if (!service) {
|
||
console.error(`setupMultiView: Service not found for container ID: ${containerId}`);
|
||
console.error(`setupMultiView: Available service IDs:`, state.services.map(s => s.id));
|
||
return;
|
||
}
|
||
|
||
const panel = createMultiViewPanel(service);
|
||
gridContainer.appendChild(panel);
|
||
});
|
||
|
||
if (logContent) {
|
||
logContent.appendChild(gridContainer);
|
||
} else {
|
||
console.error('setupMultiView: logContent not found');
|
||
}
|
||
|
||
// Применяем настройки wrap lines
|
||
applyWrapSettings();
|
||
|
||
// Очищаем активное состояние всех контейнеров в мультипросмотре
|
||
updateActiveContainerUI(null);
|
||
|
||
// Принудительно обновляем стили логов для multi-view
|
||
setTimeout(() => {
|
||
updateLogStyles();
|
||
|
||
// Дополнительная проверка для multi-view логов
|
||
forceFixMultiViewStyles();
|
||
}, 200);
|
||
|
||
// Подключаем WebSocket для каждого контейнера
|
||
state.selectedContainers.forEach((containerId, index) => {
|
||
const service = state.services.find(s => s.id === containerId);
|
||
if (service) {
|
||
openMultiViewWs(service);
|
||
} else {
|
||
console.error(`setupMultiView: Service not found for container ID: ${containerId}`);
|
||
}
|
||
});
|
||
|
||
// Multi-view setup completed
|
||
|
||
// Применяем сохраненный порядок панелей
|
||
setTimeout(() => {
|
||
// Сначала очищаем дубликаты, если они есть
|
||
cleanupDuplicatePanels();
|
||
// Затем применяем порядок
|
||
applyPanelOrder();
|
||
}, 100); // Небольшая задержка для завершения создания панелей
|
||
|
||
// Обновляем счетчики для multi view
|
||
setTimeout(() => {
|
||
recalculateMultiViewCounters();
|
||
}, 1000); // Небольшая задержка для завершения загрузки логов
|
||
|
||
// Применяем стили логов после настройки multi view
|
||
setTimeout(() => {
|
||
updateLogStyles();
|
||
}, 1500); // Задержка после настройки счетчиков
|
||
|
||
// Обновляем видимость кнопок LogLevels
|
||
updateLogLevelsVisibility();
|
||
}
|
||
|
||
/**
|
||
* Создает панель для мультипросмотра контейнера
|
||
* Генерирует HTML структуру с заголовком, кнопками уровней и областью логов
|
||
* @param {Object} service - Объект сервиса/контейнера
|
||
* @returns {HTMLElement} Созданная панель мультипросмотра
|
||
*/
|
||
function createMultiViewPanel(service) {
|
||
console.log(`Creating multi-view panel for service: ${service.name} (${service.id})`);
|
||
const panel = document.createElement('div');
|
||
panel.className = 'multi-view-panel';
|
||
panel.setAttribute('data-container-id', service.id);
|
||
|
||
panel.innerHTML = `
|
||
<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) {
|
||
// Очищаем пустые строки после создания панели
|
||
cleanMultiViewEmptyLines(logElement);
|
||
// Очищаем дублированные строки после создания панели
|
||
cleanMultiViewDuplicateLines(logElement);
|
||
} else {
|
||
console.error(`Failed to create multi-view log element for ${service.name}`);
|
||
}
|
||
|
||
// Добавляем drag & drop функциональность
|
||
if (window.setupDragAndDrop) {
|
||
window.setupDragAndDrop(panel);
|
||
} else {
|
||
console.warn('setupDragAndDrop not available yet, will be set up later');
|
||
// Устанавливаем drag & drop позже, когда функция будет доступна
|
||
setTimeout(() => {
|
||
if (window.setupDragAndDrop) {
|
||
window.setupDragAndDrop(panel);
|
||
}
|
||
}, 100);
|
||
}
|
||
|
||
// Инициализируем состояние кнопок уровней логирования для этого контейнера
|
||
setTimeout(() => {
|
||
initializeLevelButtons();
|
||
}, 100);
|
||
|
||
// Multi-view panel created
|
||
|
||
// Применяем стили к новой панели
|
||
setTimeout(() => {
|
||
updateLogStyles();
|
||
}, 200);
|
||
|
||
return panel;
|
||
}
|
||
|
||
/**
|
||
* Открывает WebSocket соединение для контейнера в режиме мультипросмотра
|
||
* Настраивает обработчики сообщений и управляет отображением логов
|
||
* @param {Object} service - Объект сервиса/контейнера
|
||
*/
|
||
function openMultiViewWs(service) {
|
||
const containerId = service.id;
|
||
|
||
// Закрываем существующее соединение только если оно действительно существует
|
||
const existingConnection = state.open[containerId];
|
||
if (existingConnection && existingConnection.ws) {
|
||
closeWs(containerId);
|
||
// Добавляем небольшую задержку перед созданием нового соединения
|
||
setTimeout(() => {
|
||
createWebSocketConnection(service, containerId);
|
||
}, 100);
|
||
return;
|
||
}
|
||
|
||
// Создаем новое WebSocket соединение
|
||
createWebSocketConnection(service, containerId);
|
||
}
|
||
|
||
function createWebSocketConnection(service, containerId) {
|
||
const ws = new WebSocket(wsUrl(containerId, service.service, service.project));
|
||
|
||
ws.onopen = () => {
|
||
const logEl = document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`);
|
||
if (logEl) {
|
||
// Убираем сообщение "Connected..." для MultiView режима
|
||
logEl.textContent = '';
|
||
// Очищаем пустые строки после установки соединения
|
||
setTimeout(() => {
|
||
cleanMultiViewEmptyLines(logEl);
|
||
cleanMultiViewDuplicateLines(logEl);
|
||
}, 100);
|
||
}
|
||
};
|
||
|
||
ws.onmessage = (event) => {
|
||
// Устанавливаем состояние 'on' при получении сообщений
|
||
setWsState('on');
|
||
|
||
const parts = (event.data||'').split(/\r?\n/);
|
||
|
||
// Проверяем на дублирование в исходных данных
|
||
if (event.data.includes('FoundINFO:')) {
|
||
// FoundINFO detected in WebSocket data
|
||
}
|
||
|
||
// Проверяем на дублирование строк и убираем дубликаты
|
||
const lines = event.data.split(/\r?\n/).filter(line => line.trim().length > 0);
|
||
const uniqueLines = [...new Set(lines)];
|
||
if (lines.length !== uniqueLines.length) {
|
||
// Дублирование строк обнаружено, используем только уникальные строки
|
||
const uniqueParts = uniqueLines.map(line => line.trim()).filter(line => line.length > 0);
|
||
|
||
for (let i=0;i<uniqueParts.length;i++){
|
||
// Проверяем каждую часть на FoundINFO:
|
||
if (uniqueParts[i].includes('FoundINFO:')) {
|
||
// FoundINFO detected in unique part
|
||
}
|
||
|
||
// 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:')) {
|
||
// FoundINFO detected in part
|
||
}
|
||
|
||
// 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 = () => {
|
||
// WebSocket закрыт
|
||
};
|
||
|
||
ws.onerror = (error) => {
|
||
console.error(`Multi-view WebSocket error for ${service.name}:`, error);
|
||
};
|
||
|
||
// Сохраняем соединение с полным набором полей как в openWs
|
||
const logEl = document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`);
|
||
|
||
state.open[containerId] = {
|
||
ws: ws,
|
||
serviceName: service.service,
|
||
logEl: logEl,
|
||
wrapEl: logEl,
|
||
counters: {dbg:0, info:0, warn:0, err:0, other:0},
|
||
pausedBuffer: [],
|
||
allLogs: [] // Добавляем буфер для логов
|
||
};
|
||
|
||
// Также сохраняем WebSocket в wsConnections для проверки в applyPanelOrder
|
||
state.wsConnections[containerId] = ws;
|
||
}
|
||
|
||
function clearLogArea() {
|
||
console.log('clearLogArea called');
|
||
|
||
// Очищаем мультипросмотр если он был активен
|
||
if (state.multiViewMode) {
|
||
console.log('Clearing multi-view grid');
|
||
const multiViewGrid = document.getElementById('multiViewGrid');
|
||
if (multiViewGrid) {
|
||
multiViewGrid.remove();
|
||
}
|
||
}
|
||
|
||
const logContent = document.querySelector('.log-content');
|
||
if (logContent) {
|
||
logContent.innerHTML = `
|
||
<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();
|
||
|
||
// Проверяем, нужно ли пропустить восстановление (например, после автоматического обновления)
|
||
const skipRestore = localStorage.getItem('lb_skip_restore');
|
||
if (skipRestore === 'true') {
|
||
console.log('Skipping panel restoration due to lb_skip_restore flag');
|
||
localStorage.removeItem('lb_skip_restore');
|
||
return;
|
||
}
|
||
|
||
// Восстанавливаем режим просмотра из localStorage
|
||
const savedViewMode = getViewModeFromStorage();
|
||
if (savedViewMode) {
|
||
console.log('Restoring view mode from localStorage:', savedViewMode);
|
||
|
||
if (savedViewMode.multiViewMode && savedViewMode.selectedContainers.length > 1) {
|
||
// Восстанавливаем Multi View режим
|
||
console.log('Restoring Multi View mode with containers:', savedViewMode.selectedContainers);
|
||
state.multiViewMode = true;
|
||
state.selectedContainers = savedViewMode.selectedContainers;
|
||
|
||
// Отмечаем чекбоксы для выбранных контейнеров
|
||
savedViewMode.selectedContainers.forEach(containerId => {
|
||
const checkbox = document.querySelector(`.container-checkbox[data-container-id="${containerId}"]`);
|
||
if (checkbox) {
|
||
checkbox.checked = true;
|
||
const containerItem = checkbox.closest('.container-item');
|
||
if (containerItem) {
|
||
containerItem.classList.add('selected');
|
||
}
|
||
}
|
||
});
|
||
|
||
// Настраиваем Multi View
|
||
await setupMultiView();
|
||
|
||
// Проверяем настройку автоматического обновления логов при восстановлении панелей
|
||
const autoRefreshOnRestore = localStorage.getItem('lb_auto_refresh_on_restore');
|
||
if (autoRefreshOnRestore === 'true') {
|
||
console.log('Auto-refresh logs on restore is enabled, refreshing logs in 1 second...');
|
||
setTimeout(() => {
|
||
// Обновляем логи панелей вместо обновления страницы
|
||
refreshLogsAndCounters();
|
||
console.log('Logs refreshed after panel restoration');
|
||
|
||
// Дополнительная прокрутка через небольшую задержку
|
||
setTimeout(() => {
|
||
scrollToBottom();
|
||
}, 1500);
|
||
}, 1000);
|
||
} else {
|
||
// Если это восстановление из localStorage, проверяем через некоторое время
|
||
// нужно ли обновить страницу для корректной работы обработчиков
|
||
setTimeout(() => {
|
||
const hasLevelButtons = document.querySelectorAll('.level-btn').length > 0;
|
||
if (hasLevelButtons) {
|
||
console.log('Panel restoration completed, checking event handlers in 2 seconds...');
|
||
setTimeout(() => {
|
||
// Простая проверка - если кнопки есть, но клики не работают, обновляем страницу
|
||
const testButton = document.querySelector('.level-btn');
|
||
if (testButton) {
|
||
// Симулируем клик для проверки
|
||
const clickEvent = new MouseEvent('click', { bubbles: true });
|
||
const originalHandler = testButton.onclick;
|
||
|
||
// Временно устанавливаем обработчик для проверки
|
||
testButton.onclick = () => {
|
||
testButton.onclick = originalHandler;
|
||
};
|
||
|
||
testButton.dispatchEvent(clickEvent);
|
||
|
||
// Если через 100ms обработчик не сработал, обновляем логи
|
||
setTimeout(() => {
|
||
if (testButton.onclick && testButton.onclick.toString().includes('testButton.onclick = originalHandler')) {
|
||
refreshLogsAndCounters();
|
||
}
|
||
}, 100);
|
||
}
|
||
}, 2000);
|
||
}
|
||
}, 1000);
|
||
}
|
||
} else if (savedViewMode.selectedContainers.length === 1) {
|
||
// Восстанавливаем Single View режим
|
||
console.log('Restoring Single View mode for container:', savedViewMode.selectedContainers[0]);
|
||
state.multiViewMode = false;
|
||
const selectedService = state.services.find(s => s.id === savedViewMode.selectedContainers[0]);
|
||
if (selectedService) {
|
||
await switchToSingle(selectedService);
|
||
}
|
||
} else {
|
||
// Нет сохраненного режима, не открываем автоматически первый контейнер
|
||
console.log('No saved view mode, not auto-opening first container');
|
||
// Пользователь сам выберет нужный контейнер
|
||
}
|
||
} else {
|
||
// Нет сохраненного режима, не открываем автоматически первый контейнер
|
||
console.log('No saved view mode found, not auto-opening first container');
|
||
// Пользователь сам выберет нужный контейнер
|
||
}
|
||
|
||
// Добавляем обработчики для счетчиков после загрузки сервисов
|
||
addCounterClickHandlers();
|
||
} catch (error) {
|
||
console.error('Error fetching services:', error);
|
||
}
|
||
}
|
||
|
||
function wsUrl(containerId, service, project){
|
||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||
const tail = els.tail.value || '500';
|
||
const token = encodeURIComponent(localStorage.getItem('access_token') || '');
|
||
const sp = service?`&service=${encodeURIComponent(service)}`:'';
|
||
const pj = project?`&project=${encodeURIComponent(project)}`:'';
|
||
if (els.aggregate && els.aggregate.checked && service){
|
||
// fan-in by service
|
||
return `${proto}://${location.host}/api/websocket/fan/${encodeURIComponent(service)}?tail=${tail}&token=${token}${pj}`;
|
||
}
|
||
return `${proto}://${location.host}/api/websocket/logs/${encodeURIComponent(containerId)}?tail=${tail}&token=${token}${sp}${pj}`;
|
||
}
|
||
|
||
/**
|
||
* Закрывает WebSocket соединение для контейнера
|
||
* @param {string} id - ID контейнера
|
||
*/
|
||
function closeWs(id){
|
||
const o = state.open[id];
|
||
if (!o) return;
|
||
|
||
try { o.ws.close(); } catch(e){}
|
||
delete state.open[id];
|
||
}
|
||
|
||
/**
|
||
* Создает и скачивает снимок логов контейнера
|
||
* В режиме мультипросмотра создает отдельные файлы для каждого контейнера
|
||
* @param {string} id - ID контейнера
|
||
*/
|
||
async function sendSnapshot(id){
|
||
const o = state.open[id];
|
||
if (!o){ alert('not open'); return; }
|
||
|
||
const token = localStorage.getItem('access_token');
|
||
if (!token) {
|
||
console.error('No access token found');
|
||
window.location.href = '/login';
|
||
return;
|
||
}
|
||
|
||
if (state.multiViewMode && state.selectedContainers.length > 0) {
|
||
// В Multi View режиме создаем отдельный файл для каждого контейнера
|
||
console.log('Creating snapshots for Multi View mode with containers:', state.selectedContainers);
|
||
|
||
let hasLogs = false;
|
||
|
||
// Создаем отдельный файл для каждого выбранного контейнера
|
||
for (const containerId of state.selectedContainers) {
|
||
const multiViewLog = document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`);
|
||
if (multiViewLog && multiViewLog.textContent.trim()) {
|
||
const service = state.services.find(s => s.id === containerId);
|
||
const serviceName = service ? (service.service || service.name) : containerId;
|
||
const text = multiViewLog.textContent;
|
||
|
||
console.log(`Saving snapshot for ${serviceName} with content length:`, text.length);
|
||
|
||
const payload = {container_id: containerId, service: serviceName, content: text};
|
||
|
||
try {
|
||
const res = await fetch('/api/logs/snapshot', {
|
||
method:'POST',
|
||
headers:{
|
||
'Content-Type':'application/json',
|
||
'Authorization': `Bearer ${token}`
|
||
},
|
||
body: JSON.stringify(payload)
|
||
});
|
||
|
||
if (!res.ok){
|
||
if (res.status === 401) {
|
||
console.error('Unauthorized, redirecting to login');
|
||
window.location.href = '/login';
|
||
return;
|
||
}
|
||
console.error(`Snapshot failed for ${serviceName}:`, res.status, res.statusText);
|
||
alert(`snapshot failed for ${serviceName}`);
|
||
return;
|
||
}
|
||
|
||
const js = await res.json();
|
||
const a = document.createElement('a');
|
||
a.href = js.url; a.download = js.file; a.click();
|
||
|
||
hasLogs = true;
|
||
|
||
// Небольшая задержка между скачиваниями файлов
|
||
await new Promise(resolve => setTimeout(resolve, 100));
|
||
|
||
} catch (error) {
|
||
console.error(`Error saving snapshot for ${serviceName}:`, error);
|
||
alert(`Error saving snapshot for ${serviceName}`);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!hasLogs) {
|
||
alert('No logs to save in Multi View mode');
|
||
return;
|
||
}
|
||
|
||
} else {
|
||
// Обычный режим просмотра
|
||
let text = '';
|
||
if (state.current && state.current.id === id && els.logContent) {
|
||
text = els.logContent.textContent;
|
||
} else if (o.logEl) {
|
||
text = o.logEl.textContent;
|
||
}
|
||
|
||
if (!text || text.trim() === '') {
|
||
alert('No logs to save');
|
||
return;
|
||
}
|
||
|
||
console.log('Saving snapshot with content length:', text.length);
|
||
|
||
const serviceName = o.serviceName || id;
|
||
const payload = {container_id: id, service: serviceName, content: text};
|
||
|
||
const res = await fetch('/api/logs/snapshot', {
|
||
method:'POST',
|
||
headers:{
|
||
'Content-Type':'application/json',
|
||
'Authorization': `Bearer ${token}`
|
||
},
|
||
body: JSON.stringify(payload)
|
||
});
|
||
if (!res.ok){
|
||
if (res.status === 401) {
|
||
console.error('Unauthorized, redirecting to login');
|
||
window.location.href = '/login';
|
||
return;
|
||
}
|
||
console.error('Snapshot failed:', res.status, res.statusText);
|
||
alert('snapshot failed');
|
||
return;
|
||
}
|
||
const js = await res.json();
|
||
const a = document.createElement('a');
|
||
a.href = js.url; a.download = js.file; a.click();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Открывает WebSocket соединение для контейнера
|
||
* Настраивает обработчики событий и управляет отображением логов
|
||
* @param {Object} svc - Объект сервиса/контейнера
|
||
* @param {HTMLElement} panel - Панель для отображения логов
|
||
*/
|
||
function openWs(svc, panel){
|
||
const id = svc.id;
|
||
|
||
const logEl = panel.querySelector('.log');
|
||
const wrapEl = panel.querySelector('.logwrap');
|
||
|
||
// Ищем счетчики в panel или в глобальных элементах
|
||
const cdbg = panel.querySelector('.cdbg') || document.querySelector('.cdbg');
|
||
const cinfo = panel.querySelector('.cinfo') || document.querySelector('.cinfo');
|
||
const cwarn = panel.querySelector('.cwarn') || document.querySelector('.cwarn');
|
||
const cerr = panel.querySelector('.cerr') || document.querySelector('.cerr');
|
||
const cother = panel.querySelector('.cother') || document.querySelector('.cother');
|
||
const counters = {dbg:0,info:0,warn:0,err:0,other:0};
|
||
|
||
const ws = new WebSocket(wsUrl(id, (svc.service||svc.name), svc.project||''));
|
||
state.open[id] = {ws, logEl, wrapEl, counters, pausedBuffer:[], serviceName: (svc.service||svc.name)};
|
||
|
||
ws.onopen = ()=> {
|
||
setWsState('on');
|
||
// Очищаем сообщение "Connecting..." когда соединение установлено
|
||
if (state.current && state.current.id === id && els.logContent) {
|
||
els.logContent.innerHTML = '';
|
||
}
|
||
// Также очищаем legacy элемент лога
|
||
if (obj.logEl) {
|
||
obj.logEl.innerHTML = '';
|
||
}
|
||
|
||
// Принудительно проверяем состояние через AJAX через 500мс и 1 секунду
|
||
setTimeout(() => {
|
||
checkWebSocketStatus();
|
||
}, 500);
|
||
|
||
setTimeout(() => {
|
||
checkWebSocketStatus();
|
||
}, 1000);
|
||
};
|
||
ws.onclose = ()=> {
|
||
setWsState(determineWsState());
|
||
|
||
// Принудительно проверяем состояние через AJAX через 500мс
|
||
setTimeout(() => {
|
||
checkWebSocketStatus();
|
||
}, 500);
|
||
};
|
||
ws.onerror = (error)=> {
|
||
setWsState('err');
|
||
};
|
||
ws.onmessage = (ev)=>{
|
||
// Устанавливаем состояние 'on' при получении сообщений
|
||
setWsState('on');
|
||
|
||
const parts = (ev.data||'').split(/\r?\n/);
|
||
|
||
// Проверяем на дублирование в исходных данных для Single View
|
||
if (ev.data.includes('FoundINFO:')) {
|
||
// FoundINFO detected in Single View WebSocket data
|
||
}
|
||
|
||
// Проверяем на дублирование строк и убираем дубликаты
|
||
const lines = ev.data.split(/\r?\n/).filter(line => line.trim().length > 0);
|
||
const uniqueLines = [...new Set(lines)];
|
||
if (lines.length !== uniqueLines.length) {
|
||
// Дублирование строк обнаружено в Single View, используем только уникальные строки
|
||
const uniqueParts = uniqueLines.map(line => line.trim()).filter(line => line.length > 0);
|
||
|
||
for (let i=0;i<uniqueParts.length;i++){
|
||
// Проверяем каждую часть на FoundINFO:
|
||
if (uniqueParts[i].includes('FoundINFO:')) {
|
||
// FoundINFO detected in Single View unique part
|
||
}
|
||
|
||
// 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(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:')) {
|
||
// FoundINFO detected in Single View part
|
||
}
|
||
|
||
// 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(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()) {
|
||
currentLine.remove();
|
||
removedCount++;
|
||
}
|
||
}
|
||
}
|
||
|
||
// После удаления дубликатов очищаем лишние пустые строки
|
||
if (logElement.classList.contains('multi-view-log')) {
|
||
cleanMultiViewEmptyLines(logElement);
|
||
} else {
|
||
cleanSingleViewEmptyLines(logElement);
|
||
}
|
||
|
||
// Дублированные строки удалены
|
||
}
|
||
|
||
/**
|
||
* Функция для радикальной очистки пустых строк в Single View
|
||
* Удаляет все пустые строки и лишние переносы строк
|
||
* @param {HTMLElement} logElement - элемент лога Single View
|
||
*/
|
||
function cleanSingleViewEmptyLines(logElement) {
|
||
if (!logElement) return;
|
||
|
||
let removedCount = 0;
|
||
|
||
// Удаляем все пустые строки (элементы .line без текста)
|
||
const lines = Array.from(logElement.querySelectorAll('.line'));
|
||
lines.forEach(line => {
|
||
const textContent = line.textContent || line.innerText || '';
|
||
if (textContent.trim() === '') {
|
||
line.remove();
|
||
removedCount++;
|
||
}
|
||
});
|
||
|
||
// Удаляем все текстовые узлы, которые содержат только пробелы и переносы строк
|
||
const walker = document.createTreeWalker(
|
||
logElement,
|
||
NodeFilter.SHOW_TEXT,
|
||
null,
|
||
false
|
||
);
|
||
|
||
const textNodesToRemove = [];
|
||
let node;
|
||
while (node = walker.nextNode()) {
|
||
const content = node.textContent;
|
||
// Удаляем все узлы, которые содержат только пробелы, переносы строк или табуляцию
|
||
if (content.trim() === '') {
|
||
textNodesToRemove.push(node);
|
||
}
|
||
}
|
||
|
||
textNodesToRemove.forEach(node => node.remove());
|
||
|
||
// Удаляем все пустые текстовые узлы между элементами .line
|
||
const allNodes = Array.from(logElement.childNodes);
|
||
for (let i = allNodes.length - 1; i >= 0; i--) {
|
||
const node = allNodes[i];
|
||
if (node.nodeType === Node.TEXT_NODE && node.textContent.trim() === '') {
|
||
node.remove();
|
||
}
|
||
}
|
||
|
||
if (removedCount > 0) {
|
||
console.log(`cleanSingleViewEmptyLines: Удалено ${removedCount} пустых строк`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Функция для нормализации пробелов в логах
|
||
* Заменяет множественные пробелы на один пробел
|
||
* @param {string} text - исходный текст
|
||
* @returns {string} - текст с нормализованными пробелами
|
||
*/
|
||
function normalizeSpaces(text) {
|
||
if (!text) return text;
|
||
|
||
// Заменяем множественные пробелы на один пробел
|
||
// Используем регулярное выражение для замены 2+ пробелов на один
|
||
return text.replace(/\s{2,}/g, ' ');
|
||
}
|
||
|
||
/**
|
||
* Функция для периодической очистки пустых строк
|
||
* Вызывается автоматически каждые 2 секунды для поддержания чистоты логов
|
||
*/
|
||
function periodicCleanup() {
|
||
// Очищаем пустые строки в Single View
|
||
if (!state.multiViewMode && els.logContent) {
|
||
cleanSingleViewEmptyLines(els.logContent);
|
||
cleanDuplicateLines(els.logContent);
|
||
}
|
||
|
||
// Очищаем пустые строки в мультипросмотре
|
||
if (state.multiViewMode) {
|
||
state.selectedContainers.forEach(containerId => {
|
||
const multiViewLog = document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`);
|
||
if (multiViewLog) {
|
||
cleanMultiViewEmptyLines(multiViewLog);
|
||
cleanMultiViewDuplicateLines(multiViewLog);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Очищаем пустые строки в legacy панелях
|
||
Object.values(state.open).forEach(obj => {
|
||
if (obj.logEl) {
|
||
cleanSingleViewEmptyLines(obj.logEl);
|
||
cleanDuplicateLines(obj.logEl);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Запускаем периодическую очистку каждые 2 секунды
|
||
setInterval(periodicCleanup, 2000);
|
||
|
||
// Запускаем периодическую проверку стилей multi-view логов каждые 5 секунд
|
||
setInterval(() => {
|
||
if (state.multiViewMode) {
|
||
const multiViewLogs = document.querySelectorAll('.multi-view-content .multi-view-log');
|
||
if (multiViewLogs.length > 0) {
|
||
forceFixMultiViewStyles();
|
||
|
||
// Дополнительно исправляем все контейнеры
|
||
if (window.fixAllContainers) {
|
||
window.fixAllContainers();
|
||
}
|
||
}
|
||
}
|
||
}, 5000);
|
||
|
||
/**
|
||
* Функция для обработки специальных замен в MultiView логах
|
||
* Выполняет специфичные замены для улучшения читаемости логов
|
||
* @param {string} text - исходный текст
|
||
* @returns {string} - текст с примененными заменами
|
||
*/
|
||
function processMultiViewSpecialReplacements(text) {
|
||
if (!text) return text;
|
||
|
||
let processedText = text;
|
||
|
||
// Добавляем отладочную информацию для проверки
|
||
if (text.includes('FoundINFO:')) {
|
||
// FoundINFO detected in processMultiViewSpecialReplacements
|
||
}
|
||
|
||
// Проверяем на дублирование строк в исходном тексте
|
||
const lines = processedText.split('\n');
|
||
const uniqueLines = [...new Set(lines)];
|
||
if (lines.length !== uniqueLines.length) {
|
||
console.log('🚨 processMultiViewSpecialReplacements: Обнаружено дублирование в исходном тексте!');
|
||
console.log('🚨 Исходные строки:', lines.length, 'Уникальные строки:', uniqueLines.length);
|
||
// Убираем дублированные строки
|
||
processedText = uniqueLines.join('\n');
|
||
}
|
||
|
||
// Заменяем случаи, где INFO: прилипает к предыдущему тексту
|
||
// Ищем паттерн: любой текст + INFO: (но не в начале строки)
|
||
// Используем более точное регулярное выражение для поиска
|
||
processedText = processedText.replace(/([A-Za-z0-9\s]+)INFO:/g, '$1\nINFO:');
|
||
|
||
// Убираем лишние переносы строк в начале, если они есть
|
||
processedText = processedText.replace(/^\n+/, '');
|
||
|
||
// Проверяем результат
|
||
if (text.includes('FoundINFO:') && processedText !== text) {
|
||
console.log('✅ processMultiViewSpecialReplacements: Замена выполнена:', processedText);
|
||
} else if (text.includes('FoundINFO:') && processedText === text) {
|
||
console.log('❌ processMultiViewSpecialReplacements: Замена НЕ выполнена для:', text);
|
||
}
|
||
|
||
return processedText;
|
||
}
|
||
|
||
/**
|
||
* Функция для обработки специальных замен в Single View логах
|
||
* Не добавляет лишние переносы строк
|
||
* @param {string} text - исходный текст
|
||
* @returns {string} - текст с примененными заменами
|
||
*/
|
||
function processSingleViewSpecialReplacements(text) {
|
||
if (!text) return text;
|
||
|
||
let processedText = text;
|
||
|
||
// Добавляем отладочную информацию для проверки
|
||
if (text.includes('FoundINFO:')) {
|
||
// FoundINFO detected in processSingleViewSpecialReplacements
|
||
}
|
||
|
||
// Проверяем на дублирование строк в исходном тексте
|
||
const lines = processedText.split('\n');
|
||
const uniqueLines = [...new Set(lines)];
|
||
if (lines.length !== uniqueLines.length) {
|
||
console.log('🚨 processSingleViewSpecialReplacements: Обнаружено дублирование в исходном тексте!');
|
||
console.log('🚨 Исходные строки:', lines.length, 'Уникальные строки:', uniqueLines.length);
|
||
// Убираем дублированные строки
|
||
processedText = uniqueLines.join('\n');
|
||
}
|
||
|
||
// Для Single View НЕ добавляем переносы строк, только убираем дубликаты
|
||
// Убираем лишние переносы строк в начале, если они есть
|
||
processedText = processedText.replace(/^\n+/, '');
|
||
|
||
// Проверяем результат
|
||
if (text.includes('FoundINFO:') && processedText !== text) {
|
||
console.log('✅ processSingleViewSpecialReplacements: Замена выполнена:', processedText);
|
||
} else if (text.includes('FoundINFO:') && processedText === text) {
|
||
console.log('❌ processSingleViewSpecialReplacements: Замена НЕ выполнена для:', text);
|
||
}
|
||
|
||
return processedText;
|
||
}
|
||
|
||
// Тестовая функция для проверки работы processMultiViewLineBreaks (убрана для снижения шума в консоли)
|
||
function testMultiViewLineBreaks() {
|
||
// Функция оставлена для совместимости, но логи убраны
|
||
}
|
||
|
||
// Функция для тестирования исправлений дублирования
|
||
function testDuplicateRemoval() {
|
||
// Функция оставлена для совместимости, но логи убраны
|
||
}
|
||
|
||
// Функция для тестирования Single View дублирования (убрана для снижения шума в консоли)
|
||
function testSingleViewDuplicateRemoval() {
|
||
// Функция оставлена для совместимости, но логи убраны
|
||
}
|
||
|
||
// Функция для тестирования очистки пустых строк в Single View (убрана для снижения шума в консоли)
|
||
function testSingleViewEmptyLinesRemoval() {
|
||
// Функция оставлена для совместимости, но логи убраны
|
||
}
|
||
|
||
// Функция для тестирования правильного отображения переносов строк
|
||
function testSingleViewLineBreaks() {
|
||
// Функция оставлена для совместимости, но логи убраны
|
||
}
|
||
|
||
// Тестовая функция для проверки работы cleanMultiViewEmptyLines
|
||
function testCleanMultiViewEmptyLines() {
|
||
// Функция оставлена для совместимости, но логи убраны
|
||
}
|
||
|
||
// Тестовая функция для проверки работы normalizeSpaces
|
||
function testNormalizeSpaces() {
|
||
// Функция оставлена для совместимости, но логи убраны
|
||
}
|
||
|
||
// Тестовая функция для проверки работы processMultiViewSpecialReplacements
|
||
function testMultiViewSpecialReplacements() {
|
||
// Функция оставлена для совместимости, но логи убраны
|
||
console.log('Тест 1 (обычная строка):', JSON.stringify(processMultiViewSpecialReplacements('Hello World')));
|
||
console.log('Тест 2 (200 OKINFO:):', JSON.stringify(processMultiViewSpecialReplacements('200 OKINFO: Some message')));
|
||
console.log('Тест 3 (404 Not FoundINFO:):', JSON.stringify(processMultiViewSpecialReplacements('404 Not FoundINFO: Some message')));
|
||
console.log('Тест 4 (FoundINFO:):', JSON.stringify(processMultiViewSpecialReplacements('FoundINFO: Some message')));
|
||
console.log('Тест 5 (Found INFO:):', JSON.stringify(processMultiViewSpecialReplacements('Found INFO: Some message')));
|
||
console.log('Тест 6 (500 Internal Server ErrorINFO:):', JSON.stringify(processMultiViewSpecialReplacements('500 Internal Server ErrorINFO: Some message')));
|
||
console.log('Тест 7 (GET /api/usersINFO:):', JSON.stringify(processMultiViewSpecialReplacements('GET /api/usersINFO: Some message')));
|
||
console.log('Тест 8 (POST /api/loginINFO:):', JSON.stringify(processMultiViewSpecialReplacements('POST /api/loginINFO: Some message')));
|
||
console.log('Тест 9 (INFO: в начале):', JSON.stringify(processMultiViewSpecialReplacements('INFO: Some message')));
|
||
console.log('Тест 10 (несколько INFO:):', JSON.stringify(processMultiViewSpecialReplacements('200 OKINFO: First INFO: Second')));
|
||
console.log('Тест 11 (пустая строка):', JSON.stringify(processMultiViewSpecialReplacements('')));
|
||
console.log('Тест 12 (null):', JSON.stringify(processMultiViewSpecialReplacements(null)));
|
||
console.log('=== Конец теста ===');
|
||
}
|
||
|
||
// Комплексная тестовая функция для проверки полного процесса обработки MultiView
|
||
function testFullMultiViewProcessing() {
|
||
console.log('=== Тест полного процесса обработки MultiView ===');
|
||
|
||
const testCases = [
|
||
'200 OKINFO: Some message',
|
||
'404 Not FoundINFO: Another message',
|
||
'500 Internal Server ErrorINFO: Third message',
|
||
'FoundINFO: First FoundINFO: Second',
|
||
'GET /api/usersINFO: API call',
|
||
'POST /api/loginINFO: Login attempt',
|
||
'Short',
|
||
'Long message with 200 OKINFO: inside'
|
||
];
|
||
|
||
testCases.forEach((testCase, index) => {
|
||
console.log(`\nТест ${index + 1}: "${testCase}"`);
|
||
|
||
// 1. Нормализация пробелов
|
||
const normalized = normalizeSpaces(testCase);
|
||
console.log(' 1. Нормализация пробелов:', JSON.stringify(normalized));
|
||
|
||
// 2. Специальные замены
|
||
const specialProcessed = processMultiViewSpecialReplacements(normalized);
|
||
console.log(' 2. Специальные замены:', JSON.stringify(specialProcessed));
|
||
|
||
// 3. Обработка переноса строк
|
||
const finalProcessed = processMultiViewLineBreaks(specialProcessed);
|
||
console.log(' 3. Перенос строк:', JSON.stringify(finalProcessed));
|
||
|
||
console.log(' Результат:', finalProcessed);
|
||
});
|
||
|
||
console.log('\n=== Конец комплексного теста ===');
|
||
}
|
||
|
||
// Быстрая функция для тестирования замены INFO:
|
||
function quickTestINFO() {
|
||
console.log('=== Быстрый тест замены INFO: ===');
|
||
const testStrings = [
|
||
'INFO: 127.0.0.1:53352 - "GET /health HTTP/1.1" 200 OKINFO: 127.0.0.1:53352 - "GET /health HTTP/1.1" 200 OK',
|
||
'INFO: 127.0.0.1:48988 - "GET /health HTTP/1.1" 200 OKINFO: 127.0.0.1:48988 - "GET /health HTTP/1.1" 200 OK',
|
||
'INFO: 127.0.0.1:44606 - "GET /health HTTP/1.1" 200 OKINFO: 127.0.0.1:44606 - "GET /health HTTP/1.1" 200 OK',
|
||
'200 OKINFO:',
|
||
'404 Not FoundINFO:',
|
||
'500 Internal Server ErrorINFO:',
|
||
'FoundINFO:',
|
||
'INFO:'
|
||
];
|
||
|
||
testStrings.forEach((str, index) => {
|
||
const result = processMultiViewSpecialReplacements(str);
|
||
console.log(`Тест ${index + 1}: "${str}" -> "${result}"`);
|
||
});
|
||
console.log('=== Конец быстрого теста ===');
|
||
}
|
||
|
||
// Функция для тестирования регулярного выражения
|
||
function testRegex() {
|
||
console.log('=== Тест регулярного выражения ===');
|
||
|
||
const testString = 'INFO: 172.22.0.10:33860 - "POST /api/v1/auth/verify HTTP/1.1" 404 Not FoundINFO: 172.22.0.10:33860 - "POST /api/v1/auth/verify HTTP/1.1" 404 Not Found';
|
||
|
||
console.log('Исходная строка:', testString);
|
||
|
||
// Тестируем наше регулярное выражение
|
||
const result = testString.replace(/([A-Za-z0-9\s]+)INFO:/g, '$1\nINFO:');
|
||
console.log('Результат замены:', result);
|
||
|
||
// Проверяем, есть ли совпадения
|
||
const matches = testString.match(/([A-Za-z0-9\s]+)INFO:/g);
|
||
console.log('Найденные совпадения:', matches);
|
||
|
||
console.log('=== Конец теста регулярного выражения ===');
|
||
}
|
||
|
||
// Функция для проверки HTML в MultiView на наличие FoundINFO:
|
||
function checkMultiViewHTML() {
|
||
console.log('=== Проверка HTML в MultiView ===');
|
||
|
||
const multiViewLogs = document.querySelectorAll('.multi-view-log');
|
||
console.log('Найдено MultiView логов:', multiViewLogs.length);
|
||
|
||
multiViewLogs.forEach((log, index) => {
|
||
const containerId = log.getAttribute('data-container-id');
|
||
console.log(`MultiView ${index + 1} (${containerId}):`);
|
||
|
||
// Проверяем весь HTML
|
||
const html = log.innerHTML;
|
||
if (html.includes('FoundINFO:')) {
|
||
console.log('🚨 НАЙДЕНО FoundINFO: в HTML!');
|
||
console.log('HTML:', html);
|
||
} else {
|
||
console.log('✅ FoundINFO: не найдено в HTML');
|
||
}
|
||
|
||
// Проверяем текстовое содержимое
|
||
const textContent = log.textContent;
|
||
if (textContent.includes('FoundINFO:')) {
|
||
console.log('🚨 НАЙДЕНО FoundINFO: в тексте!');
|
||
console.log('Текст:', textContent);
|
||
} else {
|
||
console.log('✅ FoundINFO: не найдено в тексте');
|
||
}
|
||
});
|
||
|
||
console.log('=== Конец проверки HTML ===');
|
||
}
|
||
|
||
/**
|
||
* Основная функция обработки строк логов
|
||
* Классифицирует, фильтрует и отображает строки логов в зависимости от режима
|
||
* @param {string} id - ID контейнера
|
||
* @param {string} line - Строка лога для обработки
|
||
*/
|
||
function handleLine(id, line){
|
||
|
||
const obj = state.open[id];
|
||
if (!obj) {
|
||
console.error(`handleLine: Object not found for container ${id}, available containers:`, Object.keys(state.open));
|
||
return;
|
||
}
|
||
|
||
// Отладочная информация для первых нескольких строк
|
||
if (!obj.counters) {
|
||
console.error(`handleLine: Counters not initialized for container ${id}`);
|
||
obj.counters = {dbg:0, info:0, warn:0, err:0, other:0};
|
||
}
|
||
|
||
// Фильтруем сообщение "Connected to container" для всех режимов
|
||
// Это сообщение отправляется сервером при установке WebSocket соединения
|
||
if (line.includes('Connected to container:')) {
|
||
return; // Пропускаем это сообщение во всех режимах
|
||
}
|
||
|
||
// Нормализуем пробелы в строке лога
|
||
const normalizedLine = normalizeSpaces(line);
|
||
|
||
const cls = classify(normalizedLine);
|
||
|
||
// Обновляем счетчики только для отображаемых логов
|
||
// Проверяем фильтры для отображения в зависимости от режима
|
||
let shouldShow;
|
||
if (state.multiViewMode && state.selectedContainers.includes(id)) {
|
||
// Для multi-view используем настройки конкретного контейнера
|
||
shouldShow = allowedByContainerLevel(cls, id) && applyFilter(normalizedLine);
|
||
} else {
|
||
// Для single-view используем глобальные настройки
|
||
shouldShow = allowedByLevel(cls) && applyFilter(normalizedLine);
|
||
}
|
||
|
||
// Обновляем счетчики только если строка будет отображаться
|
||
if (obj.counters && shouldShow) {
|
||
if (cls==='dbg') obj.counters.dbg++;
|
||
if (cls==='ok') obj.counters.info++;
|
||
if (cls==='warn') obj.counters.warn++;
|
||
if (cls==='err') obj.counters.err++;
|
||
if (cls==='other') obj.counters.other++;
|
||
}
|
||
|
||
// Обновляем счетчики в кнопках заголовков
|
||
updateHeaderCounters(id, obj.counters);
|
||
|
||
// Для Single View НЕ добавляем перенос строки после каждой строки лога
|
||
const html = `<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) {
|
||
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
|
||
if (state.current && state.current.id === id && els.logContent) {
|
||
// Добавляем новую строку напрямую в современный интерфейс
|
||
els.logContent.insertAdjacentHTML('beforeend', singleViewHtml);
|
||
|
||
// Очищаем лишние пустые строки в современном интерфейсе
|
||
cleanSingleViewEmptyLines(els.logContent);
|
||
|
||
if (els.autoscroll && els.autoscroll.checked) {
|
||
els.logContent.scrollTop = els.logContent.scrollHeight;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Update multi-view interface
|
||
if (state.multiViewMode && state.selectedContainers.includes(id)) {
|
||
const multiViewLog = document.querySelector(`.multi-view-log[data-container-id="${id}"]`);
|
||
if (multiViewLog) {
|
||
// Проверяем фильтры для конкретного контейнера
|
||
const shouldShowInMultiView = allowedByContainerLevel(cls, id) && applyFilter(normalizedLine);
|
||
|
||
if (shouldShowInMultiView) {
|
||
// Применяем ограничение tail lines в multi view
|
||
const tailLines = parseInt(els.tail.value) || 50;
|
||
|
||
// Порядок обработки строк для MultiView:
|
||
// 1. Нормализация пробелов (уже выполнена выше)
|
||
// 2. Специальные замены (например, "FoundINFO:" -> "Found\nINFO:")
|
||
// 3. Обработка переноса строк
|
||
const specialProcessedLine = processMultiViewSpecialReplacements(normalizedLine);
|
||
|
||
// Обрабатываем перенос строк для multi view
|
||
// Если символов больше 5, то перенос строк работает
|
||
// Если меньше 5, то переноса строк нет
|
||
const processedLine = processMultiViewLineBreaks(specialProcessedLine);
|
||
|
||
// Проверяем на дублирование в multi-view логах
|
||
const existingLines = Array.from(multiViewLog.querySelectorAll('.line'));
|
||
const lastLine = existingLines[existingLines.length - 1];
|
||
if (lastLine && lastLine.textContent === processedLine) {
|
||
return; // Пропускаем дублированную строку
|
||
}
|
||
|
||
const multiViewHtml = `<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;
|
||
// Удаляем первые N строк
|
||
logLines.slice(0, linesToRemove).forEach(line => {
|
||
line.remove();
|
||
});
|
||
}
|
||
|
||
if (els.autoscroll && els.autoscroll.checked) {
|
||
multiViewLog.scrollTop = multiViewLog.scrollHeight;
|
||
}
|
||
|
||
}
|
||
} else {
|
||
console.error(`handleLine: Multi-view log element not found for container ${id}`);
|
||
}
|
||
|
||
// Обновляем счетчики в multi view периодически (каждые 10 строк)
|
||
if (!state.multiViewCounterUpdateTimer) {
|
||
state.multiViewCounterUpdateTimer = setTimeout(() => {
|
||
updateMultiViewCounters();
|
||
|
||
// Периодически очищаем дублированные строки во всех multi-view логах
|
||
state.selectedContainers.forEach(containerId => {
|
||
const multiViewLog = document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`);
|
||
if (multiViewLog) {
|
||
cleanMultiViewDuplicateLines(multiViewLog);
|
||
}
|
||
});
|
||
|
||
state.multiViewCounterUpdateTimer = null;
|
||
}, 1000); // Обновляем каждую секунду
|
||
}
|
||
}
|
||
}
|
||
|
||
function ensurePanel(svc){
|
||
let panel = els.grid.querySelector(`.panel[data-cid="${svc.id}"]`);
|
||
if (!panel){
|
||
panel = panelTemplate(svc);
|
||
els.grid.appendChild(panel);
|
||
panel.querySelector('.t-reconnect').onclick = ()=>{
|
||
const id = svc.id;
|
||
const o = state.open[id];
|
||
if (o){ o.logEl.textContent=''; closeWs(id); }
|
||
openWs(svc, panel);
|
||
};
|
||
panel.querySelector('.t-close').onclick = ()=>{
|
||
closeWs(svc.id);
|
||
panel.remove();
|
||
if (!Object.keys(state.open).length) setWsState('off');
|
||
};
|
||
panel.querySelector('.t-snapshot').onclick = ()=> sendSnapshot(svc.id);
|
||
}
|
||
return panel;
|
||
}
|
||
|
||
/**
|
||
* Переключает интерфейс в режим одиночного просмотра (single view)
|
||
* Закрывает мультипросмотр, открывает WebSocket для выбранного контейнера
|
||
* @param {Object} svc - Объект сервиса/контейнера для просмотра
|
||
*/
|
||
async function switchToSingle(svc){
|
||
try {
|
||
|
||
// Всегда очищаем мультипросмотр при переключении в single view
|
||
console.log('Clearing multi-view mode');
|
||
state.multiViewMode = false;
|
||
|
||
// Закрываем WebSocket соединения для мультипросмотра
|
||
// Closing WebSocket connections for multi-view
|
||
state.selectedContainers.forEach(containerId => {
|
||
closeWs(containerId);
|
||
});
|
||
|
||
// Очищаем область логов
|
||
if (els.logContent) {
|
||
els.logContent.innerHTML = '';
|
||
}
|
||
|
||
// Воссоздаем single-view-panel если его нет
|
||
const logContent = document.querySelector('.log-content');
|
||
const singleViewPanel = document.getElementById('singleViewPanel');
|
||
if (logContent && !singleViewPanel) {
|
||
logContent.innerHTML = `
|
||
<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();
|
||
}
|
||
|
||
// Legacy functionality (скрытая)
|
||
setLayout('tabs');
|
||
els.grid.innerHTML='';
|
||
const panel = ensurePanel(svc);
|
||
panel.querySelector('.log').textContent='';
|
||
closeWs(svc.id);
|
||
openWs(svc, panel);
|
||
state.current = svc;
|
||
buildTabs();
|
||
for (const p of [...els.grid.children]) if (p!==panel) p.remove();
|
||
|
||
// Обновляем состояние выбранных контейнеров для корректного отображения заголовка
|
||
state.selectedContainers = [svc.id];
|
||
|
||
// Сохраняем режим просмотра в localStorage
|
||
saveViewMode(false, [svc.id]);
|
||
|
||
// Обновляем активное состояние в UI
|
||
updateActiveContainerUI(svc.id);
|
||
|
||
// Сохраняем состояние кнопок loglevels в localStorage
|
||
saveLogLevelsState();
|
||
|
||
|
||
if (els.multiViewPanelTitle) {
|
||
els.multiViewPanelTitle.textContent = `${svc.name} (${svc.service || svc.name})`;
|
||
}
|
||
if (els.singleViewTitle) {
|
||
els.singleViewTitle.textContent = `${svc.name} (${svc.service || svc.name})`;
|
||
}
|
||
if (els.logContent) {
|
||
els.logContent.innerHTML = '<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;
|
||
|
||
// Если у нас уже есть логи в буфере, отображаем их
|
||
if (obj.allLogs && obj.allLogs.length > 0) {
|
||
els.logContent.innerHTML = '';
|
||
obj.allLogs.forEach(logEntry => {
|
||
if (allowedByLevel(logEntry.cls) && applyFilter(logEntry.line)) {
|
||
els.logContent.insertAdjacentHTML('beforeend', logEntry.html);
|
||
}
|
||
});
|
||
|
||
// Очищаем лишние пустые строки после восстановления логов
|
||
cleanSingleViewEmptyLines(els.logContent);
|
||
cleanDuplicateLines(els.logContent);
|
||
|
||
if (els.autoscroll && els.autoscroll.checked) {
|
||
els.logContent.scrollTop = els.logContent.scrollHeight;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Update active state in container list
|
||
document.querySelectorAll('.container-item').forEach(item => {
|
||
item.classList.remove('active');
|
||
});
|
||
const activeItem = document.querySelector(`.container-item[data-cid="${svc.id}"]`);
|
||
if (activeItem) {
|
||
activeItem.classList.add('active');
|
||
}
|
||
|
||
// Обновляем состояние чекбоксов после переключения контейнера
|
||
updateContainerSelectionUI();
|
||
|
||
// Обновляем счетчики для нового контейнера
|
||
setTimeout(() => {
|
||
recalculateCounters();
|
||
// Применяем настройки wrap text после переключения контейнера
|
||
applyWrapSettings();
|
||
}, 500); // Небольшая задержка для завершения загрузки логов
|
||
await updateCounters(svc.id);
|
||
|
||
// Добавляем обработчики для счетчиков после переключения контейнера
|
||
addCounterClickHandlers();
|
||
|
||
// Обновляем состояние кнопок уровней логирования
|
||
setTimeout(() => {
|
||
initializeLevelButtons();
|
||
}, 100);
|
||
|
||
} catch (error) {
|
||
console.error('switchToSingle: Error occurred:', error);
|
||
console.error('switchToSingle: Error stack:', error.stack);
|
||
}
|
||
}
|
||
|
||
async function openMulti(ids){
|
||
els.grid.innerHTML='';
|
||
const chosen = state.services.filter(s=> ids.includes(s.id));
|
||
const n = chosen.length;
|
||
if (n<=1){ if (n===1) await switchToSingle(chosen[0]); return; }
|
||
setLayout(n>=4 ? 'grid4' : n===3 ? 'grid3' : 'grid2');
|
||
for (const svc of chosen){
|
||
const panel = ensurePanel(svc);
|
||
panel.querySelector('.log').textContent='';
|
||
closeWs(svc.id);
|
||
openWs(svc, panel);
|
||
}
|
||
|
||
// Добавляем обработчики для счетчиков после открытия мульти-контейнеров
|
||
addCounterClickHandlers();
|
||
|
||
// Применяем настройки wrap text после открытия мульти-контейнеров
|
||
applyWrapSettings();
|
||
}
|
||
|
||
// ----- Copy on selection -----
|
||
function getSelectionText(){
|
||
const sel = window.getSelection();
|
||
return sel && sel.rangeCount ? sel.toString() : "";
|
||
}
|
||
function showCopyFabNearSelection(){
|
||
const sel = window.getSelection();
|
||
if (!sel || sel.rangeCount===0) return hideCopyFab();
|
||
const text = sel.toString();
|
||
if (!text.trim()) return hideCopyFab();
|
||
// Only show if selection inside a .log or .logwrap
|
||
const range = sel.getRangeAt(0);
|
||
const common = range.commonAncestorContainer;
|
||
const el = common.nodeType===1 ? common : common.parentElement;
|
||
if (!el || !el.closest('.logwrap')) return hideCopyFab();
|
||
const rect = range.getBoundingClientRect();
|
||
const top = rect.bottom + 8 + window.scrollY;
|
||
const left = rect.right + 8 + window.scrollX;
|
||
els.copyFab.style.top = top + 'px';
|
||
els.copyFab.style.left = left + 'px';
|
||
els.copyFab.classList.add('show');
|
||
}
|
||
function hideCopyFab(){
|
||
els.copyFab.classList.remove('show');
|
||
}
|
||
document.addEventListener('selectionchange', ()=>{
|
||
// throttle-ish using requestAnimationFrame
|
||
window.requestAnimationFrame(showCopyFabNearSelection);
|
||
});
|
||
document.addEventListener('scroll', hideCopyFab, true);
|
||
els.copyFab.addEventListener('click', async ()=>{
|
||
const text = getSelectionText();
|
||
if (!text) return;
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
const old = els.copyFab.textContent;
|
||
els.copyFab.textContent = 'скопировано';
|
||
setTimeout(()=> els.copyFab.textContent = old, 1000);
|
||
hideCopyFab();
|
||
window.getSelection()?.removeAllRanges();
|
||
} catch(e){
|
||
alert('не удалось скопировать: ' + e);
|
||
}
|
||
});
|
||
|
||
|
||
function fanGroupUrl(servicesCsv, project){
|
||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||
const tail = els.tail.value || '500';
|
||
const token = encodeURIComponent(localStorage.getItem('access_token') || '');
|
||
const pj = project?`&project=${encodeURIComponent(project)}`:'';
|
||
return `${proto}://${location.host}/api/websocket/fan_group?services=${encodeURIComponent(servicesCsv)}&tail=${tail}&token=${token}${pj}`;
|
||
}
|
||
|
||
function openFanGroup(services){
|
||
// Build a special panel named after the group
|
||
els.grid.innerHTML='';
|
||
const fake = { id: 'group-'+services.join(','), name:'group', service: 'group', project: (localStorage.lb_project||'') };
|
||
const panel = ensurePanel(fake);
|
||
panel.querySelector('.log').textContent='';
|
||
closeWs(fake.id);
|
||
|
||
// Override ws creation to fan_group
|
||
const id = fake.id;
|
||
const logEl = panel.querySelector('.log');
|
||
const wrapEl = panel.querySelector('.logwrap');
|
||
const cdbg = panel.querySelector('.cdbg') || document.querySelector('.cdbg');
|
||
const cinfo = panel.querySelector('.cinfo') || document.querySelector('.cinfo');
|
||
const cwarn = panel.querySelector('.cwarn') || document.querySelector('.cwarn');
|
||
const cerr = panel.querySelector('.cerr') || document.querySelector('.cerr');
|
||
const cother = panel.querySelector('.cother') || document.querySelector('.cother');
|
||
const counters = {dbg:0,info:0,warn:0,err:0,other:0};
|
||
|
||
const ws = new WebSocket(fanGroupUrl(services.join(','), fake.project||''));
|
||
state.open[id] = {ws, logEl, wrapEl, counters, pausedBuffer:[], serviceName: ('group:'+services.join(','))};
|
||
|
||
ws.onopen = ()=> {
|
||
setWsState('on');
|
||
};
|
||
ws.onclose = ()=> {
|
||
setWsState(determineWsState());
|
||
|
||
// Принудительно проверяем состояние через AJAX через 500мс
|
||
setTimeout(() => {
|
||
checkWebSocketStatus();
|
||
}, 500);
|
||
};
|
||
ws.onerror = (error)=> {
|
||
setWsState('err');
|
||
};
|
||
ws.onmessage = (ev)=>{
|
||
// Устанавливаем состояние 'on' при получении сообщений
|
||
setWsState('on');
|
||
|
||
const parts = (ev.data||'').split(/\r?\n/);
|
||
for (let i=0;i<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 {
|
||
// Используем новую функцию пересчета счетчиков
|
||
recalculateMultiViewCounters();
|
||
|
||
// Добавляем обработчики для счетчиков
|
||
addCounterClickHandlers();
|
||
|
||
} catch (error) {
|
||
console.error('Error updating multi-view counters:', error);
|
||
}
|
||
}
|
||
|
||
// Функция для пересчета счетчиков на основе отображаемых логов (Single View)
|
||
function recalculateCounters() {
|
||
if (!state.current) return;
|
||
|
||
const containerId = state.current.id;
|
||
const obj = state.open[containerId];
|
||
if (!obj || !obj.allLogs) return;
|
||
|
||
// Получаем значение Tail Lines
|
||
const tailLines = parseInt(els.tail.value) || 50;
|
||
|
||
// Берем только последние N логов в соответствии с Tail Lines
|
||
const visibleLogs = obj.allLogs.slice(-tailLines);
|
||
|
||
// Сбрасываем счетчики
|
||
obj.counters = {dbg: 0, info: 0, warn: 0, err: 0, other: 0};
|
||
|
||
// Пересчитываем счетчики только для отображаемых логов
|
||
visibleLogs.forEach(logEntry => {
|
||
const shouldShow = allowedByLevel(logEntry.cls) && applyFilter(logEntry.line);
|
||
if (shouldShow) {
|
||
if (logEntry.cls === 'dbg') obj.counters.dbg++;
|
||
if (logEntry.cls === 'ok') obj.counters.info++;
|
||
if (logEntry.cls === 'warn') obj.counters.warn++;
|
||
if (logEntry.cls === 'err') obj.counters.err++;
|
||
if (logEntry.cls === 'other') obj.counters.other++;
|
||
}
|
||
});
|
||
|
||
// Обновляем отображение счетчиков
|
||
const cdbg = document.querySelector('.cdbg');
|
||
const cinfo = document.querySelector('.cinfo');
|
||
const cwarn = document.querySelector('.cwarn');
|
||
const cerr = document.querySelector('.cerr');
|
||
const cother = document.querySelector('.cother');
|
||
|
||
if (cdbg) cdbg.textContent = obj.counters.dbg;
|
||
if (cinfo) cinfo.textContent = obj.counters.info;
|
||
if (cwarn) cwarn.textContent = obj.counters.warn;
|
||
if (cerr) cerr.textContent = obj.counters.err;
|
||
if (cother) cother.textContent = obj.counters.other;
|
||
|
||
// Обновляем счетчики в кнопках заголовка single-view
|
||
updateHeaderCounters(containerId, obj.counters);
|
||
|
||
console.log(`Counters recalculated for container ${containerId} (tail: ${tailLines}):`, obj.counters);
|
||
}
|
||
|
||
// Функция для пересчета счетчиков в MultiView на основе отображаемых логов
|
||
function recalculateMultiViewCounters() {
|
||
if (!state.multiViewMode || state.selectedContainers.length === 0) {
|
||
return;
|
||
}
|
||
|
||
// Recalculating multi-view counters for containers
|
||
|
||
// Получаем значение Tail Lines
|
||
const tailLines = parseInt(els.tail.value) || 50;
|
||
|
||
// Суммируем статистику всех выбранных контейнеров
|
||
let totalDebug = 0;
|
||
let totalInfo = 0;
|
||
let totalWarn = 0;
|
||
let totalError = 0;
|
||
let totalOther = 0;
|
||
|
||
// Пересчитываем счетчики для каждого контейнера
|
||
for (const containerId of state.selectedContainers) {
|
||
const obj = state.open[containerId];
|
||
if (!obj || !obj.allLogs) continue;
|
||
|
||
// Берем только последние N логов в соответствии с Tail Lines
|
||
const visibleLogs = obj.allLogs.slice(-tailLines);
|
||
|
||
// Сбрасываем счетчики для этого контейнера
|
||
obj.counters = {dbg: 0, info: 0, warn: 0, err: 0, other: 0};
|
||
|
||
// Пересчитываем счетчики только для отображаемых логов
|
||
visibleLogs.forEach(logEntry => {
|
||
const shouldShow = allowedByContainerLevel(logEntry.cls, containerId) && applyFilter(logEntry.line);
|
||
if (shouldShow) {
|
||
if (logEntry.cls === 'dbg') obj.counters.dbg++;
|
||
if (logEntry.cls === 'ok') obj.counters.info++;
|
||
if (logEntry.cls === 'warn') obj.counters.warn++;
|
||
if (logEntry.cls === 'err') obj.counters.err++;
|
||
if (logEntry.cls === 'other') obj.counters.other++;
|
||
}
|
||
});
|
||
|
||
// Обновляем счетчики в кнопках заголовка для этого контейнера
|
||
updateHeaderCounters(containerId, obj.counters);
|
||
|
||
// Добавляем к общим счетчикам
|
||
totalDebug += obj.counters.dbg;
|
||
totalInfo += obj.counters.info;
|
||
totalWarn += obj.counters.warn;
|
||
totalError += obj.counters.err;
|
||
totalOther += obj.counters.other;
|
||
}
|
||
|
||
// Обновляем отображение счетчиков
|
||
const cdbg = document.querySelector('.cdbg');
|
||
const cinfo = document.querySelector('.cinfo');
|
||
const cwarn = document.querySelector('.cwarn');
|
||
const cerr = document.querySelector('.cerr');
|
||
const cother = document.querySelector('.cother');
|
||
|
||
if (cdbg) cdbg.textContent = totalDebug;
|
||
if (cinfo) cinfo.textContent = totalInfo;
|
||
if (cwarn) cwarn.textContent = totalWarn;
|
||
if (cerr) cerr.textContent = totalError;
|
||
if (cother) cother.textContent = totalOther;
|
||
|
||
// Multi-view counters recalculated
|
||
}
|
||
|
||
// Функция для обновления видимости счетчиков
|
||
function updateCounterVisibility() {
|
||
// Обновляем старые кнопки счетчиков (только для legacy интерфейса)
|
||
const debugBtn = document.querySelector('.debug-btn');
|
||
const infoBtn = document.querySelector('.info-btn');
|
||
const warnBtn = document.querySelector('.warn-btn');
|
||
const errorBtn = document.querySelector('.error-btn');
|
||
const otherBtn = document.querySelector('.other-btn');
|
||
|
||
if (debugBtn) {
|
||
debugBtn.classList.toggle('disabled', !state.levels.debug);
|
||
}
|
||
if (infoBtn) {
|
||
infoBtn.classList.toggle('disabled', !state.levels.info);
|
||
}
|
||
if (warnBtn) {
|
||
warnBtn.classList.toggle('disabled', !state.levels.warn);
|
||
}
|
||
if (errorBtn) {
|
||
errorBtn.classList.toggle('disabled', !state.levels.err);
|
||
}
|
||
if (otherBtn) {
|
||
otherBtn.classList.toggle('disabled', !state.levels.other);
|
||
}
|
||
}
|
||
|
||
// Функция для управления видимостью кнопок LogLevels
|
||
function updateLogLevelsVisibility() {
|
||
const singleViewLevels = document.querySelector('.single-view-levels');
|
||
const multiViewLevels = document.querySelectorAll('.multi-view-levels');
|
||
|
||
// Проверяем, есть ли выбранные контейнеры
|
||
const hasSelectedContainers = state.selectedContainers.length > 0 || state.current;
|
||
|
||
// Управляем видимостью кнопок в single view
|
||
if (singleViewLevels) {
|
||
if (hasSelectedContainers) {
|
||
singleViewLevels.style.display = 'flex';
|
||
} else {
|
||
singleViewLevels.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// Управляем видимостью кнопок в multi view
|
||
multiViewLevels.forEach(levelsContainer => {
|
||
if (hasSelectedContainers) {
|
||
levelsContainer.style.display = 'flex';
|
||
} else {
|
||
levelsContainer.style.display = 'none';
|
||
}
|
||
});
|
||
}
|
||
|
||
// Функция для обновления логов и счетчиков
|
||
/**
|
||
* Автоматически прокручивает логи к самому низу (последние логи)
|
||
* Работает как для single-view, так и для multi-view режимов
|
||
*/
|
||
function scrollToBottom() {
|
||
if (state.multiViewMode && state.selectedContainers.length > 0) {
|
||
// Для multi-view прокручиваем все панели
|
||
state.selectedContainers.forEach(containerId => {
|
||
const multiViewLog = document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`);
|
||
if (multiViewLog) {
|
||
multiViewLog.scrollTop = multiViewLog.scrollHeight;
|
||
}
|
||
});
|
||
} else if (els.logContent) {
|
||
// Для single-view прокручиваем основной контент
|
||
els.logContent.scrollTop = els.logContent.scrollHeight;
|
||
}
|
||
}
|
||
|
||
async function refreshLogsAndCounters() {
|
||
if (state.multiViewMode && state.selectedContainers.length > 0) {
|
||
// Обновляем мультипросмотр
|
||
console.log('Refreshing multi-view for containers:', state.selectedContainers);
|
||
|
||
// Очищаем логи в мультипросмотре перед обновлением
|
||
state.selectedContainers.forEach(containerId => {
|
||
const multiViewLog = document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`);
|
||
if (multiViewLog) {
|
||
multiViewLog.textContent = 'Refreshing...';
|
||
}
|
||
// Очищаем буфер логов для мультипросмотра
|
||
const obj = state.open[containerId];
|
||
if (obj && obj.allLogs) {
|
||
obj.allLogs = [];
|
||
}
|
||
});
|
||
|
||
// Перезапускаем WebSocket соединения для всех выбранных контейнеров
|
||
state.selectedContainers.forEach(containerId => {
|
||
closeWs(containerId);
|
||
const service = state.services.find(s => s.id === containerId);
|
||
if (service) {
|
||
openMultiViewWs(service);
|
||
}
|
||
});
|
||
|
||
// Пересчитываем счетчики на основе отображаемых логов
|
||
setTimeout(() => {
|
||
recalculateMultiViewCounters();
|
||
// Применяем настройки wrap text после обновления
|
||
applyWrapSettings();
|
||
// Прокручиваем к последним логам
|
||
scrollToBottom();
|
||
}, 1000); // Небольшая задержка для завершения переподключения
|
||
|
||
} else if (state.current) {
|
||
// Обычный режим просмотра
|
||
console.log('Refreshing logs and counters for:', state.current.id);
|
||
|
||
// Очищаем логи перед обновлением
|
||
if (els.logContent) {
|
||
els.logContent.textContent = 'Refreshing...';
|
||
}
|
||
|
||
// Перезапускаем WebSocket соединение для получения свежих логов
|
||
const currentId = state.current.id;
|
||
closeWs(currentId);
|
||
|
||
// Находим обновленный контейнер в списке
|
||
const updatedContainer = state.services.find(s => s.id === currentId);
|
||
if (updatedContainer) {
|
||
// Переключаемся на обновленный контейнер
|
||
await switchToSingle(updatedContainer);
|
||
|
||
// Очищаем лишние пустые строки после переключения
|
||
if (els.logContent) {
|
||
cleanSingleViewEmptyLines(els.logContent);
|
||
cleanDuplicateLines(els.logContent);
|
||
}
|
||
}
|
||
|
||
// Пересчитываем счетчики на основе отображаемых логов
|
||
setTimeout(() => {
|
||
recalculateCounters();
|
||
// Применяем настройки wrap text после обновления
|
||
applyWrapSettings();
|
||
// Прокручиваем к последним логам
|
||
scrollToBottom();
|
||
}, 1000); // Небольшая задержка для завершения переподключения
|
||
|
||
} else {
|
||
console.log('No container selected');
|
||
}
|
||
}
|
||
|
||
// Controls
|
||
els.clearBtn.onclick = ()=> {
|
||
// Очищаем обычный просмотр
|
||
Object.values(state.open).forEach(o => {
|
||
if (o.logEl) o.logEl.textContent = '';
|
||
if (o.allLogs) o.allLogs = []; // Очищаем буфер логов
|
||
});
|
||
|
||
// Очищаем современный интерфейс
|
||
if (els.logContent) {
|
||
els.logContent.textContent = '';
|
||
// Очищаем лишние пустые строки после очистки
|
||
cleanSingleViewEmptyLines(els.logContent);
|
||
cleanDuplicateLines(els.logContent);
|
||
}
|
||
|
||
// Очищаем мультипросмотр
|
||
if (state.multiViewMode) {
|
||
state.selectedContainers.forEach(containerId => {
|
||
const multiViewLog = document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`);
|
||
if (multiViewLog) {
|
||
multiViewLog.textContent = '';
|
||
}
|
||
// Очищаем буфер логов для мультипросмотра
|
||
const obj = state.open[containerId];
|
||
if (obj && obj.allLogs) {
|
||
obj.allLogs = [];
|
||
}
|
||
});
|
||
}
|
||
|
||
// Сбрасываем счетчики
|
||
document.querySelectorAll('.cdbg, .cinfo, .cwarn, .cerr').forEach(el => {
|
||
el.textContent = '0';
|
||
});
|
||
|
||
// Сбрасываем счетчики в объектах состояния
|
||
Object.values(state.open).forEach(obj => {
|
||
if (obj.counters) {
|
||
obj.counters = {dbg: 0, info: 0, warn: 0, err: 0};
|
||
}
|
||
});
|
||
};
|
||
|
||
els.refreshBtn.onclick = async () => {
|
||
console.log('Refreshing services...');
|
||
await fetchServices();
|
||
|
||
if (state.multiViewMode && state.selectedContainers.length > 0) {
|
||
// В Multi View режиме обновляем все выбранные контейнеры
|
||
console.log('Refreshing Multi View mode with containers:', state.selectedContainers);
|
||
|
||
// Закрываем все текущие соединения
|
||
state.selectedContainers.forEach(containerId => {
|
||
closeWs(containerId);
|
||
});
|
||
|
||
// Перезапускаем соединения для всех выбранных контейнеров
|
||
state.selectedContainers.forEach(containerId => {
|
||
const service = state.services.find(s => s.id === containerId);
|
||
if (service) {
|
||
openMultiViewWs(service);
|
||
}
|
||
});
|
||
|
||
// Очищаем логи в мультипросмотре
|
||
state.selectedContainers.forEach(containerId => {
|
||
const multiViewLog = document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`);
|
||
if (multiViewLog) {
|
||
multiViewLog.textContent = 'Refreshing...';
|
||
}
|
||
});
|
||
|
||
} else if (state.current) {
|
||
// Обычный режим просмотра
|
||
console.log('Reconnecting to current container:', state.current.id);
|
||
const currentId = state.current.id;
|
||
|
||
// Закрываем текущее соединение
|
||
closeWs(currentId);
|
||
|
||
// Находим обновленный контейнер в списке
|
||
const updatedContainer = state.services.find(s => s.id === currentId);
|
||
if (updatedContainer) {
|
||
// Переключаемся на обновленный контейнер
|
||
await switchToSingle(updatedContainer);
|
||
} else {
|
||
// Если контейнер больше не существует, не открываем автоматически первый доступный
|
||
// Пользователь сам выберет нужный контейнер
|
||
console.log('Container no longer exists, not auto-opening first available container');
|
||
}
|
||
}
|
||
};
|
||
|
||
// Обработчик для кнопок refresh логов (в log-header и в header)
|
||
document.querySelectorAll('.log-refresh-btn').forEach(btn=>{
|
||
btn.addEventListener('click', refreshLogsAndCounters);
|
||
});
|
||
|
||
// Обработчик для кнопки update (AJAX autoupdate toggle)
|
||
if (els.ajaxUpdateBtn) {
|
||
els.ajaxUpdateBtn.addEventListener('click', () => {
|
||
toggleAjaxLogUpdate();
|
||
});
|
||
} else {
|
||
console.error('Кнопка ajaxUpdateBtn не найдена при инициализации обработчика!');
|
||
}
|
||
|
||
// Обработчики для счетчиков
|
||
function addCounterClickHandlers() {
|
||
// Назначаем обработчики на все дубликаты кнопок (и в log-header, и в header-compact-controls)
|
||
const debugButtons = document.querySelectorAll('.debug-btn');
|
||
const infoButtons = document.querySelectorAll('.info-btn');
|
||
const warnButtons = document.querySelectorAll('.warn-btn');
|
||
const errorButtons = document.querySelectorAll('.error-btn');
|
||
const otherButtons = document.querySelectorAll('.other-btn');
|
||
|
||
debugButtons.forEach(debugBtn => debugBtn.onclick = () => {
|
||
state.levels.debug = !state.levels.debug;
|
||
updateCounterVisibility();
|
||
refreshAllLogs();
|
||
// Добавляем refresh для обновления логов
|
||
if (state.current) {
|
||
refreshLogsAndCounters();
|
||
}
|
||
// Обновляем multi-view если он активен
|
||
if (state.multiViewMode) {
|
||
refreshAllLogs();
|
||
}
|
||
});
|
||
|
||
infoButtons.forEach(infoBtn => infoBtn.onclick = () => {
|
||
state.levels.info = !state.levels.info;
|
||
updateCounterVisibility();
|
||
refreshAllLogs();
|
||
// Добавляем refresh для обновления логов
|
||
if (state.current) {
|
||
refreshLogsAndCounters();
|
||
}
|
||
// Обновляем multi-view если он активен
|
||
if (state.multiViewMode) {
|
||
refreshAllLogs();
|
||
}
|
||
});
|
||
|
||
warnButtons.forEach(warnBtn => warnBtn.onclick = () => {
|
||
state.levels.warn = !state.levels.warn;
|
||
updateCounterVisibility();
|
||
refreshAllLogs();
|
||
// Добавляем refresh для обновления логов
|
||
if (state.current) {
|
||
refreshLogsAndCounters();
|
||
}
|
||
// Обновляем multi-view если он активен
|
||
if (state.multiViewMode) {
|
||
refreshAllLogs();
|
||
}
|
||
});
|
||
|
||
errorButtons.forEach(errorBtn => errorBtn.onclick = () => {
|
||
state.levels.err = !state.levels.err;
|
||
updateCounterVisibility();
|
||
refreshAllLogs();
|
||
// Добавляем refresh для обновления логов
|
||
if (state.current) {
|
||
refreshLogsAndCounters();
|
||
}
|
||
// Обновляем multi-view если он активен
|
||
if (state.multiViewMode) {
|
||
refreshAllLogs();
|
||
}
|
||
});
|
||
|
||
otherButtons.forEach(otherBtn => otherBtn.onclick = () => {
|
||
state.levels.other = !state.levels.other;
|
||
updateCounterVisibility();
|
||
refreshAllLogs();
|
||
// Добавляем refresh для обновления логов
|
||
if (state.current) {
|
||
refreshLogsAndCounters();
|
||
}
|
||
// Обновляем multi-view если он активен
|
||
if (state.multiViewMode) {
|
||
refreshAllLogs();
|
||
}
|
||
});
|
||
}
|
||
|
||
|
||
// Функция для добавления обработчиков мультивыбора проектов
|
||
function addMultiSelectHandlers() {
|
||
const display = document.getElementById('projectSelectDisplay');
|
||
const dropdown = document.getElementById('projectSelectDropdown');
|
||
|
||
console.log('Adding multi-select handlers, elements found:', {display: !!display, dropdown: !!dropdown});
|
||
|
||
if (display && dropdown) {
|
||
// Обработчик клика по дисплею
|
||
display.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
const isOpen = dropdown.style.display !== 'none';
|
||
|
||
if (isOpen) {
|
||
dropdown.style.display = 'none';
|
||
display.classList.remove('active');
|
||
} else {
|
||
dropdown.style.display = 'block';
|
||
display.classList.add('active');
|
||
}
|
||
});
|
||
|
||
// Обработчики кликов по опциям
|
||
dropdown.addEventListener('click', async (e) => {
|
||
e.stopPropagation();
|
||
|
||
const option = e.target.closest('.multi-select-option');
|
||
if (!option) return;
|
||
|
||
const checkbox = option.querySelector('input[type="checkbox"]');
|
||
if (!checkbox) return;
|
||
|
||
const value = option.getAttribute('data-value');
|
||
|
||
// Специальная логика для "All Projects"
|
||
if (value === 'all') {
|
||
if (checkbox.checked) {
|
||
// Если "All Projects" выбран, снимаем все остальные
|
||
dropdown.querySelectorAll('.multi-select-option input[type="checkbox"]').forEach(cb => {
|
||
if (cb !== checkbox) cb.checked = false;
|
||
});
|
||
}
|
||
} else {
|
||
// Если выбран конкретный проект, снимаем "All Projects"
|
||
const allCheckbox = dropdown.querySelector('#project-all');
|
||
if (allCheckbox) allCheckbox.checked = false;
|
||
}
|
||
|
||
// Обновляем отображение и загружаем сервисы
|
||
const selectedProjects = getSelectedProjects();
|
||
updateMultiSelect(selectedProjects);
|
||
await fetchServices();
|
||
});
|
||
|
||
// Закрытие dropdown при клике вне его
|
||
document.addEventListener('click', (e) => {
|
||
if (!display.contains(e.target) && !dropdown.contains(e.target)) {
|
||
dropdown.style.display = 'none';
|
||
display.classList.remove('active');
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// Функция для показа уведомления о горячих клавишах
|
||
function showHotkeysNotification() {
|
||
// Создаем уведомление
|
||
const notification = document.createElement('div');
|
||
notification.className = 'hotkeys-notification';
|
||
notification.innerHTML = `
|
||
<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) {
|
||
forceFixMultiViewStyles();
|
||
}
|
||
}, 100);
|
||
}
|
||
}
|
||
|
||
// Функция для принудительного исправления стилей multi-view логов
|
||
function forceFixMultiViewStyles() {
|
||
const multiViewLogs = document.querySelectorAll('.multi-view-content .multi-view-log');
|
||
|
||
multiViewLogs.forEach((log, index) => {
|
||
const containerId = log.getAttribute('data-container-id');
|
||
|
||
// Универсальное исправление для всех контейнеров
|
||
log.style.setProperty('height', '100%', 'important');
|
||
log.style.setProperty('overflow', 'auto', 'important');
|
||
log.style.setProperty('max-height', 'none', 'important');
|
||
log.style.setProperty('display', 'block', 'important');
|
||
log.style.setProperty('min-height', '200px', 'important');
|
||
log.style.setProperty('position', 'relative', 'important');
|
||
log.style.setProperty('flex', '1', 'important');
|
||
log.style.setProperty('min-height', '0', 'important');
|
||
log.style.setProperty('width', '100%', 'important');
|
||
log.style.setProperty('box-sizing', 'border-box', 'important');
|
||
|
||
// Принудительно вызываем пересчет layout
|
||
log.style.setProperty('transform', 'translateZ(0)', 'important');
|
||
|
||
// Устанавливаем универсальные inline стили для всех контейнеров
|
||
const currentStyle = log.getAttribute('style') || '';
|
||
const newStyle = currentStyle + '; height: 100% !important; overflow: auto !important; flex: 1 !important; min-height: 0 !important; width: 100% !important; box-sizing: border-box !important; display: block !important; position: relative !important;';
|
||
log.setAttribute('style', newStyle);
|
||
|
||
// Проверяем и исправляем родительские элементы для всех контейнеров
|
||
const parentContent = log.closest('.multi-view-content');
|
||
if (parentContent) {
|
||
parentContent.style.setProperty('display', 'flex', 'important');
|
||
parentContent.style.setProperty('flex-direction', 'column', 'important');
|
||
parentContent.style.setProperty('overflow', 'hidden', 'important');
|
||
parentContent.style.setProperty('height', '100%', 'important');
|
||
}
|
||
|
||
const parentPanel = log.closest('.multi-view-panel');
|
||
if (parentPanel) {
|
||
parentPanel.style.setProperty('display', 'flex', 'important');
|
||
parentPanel.style.setProperty('flex-direction', 'column', 'important');
|
||
parentPanel.style.setProperty('overflow', 'hidden', 'important');
|
||
parentPanel.style.setProperty('height', '100%', 'important');
|
||
}
|
||
});
|
||
|
||
// Также исправляем стили для multi-view-content контейнеров
|
||
const multiViewContents = document.querySelectorAll('.multi-view-content');
|
||
multiViewContents.forEach(content => {
|
||
content.style.setProperty('display', 'flex', 'important');
|
||
content.style.setProperty('flex-direction', 'column', 'important');
|
||
content.style.setProperty('overflow', 'hidden', 'important');
|
||
content.style.setProperty('height', '100%', 'important');
|
||
});
|
||
|
||
// Универсальное исправление для всех контейнеров
|
||
multiViewLogs.forEach(log => {
|
||
|
||
// Принудительно устанавливаем все стили заново для всех контейнеров
|
||
log.style.cssText = 'height: 100% !important; overflow: auto !important; max-height: none !important; display: block !important; min-height: 200px !important; position: relative !important; flex: 1 !important; min-height: 0 !important; width: 100% !important; box-sizing: border-box !important;';
|
||
|
||
// Принудительно вызываем пересчет layout
|
||
log.style.setProperty('transform', 'translateZ(0)', 'important');
|
||
});
|
||
|
||
// Применяем настройки wrap text после исправления стилей
|
||
applyWrapSettings();
|
||
}
|
||
|
||
// Функция для обновления стилей логов
|
||
function updateLogStyles() {
|
||
const isCollapsed = els.sidebar && els.sidebar.classList.contains('collapsed');
|
||
|
||
// Обновляем стили для single-view логов
|
||
const singleViewLogs = document.querySelectorAll('.single-view-content .log');
|
||
singleViewLogs.forEach(log => {
|
||
if (isCollapsed) {
|
||
log.style.height = 'calc(100vh - var(--header-height))';
|
||
log.style.overflow = 'auto';
|
||
} else {
|
||
log.style.height = '100%';
|
||
log.style.overflow = 'auto';
|
||
}
|
||
});
|
||
|
||
// Обновляем стили для multi-view логов (более агрессивно)
|
||
const multiViewLogs = document.querySelectorAll('.multi-view-content .multi-view-log');
|
||
|
||
multiViewLogs.forEach((log, index) => {
|
||
const containerId = log.getAttribute('data-container-id');
|
||
|
||
// Принудительно устанавливаем правильные стили независимо от состояния sidebar
|
||
log.style.setProperty('height', '100%', 'important');
|
||
log.style.setProperty('overflow', 'auto', 'important');
|
||
log.style.setProperty('max-height', 'none', 'important');
|
||
log.style.setProperty('display', 'block', 'important');
|
||
log.style.setProperty('min-height', '200px', 'important');
|
||
log.style.setProperty('position', 'relative', 'important');
|
||
log.style.setProperty('flex', '1', 'important');
|
||
log.style.setProperty('min-height', '0', 'important');
|
||
|
||
// Принудительно вызываем пересчет layout
|
||
log.style.setProperty('transform', 'translateZ(0)', 'important');
|
||
});
|
||
|
||
// Также обновляем стили для multi-view-content контейнеров
|
||
const multiViewContents = document.querySelectorAll('.multi-view-content');
|
||
multiViewContents.forEach(content => {
|
||
if (isCollapsed) {
|
||
// В свернутом состоянии multi-view-content должен иметь правильную высоту
|
||
content.style.setProperty('height', 'calc(100vh - var(--header-height) - 60px)', 'important');
|
||
} else {
|
||
content.style.setProperty('height', '100%', 'important');
|
||
}
|
||
content.style.setProperty('overflow', 'hidden', 'important');
|
||
content.style.setProperty('display', 'flex', 'important');
|
||
content.style.setProperty('flex-direction', 'column', 'important');
|
||
});
|
||
|
||
// Применяем настройки wrap text
|
||
applyWrapSettings();
|
||
|
||
// Принудительно исправляем стили multi-view логов
|
||
forceFixMultiViewStyles();
|
||
|
||
// Дополнительная проверка через 500ms для multi view логов
|
||
if (multiViewLogs.length > 0) {
|
||
setTimeout(() => {
|
||
forceFixMultiViewStyles();
|
||
}, 500);
|
||
}
|
||
}
|
||
|
||
// Mobile menu toggle
|
||
if (els.mobileToggle) {
|
||
els.mobileToggle.onclick = () => {
|
||
const sidebar = document.querySelector('.sidebar');
|
||
if (sidebar) {
|
||
sidebar.classList.toggle('open');
|
||
}
|
||
};
|
||
}
|
||
|
||
// Функция для показа/скрытия модального окна с горячими клавишами
|
||
function toggleHotkeysModal() {
|
||
if (els.hotkeysModal) {
|
||
const isVisible = els.hotkeysModal.classList.contains('show');
|
||
if (isVisible) {
|
||
els.hotkeysModal.classList.remove('show');
|
||
} else {
|
||
els.hotkeysModal.classList.add('show');
|
||
}
|
||
}
|
||
}
|
||
|
||
// Sidebar toggle button
|
||
if (els.sidebarToggle) {
|
||
els.sidebarToggle.onclick = toggleSidebar;
|
||
}
|
||
|
||
|
||
|
||
// Modal close button
|
||
if (els.hotkeysModalClose) {
|
||
els.hotkeysModalClose.onclick = toggleHotkeysModal;
|
||
}
|
||
|
||
// Close modal on background click
|
||
if (els.hotkeysModal) {
|
||
els.hotkeysModal.onclick = (e) => {
|
||
if (e.target === els.hotkeysModal) {
|
||
toggleHotkeysModal();
|
||
}
|
||
};
|
||
}
|
||
|
||
// Collapsible sections
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
// Обработчики для сворачивания секций
|
||
document.querySelectorAll('.control-header').forEach(header => {
|
||
header.addEventListener('click', (e) => {
|
||
if (e.target.closest('.collapse-btn')) return; // Не сворачиваем при клике на кнопку
|
||
|
||
const group = header.closest('.control-group');
|
||
|
||
// Если секция минимизирована, сначала разворачиваем
|
||
if (group.classList.contains('minimized')) {
|
||
group.classList.remove('minimized');
|
||
group.classList.add('collapsed');
|
||
const section = group.dataset.section;
|
||
localStorage.setItem(`lb_minimized_${section}`, 'false');
|
||
localStorage.setItem(`lb_collapsed_${section}`, 'true');
|
||
} else {
|
||
// Обычное сворачивание/разворачивание
|
||
group.classList.toggle('collapsed');
|
||
const section = group.dataset.section;
|
||
localStorage.setItem(`lb_collapsed_${section}`, group.classList.contains('collapsed'));
|
||
localStorage.setItem(`lb_minimized_${section}`, 'false');
|
||
}
|
||
});
|
||
});
|
||
|
||
// Обработчики для кнопок сворачивания
|
||
document.querySelectorAll('.collapse-btn').forEach(btn => {
|
||
btn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
const group = btn.closest('.control-group');
|
||
|
||
// Если секция минимизирована, сначала разворачиваем
|
||
if (group.classList.contains('minimized')) {
|
||
group.classList.remove('minimized');
|
||
group.classList.add('collapsed');
|
||
const section = group.dataset.section;
|
||
localStorage.setItem(`lb_minimized_${section}`, 'false');
|
||
localStorage.setItem(`lb_collapsed_${section}`, 'true');
|
||
} else {
|
||
// Обычное сворачивание/разворачивание
|
||
group.classList.toggle('collapsed');
|
||
const section = group.dataset.section;
|
||
localStorage.setItem(`lb_collapsed_${section}`, group.classList.contains('collapsed'));
|
||
localStorage.setItem(`lb_minimized_${section}`, 'false');
|
||
}
|
||
});
|
||
});
|
||
|
||
// Восстанавливаем состояние секций из localStorage или сворачиваем по умолчанию
|
||
document.querySelectorAll('.control-group.collapsible').forEach(group => {
|
||
const section = group.dataset.section;
|
||
const savedCollapsed = localStorage.getItem(`lb_collapsed_${section}`);
|
||
const savedMinimized = localStorage.getItem(`lb_minimized_${section}`);
|
||
|
||
// Если состояние не сохранено, сворачиваем по умолчанию
|
||
if (savedCollapsed === null && savedMinimized === null) {
|
||
group.classList.add('collapsed');
|
||
localStorage.setItem(`lb_collapsed_${section}`, 'true');
|
||
localStorage.setItem(`lb_minimized_${section}`, 'false');
|
||
} else if (savedMinimized === 'true') {
|
||
group.classList.add('minimized');
|
||
group.classList.remove('collapsed');
|
||
} else if (savedCollapsed === 'true') {
|
||
group.classList.add('collapsed');
|
||
group.classList.remove('minimized');
|
||
}
|
||
});
|
||
|
||
// Обработчик для кнопки Options
|
||
if (els.optionsBtn) {
|
||
els.optionsBtn.addEventListener('click', () => {
|
||
const sidebarControls = document.querySelector('.sidebar-controls');
|
||
const isHidden = sidebarControls.classList.contains('hidden');
|
||
|
||
if (isHidden) {
|
||
// Если сайдбар свернут, сначала разворачиваем его
|
||
if (els.sidebar.classList.contains('collapsed')) {
|
||
toggleSidebar();
|
||
}
|
||
// Показываем настройки
|
||
sidebarControls.classList.remove('hidden');
|
||
els.optionsBtn.classList.remove('active');
|
||
els.optionsBtn.title = 'Скрыть настройки';
|
||
localStorage.setItem('lb_options_hidden', 'false');
|
||
} else {
|
||
// Скрываем настройки
|
||
sidebarControls.classList.add('hidden');
|
||
els.optionsBtn.classList.add('active');
|
||
els.optionsBtn.title = 'Показать настройки';
|
||
localStorage.setItem('lb_options_hidden', 'true');
|
||
}
|
||
});
|
||
|
||
// Восстанавливаем состояние кнопки Options (по умолчанию скрыто)
|
||
const optionsHidden = localStorage.getItem('lb_options_hidden');
|
||
if (optionsHidden === null || optionsHidden === 'true') {
|
||
document.querySelector('.sidebar-controls').classList.add('hidden');
|
||
els.optionsBtn.classList.add('active');
|
||
els.optionsBtn.title = 'Показать настройки';
|
||
localStorage.setItem('lb_options_hidden', 'true');
|
||
}
|
||
|
||
// Инициализируем состояние кнопок уровней логирования
|
||
initializeLevelButtons();
|
||
}
|
||
|
||
// Обработчик для кнопки выхода
|
||
if (els.logoutBtn) {
|
||
els.logoutBtn.addEventListener('click', async () => {
|
||
if (confirm('Вы уверены, что хотите выйти?')) {
|
||
try {
|
||
// Вызываем API для выхода
|
||
await fetch('/api/auth/logout', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
|
||
}
|
||
});
|
||
} catch (error) {
|
||
console.error('Logout error:', error);
|
||
} finally {
|
||
// Останавливаем автоматическую проверку WebSocket
|
||
stopWebSocketStatusCheck();
|
||
// Очищаем localStorage
|
||
localStorage.removeItem('access_token');
|
||
// Перенаправляем на страницу входа
|
||
window.location.href = '/login';
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
|
||
|
||
// Инициализируем стили логов при загрузке страницы
|
||
updateLogStyles();
|
||
|
||
// Применяем настройки wrap text при загрузке
|
||
applyWrapSettings();
|
||
|
||
// Дополнительная проверка для multi-view логов при загрузке
|
||
setTimeout(() => {
|
||
if (state.multiViewMode) {
|
||
forceFixMultiViewStyles();
|
||
}
|
||
}, 1000);
|
||
|
||
// Обработчик для кнопки помощи
|
||
if (els.helpBtn) {
|
||
const helpTooltip = document.getElementById('helpTooltip');
|
||
let tooltipTimeout;
|
||
|
||
// Показ модального окна при клике
|
||
els.helpBtn.addEventListener('click', () => {
|
||
showHelpTooltip();
|
||
});
|
||
|
||
// Кнопка закрытия модального окна
|
||
const helpTooltipClose = document.getElementById('helpTooltipClose');
|
||
if (helpTooltipClose) {
|
||
helpTooltipClose.addEventListener('click', () => {
|
||
hideHelpTooltip();
|
||
});
|
||
}
|
||
|
||
// Закрытие по Escape
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') {
|
||
hideHelpTooltip();
|
||
}
|
||
});
|
||
|
||
// Закрытие по клику вне модального окна
|
||
helpTooltip.addEventListener('click', (e) => {
|
||
if (e.target === helpTooltip) {
|
||
hideHelpTooltip();
|
||
}
|
||
});
|
||
}
|
||
|
||
});
|
||
if (els.snapshotBtn) {
|
||
els.snapshotBtn.onclick = ()=>{
|
||
if (state.multiViewMode && state.selectedContainers.length > 0) {
|
||
// В Multi View режиме используем первый выбранный контейнер как ID для sendSnapshot
|
||
// Функция sendSnapshot сама определит, что нужно скачать логи всех контейнеров
|
||
sendSnapshot(state.selectedContainers[0]);
|
||
} else if (state.current) {
|
||
sendSnapshot(state.current.id);
|
||
} else {
|
||
alert('No container selected');
|
||
}
|
||
};
|
||
}
|
||
if (els.tail) {
|
||
els.tail.onchange = ()=> {
|
||
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)) {
|
||
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) {
|
||
setTimeout(() => {
|
||
forceFixMultiViewStyles();
|
||
|
||
// Дополнительно исправляем все контейнеры
|
||
if (window.fixAllContainers) {
|
||
window.fixAllContainers();
|
||
}
|
||
}, 100);
|
||
}
|
||
});
|
||
|
||
// Hotkeys: [ ] х ъ — navigation between containers, Ctrl/Cmd+R/K — refresh logs, Ctrl/Cmd+B/И — toggle sidebar
|
||
window.addEventListener('keydown', async (e)=>{
|
||
// Проверяем, не находится ли фокус в поле ввода
|
||
const activeElement = document.activeElement;
|
||
const isInputActive = activeElement && (
|
||
activeElement.tagName === 'INPUT' ||
|
||
activeElement.tagName === 'TEXTAREA' ||
|
||
activeElement.contentEditable === 'true'
|
||
);
|
||
|
||
// Если фокус в поле ввода, не обрабатываем горячие клавиши
|
||
if (isInputActive) {
|
||
return;
|
||
}
|
||
|
||
// Навигация между контейнерами по [ ] х ъ
|
||
if (e.key==='[' || e.key==='х'){
|
||
e.preventDefault();
|
||
const idx = state.services.findIndex(s=> s.id===state.current?.id);
|
||
if (idx>0) await switchToSingle(state.services[idx-1]);
|
||
}
|
||
if (e.key===']' || e.key==='ъ'){
|
||
e.preventDefault();
|
||
const idx = state.services.findIndex(s=> s.id===state.current?.id);
|
||
if (idx>=0 && idx<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');
|
||
els.autoRefreshOnRestore = document.getElementById('autoRefreshOnRestore');
|
||
|
||
console.log('Elements reinitialized:', {
|
||
filter: !!els.filter,
|
||
containerList: !!els.containerList,
|
||
logContent: !!els.logContent,
|
||
sidebar: !!els.sidebar,
|
||
sidebarToggle: !!els.sidebarToggle
|
||
});
|
||
}
|
||
|
||
// Инициализация
|
||
(async function init() {
|
||
console.log('Initializing LogBoard+...');
|
||
|
||
// Переинициализируем элементы
|
||
reinitializeElements();
|
||
|
||
// Инициализируем состояние WebSocket
|
||
setWsState('off');
|
||
|
||
// Дополнительно инициализируем элементы после полной загрузки DOM
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', reinitializeElements);
|
||
}
|
||
|
||
// Инициализируем элементы после полной загрузки страницы
|
||
window.addEventListener('load', reinitializeElements);
|
||
|
||
// Обработчик для правильной очистки при перезагрузке страницы
|
||
window.addEventListener('beforeunload', () => {
|
||
// Останавливаем автоматическую проверку WebSocket
|
||
stopWebSocketStatusCheck();
|
||
|
||
// Закрываем все WebSocket соединения
|
||
Object.keys(state.open).forEach(id => {
|
||
const obj = state.open[id];
|
||
if (obj && obj.ws) {
|
||
try {
|
||
obj.ws.close();
|
||
} catch (e) {
|
||
// Игнорируем ошибки при закрытии
|
||
}
|
||
}
|
||
});
|
||
|
||
// Очищаем состояние
|
||
state.open = {};
|
||
});
|
||
|
||
// Проверяем авторизацию
|
||
const token = localStorage.getItem('access_token');
|
||
if (!token) {
|
||
console.log('No access token found, redirecting to login');
|
||
window.location.href = '/login';
|
||
return;
|
||
}
|
||
|
||
// Проверяем валидность токена
|
||
try {
|
||
const response = await fetch('/api/auth/me', {
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`
|
||
}
|
||
});
|
||
|
||
if (!response.ok) {
|
||
console.log('Invalid token, redirecting to login');
|
||
localStorage.removeItem('access_token');
|
||
window.location.href = '/login';
|
||
return;
|
||
}
|
||
} catch (error) {
|
||
console.error('Error checking auth:', error);
|
||
localStorage.removeItem('access_token');
|
||
window.location.href = '/login';
|
||
return;
|
||
}
|
||
|
||
console.log('Elements found:', {
|
||
containerList: !!els.containerList,
|
||
|
||
logContent: !!els.logContent,
|
||
mobileToggle: !!els.mobileToggle,
|
||
themeSwitch: !!els.themeSwitch
|
||
});
|
||
|
||
// Проверяем header project select
|
||
const headerSelect = document.getElementById('projectSelectHeader');
|
||
console.log('Header project select found during init:', !!headerSelect);
|
||
|
||
await fetchProjects();
|
||
await fetchServices();
|
||
|
||
// Проверяем состояние WebSocket после загрузки сервисов
|
||
setTimeout(() => {
|
||
setWsState(determineWsState());
|
||
}, 1000);
|
||
|
||
// Запускаем автоматическую проверку состояния WebSocket
|
||
startWebSocketStatusCheck();
|
||
|
||
// Добавляем обработчик клика для кнопки WebSocket статуса
|
||
if (els.wsstate) {
|
||
els.wsstate.addEventListener('click', () => {
|
||
checkWebSocketStatus();
|
||
});
|
||
}
|
||
|
||
// Проверяем, есть ли сохраненный контейнер в localStorage
|
||
const savedContainerId = getSelectedContainerFromStorage();
|
||
if (savedContainerId) {
|
||
const savedService = state.services.find(s => s.id === savedContainerId);
|
||
if (savedService) {
|
||
// Добавляем контейнер в выбранные
|
||
state.selectedContainers = [savedContainerId];
|
||
// Переключаемся на сохраненный контейнер
|
||
await switchToSingle(savedService);
|
||
// Очищаем сохраненный контейнер из localStorage
|
||
saveSelectedContainer(null);
|
||
} else {
|
||
console.log('Saved container not found in services, clearing localStorage');
|
||
saveSelectedContainer(null);
|
||
}
|
||
}
|
||
|
||
// Инициализируем видимость счетчиков
|
||
updateCounterVisibility();
|
||
|
||
// Обновляем состояние чекбоксов после загрузки сервисов
|
||
updateContainerSelectionUI();
|
||
|
||
// Восстанавливаем состояние sidebar
|
||
const sidebarCollapsed = localStorage.getItem('lb_sidebar_collapsed');
|
||
if (sidebarCollapsed === 'true' && els.sidebar && els.sidebarToggle) {
|
||
els.sidebar.classList.add('collapsed');
|
||
els.sidebarToggle.innerHTML = '<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');
|
||
|
||
state.selectedContainers.forEach(containerId => {
|
||
// Ищем элемент несколькими способами
|
||
let multiViewLog = document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`);
|
||
|
||
if (!multiViewLog) {
|
||
console.warn(`Container ${containerId} not found with data-container-id, trying alternative search...`);
|
||
// Попробуем найти по другому селектору
|
||
multiViewLog = document.querySelector(`[data-container-id="${containerId}"]`);
|
||
}
|
||
|
||
if (multiViewLog) {
|
||
|
||
// Получаем все строки логов
|
||
const logLines = Array.from(multiViewLog.querySelectorAll('.line'));
|
||
console.log(`Container ${containerId}: ${logLines.length} log lines found`);
|
||
|
||
if (logLines.length > tailLines) {
|
||
// Удаляем лишние строки с начала
|
||
const linesToRemove = logLines.length - tailLines;
|
||
console.log(`Removing ${linesToRemove} lines from container ${containerId}`);
|
||
|
||
// Удаляем первые N строк
|
||
logLines.slice(0, linesToRemove).forEach(line => {
|
||
line.remove();
|
||
});
|
||
|
||
const remainingLines = multiViewLog.querySelectorAll('.line').length;
|
||
console.log(`Container ${containerId} now has ${remainingLines} lines after trimming`);
|
||
} else {
|
||
console.log(`Container ${containerId} has ${logLines.length} lines, no trimming needed (limit: ${tailLines})`);
|
||
}
|
||
} else {
|
||
console.error(`Multi-view log element not found for container ${containerId}`);
|
||
console.error(`Available multi-view-log elements:`, Array.from(document.querySelectorAll('.multi-view-log')).map(el => ({
|
||
containerId: el.getAttribute('data-container-id'),
|
||
className: el.className,
|
||
parent: el.parentElement?.className
|
||
})));
|
||
}
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
// Добавляем обработчики кликов на label чекбоксов
|
||
document.addEventListener('click', (e) => {
|
||
if (e.target.classList.contains('container-checkbox-label')) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const label = e.target;
|
||
const checkbox = label.previousElementSibling;
|
||
if (checkbox && checkbox.classList.contains('container-checkbox')) {
|
||
checkbox.checked = !checkbox.checked;
|
||
const containerId = checkbox.getAttribute('data-container-id');
|
||
toggleContainerSelection(containerId);
|
||
}
|
||
}
|
||
});
|
||
|
||
// Обработчики для кнопок уровней логирования в заголовках
|
||
console.log('Setting up level button click handler...');
|
||
|
||
// Удаляем предыдущий обработчик, если он существует
|
||
if (window.levelButtonClickHandler) {
|
||
document.removeEventListener('click', window.levelButtonClickHandler);
|
||
console.log('Removed previous level button click handler');
|
||
}
|
||
|
||
// Создаем новый обработчик
|
||
window.levelButtonClickHandler = (e) => {
|
||
|
||
// Проверяем, что клик произошел на кнопке уровня логирования или на ее дочернем элементе
|
||
let levelBtn = null;
|
||
|
||
// Если клик произошел на самой кнопке
|
||
if (e.target.classList.contains('level-btn')) {
|
||
levelBtn = e.target;
|
||
console.log('Click on button itself');
|
||
}
|
||
// Если клик произошел на дочернем элементе кнопки
|
||
else if (e.target.closest('.level-btn')) {
|
||
levelBtn = e.target.closest('.level-btn');
|
||
console.log('Click on child element of button');
|
||
}
|
||
|
||
if (levelBtn) {
|
||
const level = levelBtn.getAttribute('data-level');
|
||
const containerId = levelBtn.getAttribute('data-container-id');
|
||
|
||
console.log(`Клик по кнопке уровня логирования: level=${level}, containerId=${containerId}, multiViewMode=${state.multiViewMode}`);
|
||
console.log(`Кнопка найдена:`, levelBtn);
|
||
console.log(`Текущие классы кнопки:`, levelBtn.className);
|
||
|
||
// Переключаем состояние кнопки
|
||
const isActive = levelBtn.classList.contains('active');
|
||
levelBtn.classList.toggle('active');
|
||
|
||
// Обновляем состояние уровней логирования
|
||
if (containerId) {
|
||
// Для multi-view: конкретный контейнер
|
||
if (!state.containerLevels) {
|
||
state.containerLevels = {};
|
||
}
|
||
if (!state.containerLevels[containerId]) {
|
||
state.containerLevels[containerId] = {debug: true, info: true, warn: true, err: true, other: true};
|
||
}
|
||
state.containerLevels[containerId][level] = !isActive;
|
||
|
||
// Сохраняем состояние кнопок loglevels в localStorage
|
||
saveLogLevelsState();
|
||
|
||
// Обновляем видимость логов только для этого контейнера
|
||
updateContainerLogVisibility(containerId);
|
||
|
||
// Пересчитываем счетчики только для этого контейнера
|
||
setTimeout(() => {
|
||
updateContainerCounters(containerId);
|
||
}, 100);
|
||
|
||
// Обновляем видимость логов для всех контейнеров в multi-view
|
||
// чтобы убедиться, что изменения применились только к нужному контейнеру
|
||
state.selectedContainers.forEach(id => {
|
||
if (id !== containerId) {
|
||
updateContainerLogVisibility(id);
|
||
}
|
||
});
|
||
} else {
|
||
// Для single-view: глобальные настройки
|
||
state.levels[level] = !isActive;
|
||
|
||
// Сохраняем состояние кнопок loglevels в localStorage
|
||
saveLogLevelsState();
|
||
|
||
// Обновляем видимость логов только для текущего контейнера
|
||
if (state.current) {
|
||
updateLogVisibility(els.logContent);
|
||
}
|
||
|
||
// Пересчитываем счетчики только для текущего контейнера
|
||
setTimeout(() => {
|
||
recalculateCounters();
|
||
}, 100);
|
||
}
|
||
}
|
||
};
|
||
|
||
// Добавляем тестовые функции в глобальную область для отладки
|
||
window.testDuplicateRemoval = testDuplicateRemoval;
|
||
window.testSingleViewDuplicateRemoval = testSingleViewDuplicateRemoval;
|
||
window.testSingleViewEmptyLinesRemoval = testSingleViewEmptyLinesRemoval;
|
||
window.testSingleViewLineBreaks = testSingleViewLineBreaks;
|
||
window.testMultiViewLineBreaks = testMultiViewLineBreaks;
|
||
window.testMultiViewSpecialReplacements = testMultiViewSpecialReplacements;
|
||
window.testFullMultiViewProcessing = testFullMultiViewProcessing;
|
||
window.quickTestINFO = quickTestINFO;
|
||
window.testRegex = testRegex;
|
||
window.checkMultiViewHTML = checkMultiViewHTML;
|
||
window.cleanMultiViewDuplicateLines = cleanMultiViewDuplicateLines;
|
||
window.cleanDuplicateLines = cleanDuplicateLines;
|
||
window.cleanSingleViewEmptyLines = cleanSingleViewEmptyLines;
|
||
|
||
// Добавляем функции для исправления стилей в глобальную область
|
||
window.forceFixMultiViewStyles = forceFixMultiViewStyles;
|
||
window.updateLogStyles = updateLogStyles;
|
||
|
||
// Универсальная функция для исправления всех контейнеров
|
||
window.fixAllContainers = function() {
|
||
const allLogs = document.querySelectorAll('.multi-view-content .multi-view-log');
|
||
allLogs.forEach(log => {
|
||
const containerId = log.getAttribute('data-container-id');
|
||
|
||
// Принудительно устанавливаем все стили заново
|
||
log.style.cssText = 'height: 100% !important; overflow: auto !important; max-height: none !important; display: block !important; min-height: 200px !important; position: relative !important; flex: 1 !important; min-height: 0 !important; width: 100% !important; box-sizing: border-box !important;';
|
||
|
||
// Принудительно вызываем пересчет layout
|
||
log.style.setProperty('transform', 'translateZ(0)', 'important');
|
||
|
||
// Проверяем родительские элементы
|
||
const parentContent = log.closest('.multi-view-content');
|
||
if (parentContent) {
|
||
parentContent.style.cssText = 'display: flex !important; flex-direction: column !important; overflow: hidden !important; height: 100% !important;';
|
||
}
|
||
|
||
const parentPanel = log.closest('.multi-view-panel');
|
||
if (parentPanel) {
|
||
parentPanel.style.cssText = 'display: flex !important; flex-direction: column !important; overflow: hidden !important; height: 100% !important;';
|
||
}
|
||
});
|
||
|
||
// Применяем настройки wrap text после исправления всех контейнеров
|
||
applyWrapSettings();
|
||
};
|
||
|
||
// Оставляем старую функцию для обратной совместимости
|
||
window.fixProblematicContainers = function() {
|
||
window.fixAllContainers();
|
||
};
|
||
|
||
// LogBoard+ инициализирован с исправлениями дублирования строк (логи убраны для снижения шума в консоли)
|
||
|
||
// Запускаем первоначальную очистку пустых строк
|
||
setTimeout(() => {
|
||
if (!state.multiViewMode && els.logContent) {
|
||
cleanSingleViewEmptyLines(els.logContent);
|
||
cleanDuplicateLines(els.logContent);
|
||
}
|
||
}, 1000);
|
||
|
||
// Инициализируем видимость кнопок LogLevels
|
||
updateLogLevelsVisibility();
|
||
|
||
// ========================================
|
||
// AJAX ОБНОВЛЕНИЕ ЛОГОВ
|
||
// ========================================
|
||
|
||
// Глобальные переменные для AJAX обновления
|
||
let ajaxUpdateInterval = null;
|
||
let ajaxUpdateEnabled = true; // По умолчанию включен
|
||
let ajaxUpdateIntervalMs = 2000; // 2 секунды по умолчанию (будет переопределено из env)
|
||
|
||
// Состояние для каждого контейнера (для multi-view)
|
||
let containerStates = new Map(); // containerId -> {lastTimestamp, lastSecondCount}
|
||
|
||
/**
|
||
* Включить периодическое обновление логов через AJAX
|
||
* @param {number} intervalMs - Интервал обновления в миллисекундах
|
||
*/
|
||
function enableAjaxLogUpdate(intervalMs = null) {
|
||
if (ajaxUpdateInterval) {
|
||
clearInterval(ajaxUpdateInterval);
|
||
}
|
||
|
||
// Используем переданный интервал или значение по умолчанию
|
||
if (intervalMs === null) {
|
||
intervalMs = ajaxUpdateIntervalMs;
|
||
}
|
||
|
||
ajaxUpdateEnabled = true;
|
||
ajaxUpdateIntervalMs = intervalMs;
|
||
|
||
// Запускаем первое обновление сразу
|
||
performAjaxLogUpdate();
|
||
|
||
// Устанавливаем интервал
|
||
ajaxUpdateInterval = setInterval(performAjaxLogUpdate, intervalMs);
|
||
|
||
// Обновляем UI
|
||
updateAjaxUpdateCheckbox();
|
||
|
||
// Обновляем видимость кнопки refresh и состояние кнопки update
|
||
updateRefreshButtonVisibility();
|
||
}
|
||
|
||
/**
|
||
* Отключить периодическое обновление логов через AJAX
|
||
*/
|
||
function disableAjaxLogUpdate() {
|
||
if (ajaxUpdateInterval) {
|
||
clearInterval(ajaxUpdateInterval);
|
||
ajaxUpdateInterval = null;
|
||
}
|
||
|
||
ajaxUpdateEnabled = false;
|
||
|
||
// Обновляем UI
|
||
updateAjaxUpdateCheckbox();
|
||
|
||
// Обновляем видимость кнопки refresh и состояние кнопки update
|
||
updateRefreshButtonVisibility();
|
||
}
|
||
|
||
/**
|
||
* Переключить состояние AJAX обновления
|
||
*/
|
||
function toggleAjaxLogUpdate() {
|
||
if (ajaxUpdateEnabled) {
|
||
disableAjaxLogUpdate();
|
||
} else {
|
||
enableAjaxLogUpdate(ajaxUpdateIntervalMs);
|
||
}
|
||
|
||
// Обновляем видимость кнопки refresh и состояние кнопки update при переключении
|
||
updateRefreshButtonVisibility();
|
||
}
|
||
|
||
/**
|
||
* Выполнить обновление логов через AJAX
|
||
*/
|
||
async function performAjaxLogUpdate() {
|
||
if (!ajaxUpdateEnabled) {
|
||
return;
|
||
}
|
||
|
||
// Получаем значение tail, учитывая опцию "all"
|
||
let tailLines = els.tail.value;
|
||
if (tailLines === 'all') {
|
||
tailLines = 'all'; // Оставляем как строку для API
|
||
} else {
|
||
tailLines = parseInt(tailLines) || 50;
|
||
}
|
||
|
||
try {
|
||
const token = localStorage.getItem('access_token');
|
||
if (!token) {
|
||
console.error('AJAX Update: No access token found');
|
||
return;
|
||
}
|
||
|
||
// Определяем контейнеры для обновления
|
||
let containersToUpdate = [];
|
||
|
||
if (state.multiViewMode && state.selectedContainers.length > 0) {
|
||
// Multi-view режим: обновляем все выбранные контейнеры
|
||
containersToUpdate = state.selectedContainers;
|
||
} else if (state.current) {
|
||
// Single-view режим: обновляем текущий контейнер
|
||
containersToUpdate = [state.current.id];
|
||
} else {
|
||
return;
|
||
}
|
||
|
||
// Обновляем каждый контейнер
|
||
for (const containerId of containersToUpdate) {
|
||
await updateContainerLogs(containerId, tailLines, token);
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('AJAX Update Error:', error);
|
||
// Не отключаем обновление при ошибке, просто логируем
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Обновить логи для конкретного контейнера
|
||
*/
|
||
async function updateContainerLogs(containerId, tailLines, token) {
|
||
try {
|
||
// Формируем URL с параметрами
|
||
const url = new URL(`/api/logs/${containerId}`, window.location.origin);
|
||
|
||
// Передаем tail параметр как строку (для поддержки "all")
|
||
url.searchParams.set('tail', String(tailLines));
|
||
|
||
// Получаем состояние контейнера
|
||
const containerState = containerStates.get(containerId) || { lastTimestamp: null, lastSecondCount: 0 };
|
||
|
||
// Если у нас есть временная метка последнего обновления, используем её
|
||
if (containerState.lastTimestamp) {
|
||
url.searchParams.set('since', containerState.lastTimestamp);
|
||
}
|
||
|
||
|
||
|
||
// Формируем заголовки запроса
|
||
const headers = {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Cache-Control': 'no-cache'
|
||
};
|
||
const response = await fetch(url.toString(), { headers });
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.error) {
|
||
throw new Error(data.error);
|
||
}
|
||
|
||
// Клиентская дедупликация: пропускаем уже учтённые строки в ту же секунду
|
||
let newPortion = data.logs || [];
|
||
|
||
// Извлекаем секундную часть из timestamp ответа сервера
|
||
const serverTs = (data.timestamp || '').split('.')[0]; // отбрасываем миллисекунды
|
||
if (!containerState.lastTimestamp || serverTs !== containerState.lastTimestamp) {
|
||
// Новая секунда — сбрасываем счётчик
|
||
containerState.lastTimestamp = serverTs;
|
||
containerState.lastSecondCount = 0;
|
||
}
|
||
|
||
if (newPortion.length > 0) {
|
||
// Обрезаем уже учтённые строки в той же секунде
|
||
if (containerState.lastSecondCount > 0 && newPortion.length > containerState.lastSecondCount) {
|
||
newPortion = newPortion.slice(containerState.lastSecondCount);
|
||
} else if (containerState.lastSecondCount >= newPortion.length) {
|
||
newPortion = [];
|
||
}
|
||
|
||
if (newPortion.length > 0) {
|
||
appendNewLogsForContainer(containerId, newPortion);
|
||
containerState.lastSecondCount += newPortion.length;
|
||
}
|
||
}
|
||
|
||
// Обновляем состояние контейнера
|
||
containerStates.set(containerId, containerState);
|
||
|
||
} catch (error) {
|
||
console.error(`AJAX Update Error for ${containerId}:`, error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Добавить новые логи в конец существующих (универсальная функция для single и multi view)
|
||
* @param {string} containerId - ID контейнера
|
||
* @param {Array} newLogs - Массив новых логов
|
||
*/
|
||
function appendNewLogsForContainer(containerId, newLogs) {
|
||
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);
|
||
}
|
||
|
||
|
||
}
|
||
|
||
/**
|
||
* Добавить новые логи в конец существующих (для обратной совместимости)
|
||
* @param {Array} newLogs - Массив новых логов
|
||
*/
|
||
function appendNewLogs(newLogs) {
|
||
if (!state.current || !els.logContent) {
|
||
return;
|
||
}
|
||
|
||
const containerId = state.current.id;
|
||
appendNewLogsForContainer(containerId, newLogs);
|
||
}
|
||
|
||
/**
|
||
* Обновить чекбокс AJAX обновления в UI
|
||
*/
|
||
function updateAjaxUpdateCheckbox() {
|
||
const checkbox = document.getElementById('autoupdate');
|
||
if (checkbox) {
|
||
checkbox.checked = ajaxUpdateEnabled;
|
||
}
|
||
|
||
// Обновляем видимость кнопки refresh в зависимости от состояния ajax autoupdate
|
||
updateRefreshButtonVisibility();
|
||
}
|
||
|
||
/**
|
||
* Обновить видимость кнопки refresh в header и состояние кнопки update
|
||
*/
|
||
function updateRefreshButtonVisibility() {
|
||
const refreshButtons = document.querySelectorAll('.log-refresh-btn');
|
||
|
||
refreshButtons.forEach(btn => {
|
||
if (ajaxUpdateEnabled) {
|
||
// Если ajax autoupdate включен, скрываем кнопку refresh
|
||
btn.style.display = 'none';
|
||
} else {
|
||
// Если ajax autoupdate выключен, показываем кнопку refresh
|
||
btn.style.display = 'inline-flex';
|
||
}
|
||
});
|
||
|
||
// Обновляем состояние кнопки update
|
||
setAjaxUpdateState(ajaxUpdateEnabled);
|
||
}
|
||
|
||
/**
|
||
* Инициализировать чекбокс AJAX обновления
|
||
*/
|
||
function initAjaxUpdateCheckbox() {
|
||
const checkbox = document.getElementById('autoupdate');
|
||
if (!checkbox) {
|
||
console.error('AJAX Update Checkbox not found in HTML');
|
||
return;
|
||
}
|
||
|
||
// Настраиваем чекбокс
|
||
checkbox.title = 'Автоматическое обновление логов через AJAX';
|
||
|
||
// Добавляем обработчик изменения
|
||
checkbox.addEventListener('change', function() {
|
||
if (this.checked) {
|
||
enableAjaxLogUpdate();
|
||
} else {
|
||
disableAjaxLogUpdate();
|
||
}
|
||
|
||
// Обновляем видимость кнопки refresh и состояние кнопки update при изменении состояния чекбокса
|
||
updateRefreshButtonVisibility();
|
||
});
|
||
|
||
// Устанавливаем начальное состояние (включен по умолчанию)
|
||
checkbox.checked = true;
|
||
ajaxUpdateEnabled = true;
|
||
|
||
// Инициализируем чекбокс автообновления при восстановлении панелей
|
||
const autoRefreshCheckbox = els.autoRefreshOnRestore;
|
||
if (autoRefreshCheckbox) {
|
||
// Восстанавливаем состояние из localStorage
|
||
const savedState = localStorage.getItem('lb_auto_refresh_on_restore');
|
||
autoRefreshCheckbox.checked = savedState === 'true';
|
||
|
||
// Добавляем обработчик изменения
|
||
autoRefreshCheckbox.addEventListener('change', function() {
|
||
localStorage.setItem('lb_auto_refresh_on_restore', this.checked ? 'true' : 'false');
|
||
console.log('Auto-refresh on restore setting changed:', this.checked);
|
||
});
|
||
|
||
autoRefreshCheckbox.title = 'Автоматически обновлять логи панелей при восстановлении из localStorage';
|
||
}
|
||
|
||
// Обновляем видимость кнопки refresh и состояние кнопки update при инициализации
|
||
updateRefreshButtonVisibility();
|
||
|
||
|
||
}
|
||
|
||
/**
|
||
* Инициализация AJAX обновления
|
||
*/
|
||
async function initAjaxUpdate() {
|
||
initAjaxUpdateCheckbox();
|
||
|
||
// Получаем настройки с сервера
|
||
try {
|
||
const token = localStorage.getItem('access_token');
|
||
if (token) {
|
||
const response = await fetch('/api/settings', {
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`
|
||
}
|
||
});
|
||
|
||
if (response.ok) {
|
||
const settings = await response.json();
|
||
ajaxUpdateIntervalMs = settings.ajax_update_interval || 2000;
|
||
// AJAX Update: Интервал обновления получен с сервера
|
||
} else {
|
||
console.warn('AJAX Update: Не удалось получить настройки с сервера, используем значение по умолчанию');
|
||
ajaxUpdateIntervalMs = 2000; // 2 секунды по умолчанию
|
||
}
|
||
} else {
|
||
console.warn('AJAX Update: Токен не найден, используем значение по умолчанию');
|
||
ajaxUpdateIntervalMs = 2000; // 2 секунды по умолчанию
|
||
}
|
||
} catch (error) {
|
||
console.error('AJAX Update: Ошибка получения настроек:', error);
|
||
ajaxUpdateIntervalMs = 2000; // 2 секунды по умолчанию
|
||
}
|
||
|
||
// AJAX Update: Интервал обновления установлен
|
||
|
||
// НЕ останавливаем AJAX обновление при смене контейнера
|
||
const originalSwitchToSingle = window.switchToSingle;
|
||
window.switchToSingle = function(containerId) {
|
||
// Очищаем состояние для всех контейнеров
|
||
containerStates.clear();
|
||
return originalSwitchToSingle.call(this, containerId);
|
||
};
|
||
|
||
// НЕ останавливаем AJAX обновление при переключении в multi-view
|
||
const originalSwitchToMultiView = window.switchToMultiView;
|
||
window.switchToMultiView = function() {
|
||
// Очищаем состояние для всех контейнеров
|
||
containerStates.clear();
|
||
return originalSwitchToMultiView.call(this);
|
||
};
|
||
|
||
console.log('AJAX обновление логов инициализировано');
|
||
|
||
// Обновляем видимость кнопки refresh и состояние кнопки update после инициализации
|
||
updateRefreshButtonVisibility();
|
||
}
|
||
|
||
// Запускаем инициализацию AJAX обновления
|
||
initAjaxUpdate().then(() => {
|
||
// Автоматически запускаем AJAX обновление (так как чекбокс включен по умолчанию)
|
||
setTimeout(() => {
|
||
if (ajaxUpdateEnabled) {
|
||
enableAjaxLogUpdate();
|
||
}
|
||
}, 1000); // Запускаем через 1 секунду после инициализации
|
||
});
|
||
|
||
// Экспортируем функции в глобальную область для отладки
|
||
window.enableAjaxLogUpdate = enableAjaxLogUpdate;
|
||
window.disableAjaxLogUpdate = disableAjaxLogUpdate;
|
||
window.toggleAjaxLogUpdate = toggleAjaxLogUpdate;
|
||
window.performAjaxLogUpdate = performAjaxLogUpdate;
|
||
window.updateContainerLogs = updateContainerLogs;
|
||
|
||
// Добавляем обработчик изменения выбранных контейнеров в multi-view
|
||
const originalToggleContainerSelection = window.toggleContainerSelection;
|
||
window.toggleContainerSelection = function(containerId) {
|
||
const result = originalToggleContainerSelection.call(this, containerId);
|
||
|
||
// Если AJAX обновление активно, очищаем состояние для измененных контейнеров
|
||
if (ajaxUpdateEnabled) {
|
||
// Очищаем состояние для всех контейнеров, чтобы избежать проблем с синхронизацией
|
||
containerStates.clear();
|
||
// AJAX Update: Очищено состояние контейнеров после изменения выбора
|
||
}
|
||
|
||
return result;
|
||
};
|
||
|
||
// ============================================================================
|
||
// DRAG & DROP ФУНКЦИОНАЛЬНОСТЬ ДЛЯ MULTI-VIEW ПАНЕЛЕЙ
|
||
// ============================================================================
|
||
|
||
/**
|
||
* Находит целевую панель для drop с расширенной зоной поиска
|
||
* @param {number} x - X координата курсора
|
||
* @param {number} y - Y координата курсора
|
||
* @param {HTMLElement} draggedElement - Перетаскиваемый элемент
|
||
* @returns {HTMLElement|null} Найденная панель или null
|
||
*/
|
||
function findTargetPanel(x, y, draggedElement) {
|
||
// Сначала пробуем найти панель точно под курсором
|
||
let elementBelow = document.elementFromPoint(x, y);
|
||
let targetPanel = elementBelow?.closest('.multi-view-panel');
|
||
|
||
if (targetPanel && targetPanel !== draggedElement) {
|
||
const targetId = targetPanel.getAttribute('data-container-id');
|
||
return targetPanel;
|
||
}
|
||
|
||
// Если не нашли, расширяем зону поиска в радиусе 50px
|
||
const searchRadius = 50;
|
||
const searchPoints = [
|
||
{ x: x - searchRadius, y: y },
|
||
{ x: x + searchRadius, y: y },
|
||
{ x: x, y: y - searchRadius },
|
||
{ x: x, y: y + searchRadius },
|
||
{ x: x - searchRadius/2, y: y - searchRadius/2 },
|
||
{ x: x + searchRadius/2, y: y - searchRadius/2 },
|
||
{ x: x - searchRadius/2, y: y + searchRadius/2 },
|
||
{ x: x + searchRadius/2, y: y + searchRadius/2 }
|
||
];
|
||
|
||
for (const point of searchPoints) {
|
||
elementBelow = document.elementFromPoint(point.x, point.y);
|
||
targetPanel = elementBelow?.closest('.multi-view-panel');
|
||
|
||
if (targetPanel && targetPanel !== draggedElement) {
|
||
return targetPanel;
|
||
}
|
||
}
|
||
|
||
// Если все еще не нашли, проверяем все панели на пересечение с курсором
|
||
const allPanels = document.querySelectorAll('.multi-view-panel');
|
||
for (const panel of allPanels) {
|
||
if (panel === draggedElement) continue;
|
||
|
||
const rect = panel.getBoundingClientRect();
|
||
if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {
|
||
return panel;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Настраивает drag & drop функциональность для панели multi-view
|
||
* @param {HTMLElement} panel - Панель для настройки drag & drop
|
||
*/
|
||
function setupDragAndDrop(panel) {
|
||
const header = panel.querySelector('.multi-view-header');
|
||
if (!header) return;
|
||
|
||
let isDragging = false;
|
||
let dragStartX = 0;
|
||
let dragStartY = 0;
|
||
let draggedElement = null;
|
||
let draggedIndex = -1;
|
||
let dropTarget = null;
|
||
|
||
// Обработчик начала перетаскивания
|
||
header.addEventListener('mousedown', (e) => {
|
||
// Проверяем, что клик по заголовку, а не по кнопкам уровней
|
||
if (e.target.closest('.level-btn')) return;
|
||
|
||
isDragging = true;
|
||
draggedElement = panel;
|
||
draggedIndex = Array.from(panel.parentNode.children).indexOf(panel);
|
||
|
||
dragStartX = e.clientX;
|
||
dragStartY = e.clientY;
|
||
|
||
// Добавляем класс для визуального эффекта
|
||
panel.classList.add('dragging');
|
||
|
||
// Предотвращаем выделение текста
|
||
e.preventDefault();
|
||
|
||
console.log(`Drag started for panel: ${panel.getAttribute('data-container-id')}`);
|
||
});
|
||
|
||
// Обработчик движения мыши
|
||
document.addEventListener('mousemove', (e) => {
|
||
if (!isDragging || !draggedElement) return;
|
||
|
||
const deltaX = e.clientX - dragStartX;
|
||
const deltaY = e.clientY - dragStartY;
|
||
|
||
// Минимальное расстояние для начала перетаскивания
|
||
if (Math.abs(deltaX) < 5 && Math.abs(deltaY) < 5) return;
|
||
|
||
// Обновляем позицию элемента
|
||
draggedElement.style.transform = `translate(${deltaX}px, ${deltaY}px)`;
|
||
|
||
// Находим элемент под курсором с расширенной зоной поиска
|
||
const targetPanel = findTargetPanel(e.clientX, e.clientY, draggedElement);
|
||
|
||
// Убираем подсветку с предыдущей цели
|
||
if (dropTarget && dropTarget !== targetPanel) {
|
||
dropTarget.classList.remove('drop-target');
|
||
}
|
||
|
||
// Подсвечиваем новую цель (без перестановки во время перетаскивания)
|
||
if (targetPanel && targetPanel !== draggedElement) {
|
||
// Убираем подсветку с предыдущей цели
|
||
if (dropTarget && dropTarget !== targetPanel) {
|
||
dropTarget.classList.remove('drop-target');
|
||
}
|
||
|
||
dropTarget = targetPanel;
|
||
targetPanel.classList.add('drop-target');
|
||
} else {
|
||
if (dropTarget) {
|
||
dropTarget.classList.remove('drop-target');
|
||
}
|
||
dropTarget = null;
|
||
}
|
||
});
|
||
|
||
// Обработчик окончания перетаскивания
|
||
document.addEventListener('mouseup', (e) => {
|
||
if (!isDragging || !draggedElement) return;
|
||
|
||
isDragging = false;
|
||
|
||
// Убираем визуальные эффекты
|
||
draggedElement.classList.remove('dragging');
|
||
draggedElement.style.transform = '';
|
||
|
||
// Если есть целевая панель, выполняем перестановку
|
||
if (dropTarget) {
|
||
const containerId = draggedElement.getAttribute('data-container-id');
|
||
const targetContainerId = dropTarget.getAttribute('data-container-id');
|
||
|
||
console.log(`Dropping panel ${containerId} at position of ${targetContainerId}`);
|
||
|
||
// Сохраняем ссылку на элемент для setTimeout
|
||
const targetElement = dropTarget;
|
||
|
||
// Добавляем класс для анимации перестановки
|
||
targetElement.classList.add('swapping');
|
||
|
||
// Выполняем перестановку
|
||
swapPanels(draggedElement, targetElement);
|
||
|
||
// Убираем класс анимации через короткое время
|
||
setTimeout(() => {
|
||
if (targetElement && targetElement.classList) {
|
||
targetElement.classList.remove('swapping');
|
||
}
|
||
}, 300);
|
||
|
||
// Убираем подсветку
|
||
targetElement.classList.remove('drop-target');
|
||
|
||
// Сохраняем новый порядок в localStorage
|
||
savePanelOrder();
|
||
}
|
||
|
||
// Сбрасываем переменные
|
||
draggedElement = null;
|
||
draggedIndex = -1;
|
||
dropTarget = null;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Мгновенно меняет местами две панели в DOM и обновляет массив selectedContainers
|
||
* @param {HTMLElement} panel1 - Первая панель
|
||
* @param {HTMLElement} panel2 - Вторая панель
|
||
*/
|
||
function swapPanels(panel1, panel2) {
|
||
if (!panel1 || !panel2 || panel1 === panel2) return;
|
||
|
||
const containerId1 = panel1.getAttribute('data-container-id');
|
||
const containerId2 = panel2.getAttribute('data-container-id');
|
||
|
||
if (!containerId1 || !containerId2) {
|
||
console.error('Missing container IDs:', { containerId1, containerId2 });
|
||
return;
|
||
}
|
||
|
||
console.log(`Before swap - Panel1: ${containerId1}, Panel2: ${containerId2}`);
|
||
console.log('Before swap - selectedContainers:', [...state.selectedContainers]);
|
||
|
||
// Меняем местами панели в DOM
|
||
const parent = panel1.parentNode;
|
||
|
||
// Получаем позиции панелей в DOM
|
||
const panel1NextSibling = panel1.nextSibling;
|
||
const panel2NextSibling = panel2.nextSibling;
|
||
|
||
// Вставляем panel1 на место panel2
|
||
parent.insertBefore(panel1, panel2NextSibling);
|
||
|
||
// Вставляем panel2 на место panel1
|
||
parent.insertBefore(panel2, panel1NextSibling);
|
||
|
||
// Обновляем массив selectedContainers
|
||
const index1 = state.selectedContainers.indexOf(containerId1);
|
||
const index2 = state.selectedContainers.indexOf(containerId2);
|
||
|
||
if (index1 !== -1 && index2 !== -1) {
|
||
// Меняем местами элементы в массиве
|
||
state.selectedContainers[index1] = containerId2;
|
||
state.selectedContainers[index2] = containerId1;
|
||
|
||
console.log(`After swap - Panel1: ${containerId1}, Panel2: ${containerId2}`);
|
||
console.log('After swap - selectedContainers:', [...state.selectedContainers]);
|
||
|
||
// Проверяем, что атрибуты data-container-id остались правильными
|
||
const newContainerId1 = panel1.getAttribute('data-container-id');
|
||
const newContainerId2 = panel2.getAttribute('data-container-id');
|
||
console.log(`After swap - Panel1 data-container-id: ${newContainerId1}, Panel2 data-container-id: ${newContainerId2}`);
|
||
} else {
|
||
console.error('Could not find container IDs in selectedContainers:', { index1, index2, containerId1, containerId2 });
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Переставляет контейнеры в массиве selectedContainers
|
||
* @param {string} draggedId - ID перетаскиваемого контейнера
|
||
* @param {string} targetId - ID целевого контейнера
|
||
*/
|
||
function reorderContainers(draggedId, targetId) {
|
||
const draggedIndex = state.selectedContainers.indexOf(draggedId);
|
||
const targetIndex = state.selectedContainers.indexOf(targetId);
|
||
|
||
if (draggedIndex === -1 || targetIndex === -1) return;
|
||
|
||
// Удаляем перетаскиваемый элемент
|
||
state.selectedContainers.splice(draggedIndex, 1);
|
||
|
||
// Вставляем его в новую позицию
|
||
const newTargetIndex = draggedIndex < targetIndex ? targetIndex - 1 : targetIndex;
|
||
state.selectedContainers.splice(newTargetIndex, 0, draggedId);
|
||
|
||
console.log('Reordered containers:', state.selectedContainers);
|
||
}
|
||
|
||
/**
|
||
* Сохраняет порядок панелей в localStorage
|
||
*/
|
||
function savePanelOrder() {
|
||
const panelOrder = Array.from(document.querySelectorAll('.multi-view-panel'))
|
||
.map(panel => panel.getAttribute('data-container-id'))
|
||
.filter(id => id);
|
||
|
||
// Удаляем дубликаты, сохраняя порядок первого вхождения
|
||
const uniquePanelOrder = [...new Set(panelOrder)];
|
||
|
||
localStorage.setItem('lb_panel_order', JSON.stringify(uniquePanelOrder));
|
||
console.log('Panel order saved:', uniquePanelOrder);
|
||
}
|
||
|
||
/**
|
||
* Загружает порядок панелей из localStorage
|
||
* @returns {Array} Массив ID контейнеров в сохраненном порядке
|
||
*/
|
||
function loadPanelOrder() {
|
||
try {
|
||
const savedOrder = localStorage.getItem('lb_panel_order');
|
||
console.log('loadPanelOrder: Raw savedOrder from localStorage:', savedOrder);
|
||
|
||
if (savedOrder) {
|
||
const order = JSON.parse(savedOrder);
|
||
console.log('loadPanelOrder: Parsed order:', order);
|
||
|
||
// Удаляем дубликаты из загруженного порядка
|
||
const uniqueOrder = [...new Set(order)];
|
||
console.log('loadPanelOrder: Unique order:', uniqueOrder);
|
||
return uniqueOrder;
|
||
} else {
|
||
console.log('loadPanelOrder: No saved order found in localStorage');
|
||
}
|
||
} catch (error) {
|
||
console.error('loadPanelOrder: Error loading panel order:', error);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Применяет сохраненный порядок панелей
|
||
*/
|
||
function applyPanelOrder() {
|
||
if (!state.multiViewMode) {
|
||
return;
|
||
}
|
||
|
||
const savedOrder = loadPanelOrder();
|
||
|
||
if (!savedOrder || savedOrder.length === 0) {
|
||
return;
|
||
}
|
||
|
||
const grid = document.getElementById('multiViewGrid');
|
||
|
||
if (!grid) {
|
||
return;
|
||
}
|
||
|
||
// Создаем карту панелей по ID контейнера
|
||
const panels = Array.from(grid.children);
|
||
const panelMap = {};
|
||
panels.forEach(panel => {
|
||
const containerId = panel.getAttribute('data-container-id');
|
||
if (containerId) {
|
||
panelMap[containerId] = panel;
|
||
}
|
||
});
|
||
|
||
// Переставляем панели согласно сохраненному порядку
|
||
savedOrder.forEach(containerId => {
|
||
const panel = panelMap[containerId];
|
||
if (panel && panel.parentNode === grid) {
|
||
grid.appendChild(panel);
|
||
|
||
// Убеждаемся, что WebSocket соединение установлено для переставленной панели
|
||
const service = state.services.find(s => s.id === containerId);
|
||
if (service) {
|
||
// Проверяем, есть ли уже WebSocket соединение
|
||
const existingWs = state.wsConnections && state.wsConnections[containerId];
|
||
if (!existingWs || existingWs.readyState !== WebSocket.OPEN) {
|
||
setTimeout(() => {
|
||
console.log(`Re-establishing WebSocket for reordered panel: ${service.name} (${containerId})`);
|
||
openMultiViewWs(service);
|
||
}, 100);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// Добавляем панели для новых контейнеров, которых нет в сохраненном порядке
|
||
const currentContainers = [...state.selectedContainers];
|
||
const newContainers = currentContainers.filter(id => !savedOrder.includes(id));
|
||
|
||
newContainers.forEach(containerId => {
|
||
// Проверяем, что панель для этого контейнера еще не существует
|
||
const existingPanel = grid.querySelector(`[data-container-id="${containerId}"]`);
|
||
if (!existingPanel) {
|
||
const service = state.services.find(s => s.id === containerId);
|
||
if (service) {
|
||
console.log(`Creating new panel for container: ${service.name} (${containerId})`);
|
||
const panel = createMultiViewPanel(service);
|
||
grid.appendChild(panel);
|
||
|
||
// Создаем WebSocket соединение для новой панели
|
||
setTimeout(() => {
|
||
console.log(`Setting up WebSocket for new panel: ${service.name} (${containerId})`);
|
||
openMultiViewWs(service);
|
||
}, 100);
|
||
}
|
||
} else {
|
||
console.log(`Panel for container ${containerId} already exists, skipping creation`);
|
||
}
|
||
});
|
||
|
||
// Обновляем массив selectedContainers, сохраняя все текущие контейнеры
|
||
// но применяя сохраненный порядок для тех, которые есть в сохраненном порядке
|
||
const orderedContainers = savedOrder.filter(id => currentContainers.includes(id));
|
||
state.selectedContainers = [...orderedContainers, ...newContainers];
|
||
|
||
// Обновляем grid template columns для нового количества панелей
|
||
const totalPanels = state.selectedContainers.length;
|
||
let columns = 1;
|
||
if (totalPanels === 1) columns = 1;
|
||
else if (totalPanels === 2) columns = 2;
|
||
else if (totalPanels <= 4) columns = 2;
|
||
else if (totalPanels <= 6) columns = 3;
|
||
else columns = 4;
|
||
|
||
grid.style.gridTemplateColumns = `repeat(${columns}, 1fr)`;
|
||
console.log(`Updated grid template columns to: repeat(${columns}, 1fr) for ${totalPanels} panels`);
|
||
|
||
console.log('Applied panel order:', state.selectedContainers);
|
||
|
||
// Инициализируем кнопки уровней логирования для восстановленных панелей
|
||
setTimeout(() => {
|
||
console.log('applyPanelOrder: Initializing level buttons for restored panels');
|
||
initializeLevelButtons();
|
||
|
||
// Обновляем логи для восстановленных панелей
|
||
setTimeout(() => {
|
||
console.log('applyPanelOrder: Refreshing logs for restored panels');
|
||
refreshLogsAndCounters();
|
||
|
||
// Дополнительная прокрутка к последним логам
|
||
setTimeout(() => {
|
||
scrollToBottom();
|
||
}, 1300);
|
||
}, 300);
|
||
|
||
// Проверяем, работают ли обработчики событий корректно
|
||
// Если нет, обновляем страницу для полной инициализации
|
||
setTimeout(() => {
|
||
const testButton = document.querySelector('.level-btn');
|
||
if (testButton) {
|
||
console.log('applyPanelOrder: Testing event handlers functionality');
|
||
|
||
// Создаем тестовое событие для проверки
|
||
const testEvent = new MouseEvent('click', {
|
||
bubbles: true,
|
||
cancelable: true,
|
||
view: window
|
||
});
|
||
|
||
// Проверяем, есть ли обработчик на кнопке
|
||
const hasHandler = testButton.onclick !== null ||
|
||
testButton.getAttribute('onclick') !== null ||
|
||
(window.levelButtonClickHandler && document.addEventListener);
|
||
|
||
if (!hasHandler) {
|
||
console.log('applyPanelOrder: Event handlers not working properly, refreshing logs');
|
||
// Обновляем логи панелей вместо обновления страницы
|
||
refreshLogsAndCounters();
|
||
} else {
|
||
console.log('applyPanelOrder: Event handlers working correctly');
|
||
}
|
||
}
|
||
}, 500);
|
||
}, 200);
|
||
}
|
||
|
||
/**
|
||
* Очищает дубликаты из localStorage и обновляет порядок панелей
|
||
*/
|
||
function cleanupDuplicatePanels() {
|
||
try {
|
||
const savedOrder = localStorage.getItem('lb_panel_order');
|
||
if (savedOrder) {
|
||
const order = JSON.parse(savedOrder);
|
||
const uniqueOrder = [...new Set(order)];
|
||
|
||
// Если были найдены дубликаты, обновляем localStorage
|
||
if (order.length !== uniqueOrder.length) {
|
||
localStorage.setItem('lb_panel_order', JSON.stringify(uniqueOrder));
|
||
|
||
// Если мы в multiview режиме, пересоздаем панели
|
||
if (state.multiViewMode) {
|
||
console.log('Recreating panels to remove duplicates');
|
||
// Очищаем текущий grid
|
||
const grid = document.getElementById('multiViewGrid');
|
||
if (grid) {
|
||
grid.innerHTML = '';
|
||
}
|
||
|
||
// Пересоздаем панели в правильном порядке
|
||
setTimeout(() => {
|
||
applyPanelOrder();
|
||
}, 100);
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Error cleaning up duplicate panels:', error);
|
||
}
|
||
}
|
||
|
||
// Экспортируем функции для использования в других частях кода
|
||
window.setupDragAndDrop = setupDragAndDrop;
|
||
window.savePanelOrder = savePanelOrder;
|
||
window.loadPanelOrder = loadPanelOrder;
|
||
window.applyPanelOrder = applyPanelOrder;
|
||
window.cleanupDuplicatePanels = cleanupDuplicatePanels;
|
||
window.swapPanels = swapPanels;
|
||
|
||
})();
|