- Добавлена поддержка LOG: логов PostgreSQL как INFO уровень (зеленый цвет) - Добавлена поддержка FATAL: логов PostgreSQL как ERROR уровень (красный цвет) - Исправлена классификация логов в формате [номер] LOG: и [номер] FATAL: - Убраны отладочные console.log сообщения - Обновлены Dockerfile и docker-compose.yml - Перемещен start.sh в папку app/ Автор: Сергей Антропов Сайт: https://devops.org.ru
5854 lines
245 KiB
JavaScript
5854 lines
245 KiB
JavaScript
/**
|
||
* LogBoard+ - Веб-панель для просмотра логов микросервисов
|
||
* Автор: Сергей Антропов
|
||
* Сайт: https://devops.org.ru
|
||
* Версия: 2.0
|
||
*/
|
||
|
||
console.log('LogBoard+ script loaded - VERSION 2');
|
||
|
||
/**
|
||
* Глобальное состояние приложения
|
||
* Содержит все данные о контейнерах, настройках и режимах отображения
|
||
*/
|
||
const state = {
|
||
services: [], // Список всех доступных сервисов
|
||
current: null, // Текущий выбранный контейнер для single view
|
||
open: {}, // Открытые WebSocket соединения: id -> {ws, logEl, wrapEl, counters, pausedBuffer:[], serviceName}
|
||
layout: 'tabs', // Режим отображения: 'tabs' | 'grid2' | 'grid3' | 'grid4'
|
||
filter: null, // Текущий фильтр для логов
|
||
levels: {debug:true, info:true, warn:true, err:true, other:true}, // Уровни логирования для отображения
|
||
selectedContainers: [], // Массив ID выбранных контейнеров для мультипросмотра
|
||
multiViewMode: false, // Режим мультипросмотра (true = multi-view, false = single-view)
|
||
};
|
||
|
||
/**
|
||
* Ссылки на DOM элементы интерфейса
|
||
* Содержит все элементы управления и отображения
|
||
*/
|
||
const els = {
|
||
// Legacy elements (старые элементы для обратной совместимости)
|
||
tabs: document.getElementById('tabs'), // Контейнер с вкладками
|
||
grid: document.getElementById('grid'), // Контейнер с сеткой
|
||
tail: document.getElementById('tail'), // Поле ввода количества строк логов
|
||
autoscroll: document.getElementById('autoscroll'), // Чекбокс автопрокрутки
|
||
wrapToggle: document.getElementById('wrap'), // Переключатель переноса строк
|
||
|
||
filter: document.getElementById('filter'), // Поле фильтра логов
|
||
wsstate: document.getElementById('wsstate'), // Индикатор состояния WebSocket
|
||
ajaxUpdateBtn: document.getElementById('ajaxUpdateBtn'), // Кнопка AJAX обновления
|
||
projectBadge: document.getElementById('projectBadge'), // Бейдж текущего проекта
|
||
|
||
clearBtn: document.getElementById('clear'), // Кнопка очистки логов
|
||
refreshBtn: document.getElementById('refresh'), // Кнопка обновления
|
||
snapshotBtn: document.getElementById('snapshot'), // Кнопка создания снимка
|
||
lvlDebug: document.getElementById('lvlDebug'), // Кнопка уровня DEBUG
|
||
lvlInfo: document.getElementById('lvlInfo'), // Кнопка уровня INFO
|
||
lvlWarn: document.getElementById('lvlWarn'), // Кнопка уровня WARN
|
||
lvlErr: document.getElementById('lvlErr'), // Кнопка уровня ERROR
|
||
lvlOther: document.getElementById('lvlOther'), // Кнопка уровня OTHER
|
||
layoutBadge: document.getElementById('layoutBadge') || { textContent: '' }, // Бейдж режима отображения
|
||
aggregate: document.getElementById('aggregate') || { checked: false }, // Чекбокс агрегации
|
||
themeSwitch: document.getElementById('themeSwitch'), // Переключатель темы
|
||
copyFab: document.getElementById('copyFab'), // Кнопка копирования
|
||
groupBtn: document.getElementById('groupBtn') || { onclick: null }, // Кнопка группировки
|
||
|
||
// New modern elements (новые элементы современного интерфейса)
|
||
containerList: document.getElementById('containerList'), // Список контейнеров
|
||
logContent: document.getElementById('logContent'), // Основной контент логов
|
||
mobileToggle: document.getElementById('mobileToggle'), // Переключатель мобильного режима
|
||
optionsBtn: document.getElementById('optionsBtn'), // Кнопка настроек
|
||
helpBtn: document.getElementById('helpBtn'), // Кнопка помощи
|
||
logoutBtn: document.getElementById('logoutBtn'), // Кнопка выхода
|
||
sidebar: document.getElementById('sidebar'), // Боковая панель
|
||
sidebarToggle: document.getElementById('sidebarToggle'), // Переключатель боковой панели
|
||
header: document.getElementById('header'), // Заголовок
|
||
hotkeysModal: document.getElementById('hotkeysModal'), // Модальное окно горячих клавиш
|
||
hotkeysModalClose: document.getElementById('hotkeysModalClose'), // Кнопка закрытия модального окна
|
||
multiViewPanel: document.getElementById('multiViewPanel'), // Панель мультипросмотра
|
||
multiViewPanelTitle: document.getElementById('multiViewPanelTitle'), // Заголовок мультипросмотра
|
||
singleViewPanel: document.getElementById('singleViewPanel'), // Панель одиночного просмотра
|
||
singleViewTitle: document.getElementById('singleViewTitle'), // Заголовок одиночного просмотра
|
||
};
|
||
|
||
/**
|
||
* Инициализация переключателя темы
|
||
* Загружает сохраненную тему из localStorage и настраивает переключатель
|
||
*/
|
||
(function initTheme(){
|
||
const saved = localStorage.lb_theme || 'dark';
|
||
document.documentElement.setAttribute('data-theme', saved);
|
||
els.themeSwitch.checked = (saved==='light');
|
||
els.themeSwitch.addEventListener('change', ()=>{
|
||
const t = els.themeSwitch.checked ? 'light' : 'dark';
|
||
document.documentElement.setAttribute('data-theme', t);
|
||
localStorage.lb_theme = t;
|
||
});
|
||
})();
|
||
|
||
/**
|
||
* Устанавливает состояние WebSocket соединения в интерфейсе
|
||
* @param {string} s - Состояние: 'on', 'off', 'err', 'available'
|
||
*/
|
||
function setWsState(s){
|
||
console.log('setWsState: Устанавливаем состояние', s);
|
||
console.log('setWsState: Текущие соединения:', Object.keys(state.open));
|
||
|
||
els.wsstate.textContent = 'ws: ' + s;
|
||
|
||
// Удаляем все классы состояний
|
||
els.wsstate.classList.remove('ws-on', 'ws-off', 'ws-err', 'ws-available');
|
||
|
||
// Добавляем соответствующий класс
|
||
if (s === 'on') {
|
||
els.wsstate.classList.add('ws-on');
|
||
} else if (s === 'off') {
|
||
els.wsstate.classList.add('ws-off');
|
||
} else if (s === 'err') {
|
||
els.wsstate.classList.add('ws-err');
|
||
} else if (s === 'available') {
|
||
els.wsstate.classList.add('ws-available');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Определяет общее состояние WebSocket соединений
|
||
* Проверяет все открытые соединения и устанавливает соответствующее состояние
|
||
*/
|
||
function determineWsState() {
|
||
const openConnections = Object.keys(state.open);
|
||
|
||
console.log('determineWsState: Проверяем', openConnections.length, 'соединений');
|
||
console.log('determineWsState: Все соединения:', openConnections);
|
||
|
||
// Если нет открытых соединений, проверяем сервер через AJAX
|
||
if (openConnections.length === 0) {
|
||
console.log('determineWsState: Нет соединений, проверяем сервер');
|
||
// Асинхронно проверяем сервер, но возвращаем 'off' для немедленного отображения
|
||
// Если сервер доступен, checkWebSocketStatus установит 'on'
|
||
setTimeout(() => {
|
||
checkWebSocketStatus();
|
||
}, 100);
|
||
return 'off';
|
||
}
|
||
|
||
// Проверяем состояние всех соединений
|
||
let hasActiveConnection = false;
|
||
let hasConnecting = false;
|
||
let closedConnections = [];
|
||
let errorConnections = [];
|
||
|
||
for (const id of openConnections) {
|
||
const obj = state.open[id];
|
||
if (obj && obj.ws) {
|
||
console.log(`determineWsState: Соединение ${id}, readyState:`, obj.ws.readyState, 'WebSocket:', obj.ws);
|
||
|
||
if (obj.ws.readyState === WebSocket.OPEN) {
|
||
hasActiveConnection = true;
|
||
console.log(`determineWsState: Соединение ${id} активно`);
|
||
} else if (obj.ws.readyState === WebSocket.CONNECTING) {
|
||
hasConnecting = true;
|
||
console.log(`determineWsState: Соединение ${id} подключается`);
|
||
} else if (obj.ws.readyState === WebSocket.CLOSED || obj.ws.readyState === WebSocket.CLOSING) {
|
||
closedConnections.push(id);
|
||
console.log(`determineWsState: Соединение ${id} закрыто/закрывается`);
|
||
}
|
||
} else {
|
||
console.log(`determineWsState: Соединение ${id} не найдено или нет WebSocket, obj:`, obj);
|
||
closedConnections.push(id);
|
||
}
|
||
}
|
||
|
||
// Удаляем закрытые соединения
|
||
closedConnections.forEach(id => {
|
||
console.log(`determineWsState: Удаляем закрытое соединение ${id}`);
|
||
delete state.open[id];
|
||
});
|
||
|
||
// Если есть активные соединения или есть соединения в процессе установки
|
||
if (hasActiveConnection || hasConnecting) {
|
||
console.log('determineWsState: Есть активные/подключающиеся соединения, возвращаем on');
|
||
return 'on';
|
||
} else {
|
||
console.log('determineWsState: Нет активных соединений, проверяем сервер');
|
||
// Асинхронно проверяем сервер, но возвращаем 'off' для немедленного отображения
|
||
// Если сервер доступен, checkWebSocketStatus установит 'on'
|
||
setTimeout(() => {
|
||
checkWebSocketStatus();
|
||
}, 100);
|
||
return 'off';
|
||
}
|
||
}
|
||
|
||
// Функция для проверки состояния WebSocket через AJAX
|
||
async function checkWebSocketStatus() {
|
||
try {
|
||
const token = localStorage.getItem('access_token');
|
||
if (!token) {
|
||
console.log('checkWebSocketStatus: Нет токена, устанавливаем off');
|
||
setWsState('off');
|
||
return;
|
||
}
|
||
|
||
console.log('checkWebSocketStatus: Отправляем запрос к /api/websocket/status');
|
||
const response = await fetch('/api/websocket/status', {
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`
|
||
}
|
||
});
|
||
|
||
console.log('checkWebSocketStatus: Получен ответ, статус:', response.status, response.statusText);
|
||
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
console.log('checkWebSocketStatus: Получен ответ от сервера:', data);
|
||
|
||
if (data.status === 'available') {
|
||
// Проверяем активные клиентские соединения
|
||
const openConnections = Object.keys(state.open);
|
||
let hasActiveConnection = false;
|
||
|
||
for (const id of openConnections) {
|
||
const obj = state.open[id];
|
||
if (obj && obj.ws && obj.ws.readyState === WebSocket.OPEN) {
|
||
hasActiveConnection = true;
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Если сервер доступен, всегда показываем 'on'
|
||
console.log('checkWebSocketStatus: Сервер доступен, устанавливаем on');
|
||
setWsState('on');
|
||
} else if (data.status === 'no_containers') {
|
||
console.log('checkWebSocketStatus: Нет контейнеров, устанавливаем off');
|
||
setWsState('off');
|
||
} else {
|
||
console.log('checkWebSocketStatus: Ошибка сервера, устанавливаем err');
|
||
setWsState('err');
|
||
}
|
||
} else {
|
||
console.log('checkWebSocketStatus: HTTP ошибка, устанавливаем err');
|
||
setWsState('err');
|
||
}
|
||
} catch (error) {
|
||
console.error('checkWebSocketStatus: Ошибка запроса:', error);
|
||
setWsState('err');
|
||
}
|
||
}
|
||
|
||
// Интервал для автоматической проверки состояния WebSocket
|
||
let wsStatusInterval = null;
|
||
|
||
// Функция для запуска автоматической проверки состояния WebSocket
|
||
function startWebSocketStatusCheck() {
|
||
if (wsStatusInterval) {
|
||
clearInterval(wsStatusInterval);
|
||
}
|
||
|
||
// Проверяем каждые 3 секунды
|
||
wsStatusInterval = setInterval(() => {
|
||
console.log('Автоматическая проверка состояния WebSocket');
|
||
checkWebSocketStatus();
|
||
}, 3000);
|
||
|
||
console.log('Запущена автоматическая проверка состояния WebSocket');
|
||
}
|
||
|
||
// Функция для остановки автоматической проверки
|
||
function stopWebSocketStatusCheck() {
|
||
if (wsStatusInterval) {
|
||
clearInterval(wsStatusInterval);
|
||
wsStatusInterval = null;
|
||
console.log('Остановлена автоматическая проверка состояния WebSocket');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Устанавливает визуальное состояние кнопки AJAX обновления
|
||
* @param {boolean} enabled - Включено ли AJAX обновление
|
||
*/
|
||
function setAjaxUpdateState(enabled) {
|
||
console.log('setAjaxUpdateState: enabled =', enabled, 'els.ajaxUpdateBtn =', !!els.ajaxUpdateBtn);
|
||
|
||
if (els.ajaxUpdateBtn) {
|
||
// Удаляем все классы состояний
|
||
els.ajaxUpdateBtn.classList.remove('ajax-on', 'ajax-off');
|
||
|
||
// Добавляем соответствующий класс
|
||
if (enabled) {
|
||
els.ajaxUpdateBtn.classList.add('ajax-on');
|
||
els.ajaxUpdateBtn.textContent = 'update';
|
||
console.log('setAjaxUpdateState: Устанавливаем зеленый цвет (ajax-on)');
|
||
} else {
|
||
els.ajaxUpdateBtn.classList.add('ajax-off');
|
||
els.ajaxUpdateBtn.textContent = 'update';
|
||
console.log('setAjaxUpdateState: Устанавливаем красный цвет (ajax-off)');
|
||
}
|
||
} else {
|
||
console.error('setAjaxUpdateState: Кнопка ajaxUpdateBtn не найдена!');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Обновляет отображение всех логов при изменении фильтров
|
||
* Перерисовывает логи с учетом текущих настроек фильтрации и уровней
|
||
*/
|
||
function refreshAllLogs() {
|
||
// Обновляем обычный просмотр
|
||
Object.keys(state.open).forEach(id => {
|
||
const obj = state.open[id];
|
||
if (!obj || !obj.logEl) return;
|
||
|
||
// Получаем все логи из буфера
|
||
const allLogs = obj.allLogs || [];
|
||
const filteredHtml = [];
|
||
|
||
allLogs.forEach(logEntry => {
|
||
// Проверяем уровень логирования
|
||
if (!allowedByLevel(logEntry.cls)) return;
|
||
|
||
// Проверяем фильтр
|
||
if (!applyFilter(logEntry.line)) return;
|
||
|
||
filteredHtml.push(logEntry.html);
|
||
});
|
||
|
||
// Обновляем отображение
|
||
obj.logEl.innerHTML = filteredHtml.join('');
|
||
|
||
// Сразу очищаем пустые строки в legacy панели
|
||
cleanSingleViewEmptyLines(obj.logEl);
|
||
cleanDuplicateLines(obj.logEl);
|
||
|
||
// Обновляем современный интерфейс
|
||
if (state.current && state.current.id === id && els.logContent) {
|
||
els.logContent.innerHTML = obj.logEl.innerHTML;
|
||
|
||
// Очищаем дублированные строки в Single View после обновления
|
||
cleanSingleViewEmptyLines(els.logContent);
|
||
cleanDuplicateLines(els.logContent);
|
||
}
|
||
});
|
||
|
||
// Обновляем мультипросмотр
|
||
if (state.multiViewMode) {
|
||
state.selectedContainers.forEach(containerId => {
|
||
const obj = state.open[containerId];
|
||
if (!obj || !obj.logEl) return;
|
||
|
||
// Получаем все логи из буфера
|
||
const allLogs = obj.allLogs || [];
|
||
const filteredHtml = [];
|
||
|
||
allLogs.forEach(logEntry => {
|
||
// Проверяем уровень логирования
|
||
if (!allowedByLevel(logEntry.cls)) return;
|
||
|
||
// Проверяем фильтр
|
||
if (!applyFilter(logEntry.line)) return;
|
||
|
||
filteredHtml.push(logEntry.html);
|
||
});
|
||
|
||
// Обновляем отображение в мультипросмотре
|
||
const multiViewLog = document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`);
|
||
if (multiViewLog) {
|
||
multiViewLog.innerHTML = filteredHtml.join('');
|
||
|
||
// Сразу очищаем пустые строки в мультипросмотре
|
||
cleanMultiViewEmptyLines(multiViewLog);
|
||
cleanMultiViewDuplicateLines(multiViewLog);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Пересчитываем счетчики в зависимости от режима после обновления логов
|
||
setTimeout(() => {
|
||
if (state.multiViewMode) {
|
||
recalculateMultiViewCounters();
|
||
} else {
|
||
recalculateCounters();
|
||
}
|
||
}, 100);
|
||
}
|
||
/**
|
||
* Экранирует HTML символы для безопасного отображения
|
||
* @param {string} s - Строка для экранирования
|
||
* @returns {string} Экранированная строка
|
||
*/
|
||
function escapeHtml(s){ return s.replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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
|
||
}
|
||
|
||
// Отладка для неклассифицированных логов
|
||
if (l.includes('log:') || l.includes('fatal:')) {
|
||
console.log('Unclassified LOG/FATAL line:', line);
|
||
}
|
||
|
||
return 'other';
|
||
}
|
||
/**
|
||
* Проверяет, разрешен ли отображение лога данного уровня
|
||
* @param {string} cls - Класс уровня лога ('dbg', 'err', 'warn', 'ok', 'other')
|
||
* @returns {boolean} Разрешен ли отображение
|
||
*/
|
||
function allowedByLevel(cls){
|
||
if (cls==='dbg') return state.levels.debug;
|
||
if (cls==='err') return state.levels.err;
|
||
if (cls==='warn') return state.levels.warn;
|
||
if (cls==='ok') return state.levels.info;
|
||
if (cls==='other') return state.levels.other; // Показываем неклассифицированные строки в зависимости от настройки
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Проверяет, разрешен ли отображение лога данного уровня для конкретного контейнера
|
||
* Используется в режиме мультипросмотра для индивидуальных настроек контейнеров
|
||
* @param {string} cls - Класс уровня лога ('dbg', 'err', 'warn', 'ok', 'other')
|
||
* @param {string} containerId - ID контейнера
|
||
* @returns {boolean} Разрешен ли отображение
|
||
*/
|
||
function allowedByContainerLevel(cls, containerId) {
|
||
// Если настройки контейнера не инициализированы, инициализируем их
|
||
if (!state.containerLevels) {
|
||
state.containerLevels = {};
|
||
}
|
||
if (!state.containerLevels[containerId]) {
|
||
state.containerLevels[containerId] = {debug: true, info: true, warn: true, err: true, other: true};
|
||
}
|
||
|
||
const containerLevels = state.containerLevels[containerId];
|
||
let result;
|
||
|
||
if (cls==='dbg') result = containerLevels.debug;
|
||
else if (cls==='err') result = containerLevels.err;
|
||
else if (cls==='warn') result = containerLevels.warn;
|
||
else if (cls==='ok') result = containerLevels.info;
|
||
else if (cls==='other') result = containerLevels.other;
|
||
else result = true;
|
||
|
||
console.log(`allowedByContainerLevel: containerId=${containerId}, cls=${cls}, result=${result}, levels=`, containerLevels);
|
||
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Обновляет видимость логов в Single View режиме
|
||
* Перерисовывает логи с учетом текущих фильтров и настроек уровней
|
||
* @param {HTMLElement} logElement - Элемент для обновления
|
||
*/
|
||
function updateLogVisibility(logElement) {
|
||
if (!logElement || !state.current) return;
|
||
|
||
const containerId = state.current.id;
|
||
const obj = state.open[containerId];
|
||
if (!obj || !obj.allLogs) return;
|
||
|
||
// Пересоздаем содержимое лога с учетом фильтров, сохраняя HTML-разметку
|
||
let visibleHtml = '';
|
||
obj.allLogs.forEach(logEntry => {
|
||
const shouldShow = allowedByLevel(logEntry.cls) && applyFilter(logEntry.line);
|
||
if (shouldShow) {
|
||
visibleHtml += logEntry.html + '\n';
|
||
}
|
||
});
|
||
|
||
logElement.innerHTML = visibleHtml;
|
||
|
||
// Обновляем счетчики
|
||
recalculateCounters();
|
||
|
||
// Обновляем состояние кнопок уровней логирования только для single-view
|
||
const singleLevelBtns = document.querySelectorAll('.single-view-levels .level-btn');
|
||
singleLevelBtns.forEach(btn => {
|
||
const level = btn.getAttribute('data-level');
|
||
const isActive = state.levels[level];
|
||
btn.classList.toggle('active', isActive);
|
||
btn.classList.toggle('disabled', !isActive);
|
||
});
|
||
}
|
||
|
||
// Функция для обновления видимости логов конкретного контейнера в Multi View
|
||
function updateContainerLogVisibility(containerId) {
|
||
if (!state.multiViewMode) return;
|
||
|
||
console.log(`updateContainerLogVisibility: Обновляем видимость логов для контейнера ${containerId}`);
|
||
|
||
const logElement = document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`);
|
||
if (!logElement) return;
|
||
|
||
const obj = state.open[containerId];
|
||
if (!obj || !obj.allLogs) return;
|
||
|
||
// Пересоздаем содержимое лога с учетом фильтров контейнера, сохраняя HTML-разметку
|
||
let visibleHtml = '';
|
||
obj.allLogs.forEach(logEntry => {
|
||
const shouldShow = allowedByContainerLevel(logEntry.cls, containerId) && applyFilter(logEntry.line);
|
||
if (shouldShow) {
|
||
visibleHtml += logEntry.html + '\n';
|
||
}
|
||
});
|
||
|
||
logElement.innerHTML = visibleHtml;
|
||
|
||
// Обновляем счетчики для этого контейнера
|
||
updateContainerCounters(containerId);
|
||
|
||
// Обновляем состояние кнопок уровней логирования только для этого контейнера
|
||
const levelBtns = document.querySelectorAll(`.level-btn[data-container-id="${containerId}"]`);
|
||
levelBtns.forEach(btn => {
|
||
const level = btn.getAttribute('data-level');
|
||
// Используем настройки контейнера, если они есть
|
||
const containerLevels = state.containerLevels && state.containerLevels[containerId] ?
|
||
state.containerLevels[containerId] : {debug: true, info: true, warn: true, err: true, other: true};
|
||
const isActive = containerLevels[level];
|
||
btn.classList.toggle('active', isActive);
|
||
btn.classList.toggle('disabled', !isActive);
|
||
});
|
||
}
|
||
|
||
// Функция для обновления счетчиков конкретного контейнера
|
||
function updateContainerCounters(containerId) {
|
||
const obj = state.open[containerId];
|
||
if (!obj || !obj.allLogs) return;
|
||
|
||
// Получаем значение Tail Lines
|
||
const tailLines = parseInt(els.tail.value) || 50;
|
||
|
||
// Берем только последние N логов
|
||
const visibleLogs = obj.allLogs.slice(-tailLines);
|
||
|
||
// Сбрасываем счетчики
|
||
obj.counters = {dbg: 0, info: 0, warn: 0, err: 0, other: 0};
|
||
|
||
// Пересчитываем счетчики только для отображаемых логов
|
||
visibleLogs.forEach(logEntry => {
|
||
const shouldShow = allowedByContainerLevel(logEntry.cls, containerId) && applyFilter(logEntry.line);
|
||
if (shouldShow) {
|
||
if (logEntry.cls === 'dbg') obj.counters.dbg++;
|
||
if (logEntry.cls === 'ok') obj.counters.info++;
|
||
if (logEntry.cls === 'warn') obj.counters.warn++;
|
||
if (logEntry.cls === 'err') obj.counters.err++;
|
||
if (logEntry.cls === 'other') obj.counters.other++;
|
||
}
|
||
});
|
||
|
||
// Обновляем отображение счетчиков в кнопках заголовка
|
||
const levelBtns = document.querySelectorAll(`.level-btn[data-container-id="${containerId}"]`);
|
||
levelBtns.forEach(btn => {
|
||
const level = btn.getAttribute('data-level');
|
||
const valueEl = btn.querySelector('.level-value');
|
||
if (valueEl) {
|
||
switch (level) {
|
||
case 'debug': valueEl.textContent = obj.counters.dbg; break;
|
||
case 'info': valueEl.textContent = obj.counters.info; break;
|
||
case 'warn': valueEl.textContent = obj.counters.warn; break;
|
||
case 'err': valueEl.textContent = obj.counters.err; break;
|
||
case 'other': valueEl.textContent = obj.counters.other; break;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// Функция для обновления счетчиков в кнопках заголовков
|
||
function updateHeaderCounters(containerId, counters) {
|
||
// Обновляем счетчики для single-view (если это текущий контейнер)
|
||
if (state.current && state.current.id === containerId) {
|
||
const singleLevelBtns = document.querySelectorAll('.single-view-levels .level-btn');
|
||
singleLevelBtns.forEach(btn => {
|
||
const level = btn.getAttribute('data-level');
|
||
const valueEl = btn.querySelector('.level-value');
|
||
if (valueEl) {
|
||
switch (level) {
|
||
case 'debug': valueEl.textContent = counters.dbg; break;
|
||
case 'info': valueEl.textContent = counters.info; break;
|
||
case 'warn': valueEl.textContent = counters.warn; break;
|
||
case 'err': valueEl.textContent = counters.err; break;
|
||
case 'other': valueEl.textContent = counters.other; break;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// Обновляем счетчики для multi-view (только для конкретного контейнера)
|
||
if (state.multiViewMode && state.selectedContainers.includes(containerId)) {
|
||
const multiLevelBtns = document.querySelectorAll(`.level-btn[data-container-id="${containerId}"]`);
|
||
multiLevelBtns.forEach(btn => {
|
||
const level = btn.getAttribute('data-level');
|
||
const valueEl = btn.querySelector('.level-value');
|
||
if (valueEl) {
|
||
switch (level) {
|
||
case 'debug': valueEl.textContent = counters.dbg; break;
|
||
case 'info': valueEl.textContent = counters.info; break;
|
||
case 'warn': valueEl.textContent = counters.warn; break;
|
||
case 'err': valueEl.textContent = counters.err; break;
|
||
case 'other': valueEl.textContent = counters.other; break;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// Функция для инициализации состояния кнопок уровней логирования
|
||
function initializeLevelButtons() {
|
||
// Восстанавливаем состояние кнопок loglevels из localStorage
|
||
const savedLevelsState = getLogLevelsStateFromStorage();
|
||
if (savedLevelsState) {
|
||
console.log('Restoring log levels state from localStorage');
|
||
|
||
// Восстанавливаем глобальные настройки для single-view
|
||
if (savedLevelsState.globalLevels) {
|
||
state.levels = { ...state.levels, ...savedLevelsState.globalLevels };
|
||
}
|
||
|
||
// Восстанавливаем настройки контейнеров для multi-view
|
||
if (savedLevelsState.containerLevels) {
|
||
state.containerLevels = { ...state.containerLevels, ...savedLevelsState.containerLevels };
|
||
}
|
||
}
|
||
|
||
// Инициализируем кнопки для single-view
|
||
const singleLevelBtns = document.querySelectorAll('.single-view-levels .level-btn');
|
||
singleLevelBtns.forEach(btn => {
|
||
const level = btn.getAttribute('data-level');
|
||
const isActive = state.levels[level];
|
||
btn.classList.toggle('active', isActive);
|
||
btn.classList.toggle('disabled', !isActive);
|
||
});
|
||
|
||
// Инициализируем кнопки для multi-view (если есть)
|
||
const multiLevelBtns = document.querySelectorAll('.multi-view-levels .level-btn');
|
||
multiLevelBtns.forEach(btn => {
|
||
const level = btn.getAttribute('data-level');
|
||
const containerId = btn.getAttribute('data-container-id');
|
||
|
||
// Инициализируем настройки контейнера, если их нет
|
||
if (containerId && (!state.containerLevels || !state.containerLevels[containerId])) {
|
||
if (!state.containerLevels) {
|
||
state.containerLevels = {};
|
||
}
|
||
state.containerLevels[containerId] = {debug: true, info: true, warn: true, err: true, other: true};
|
||
}
|
||
|
||
// Используем настройки контейнера
|
||
const isActive = state.containerLevels && state.containerLevels[containerId] ?
|
||
state.containerLevels[containerId][level] : true;
|
||
|
||
btn.classList.toggle('active', isActive);
|
||
btn.classList.toggle('disabled', !isActive);
|
||
});
|
||
|
||
// Обновляем стили логов после инициализации кнопок
|
||
updateLogStyles();
|
||
|
||
// Применяем настройки wrap text
|
||
applyWrapSettings();
|
||
}
|
||
/**
|
||
* Применяет фильтр к строке лога
|
||
* Проверяет, соответствует ли строка текущему фильтру (безопасный regex поиск)
|
||
* @param {string} line - Строка лога для проверки
|
||
* @returns {boolean} Проходит ли строка фильтр
|
||
*/
|
||
function applyFilter(line){
|
||
if(!state.filter) return true;
|
||
try{
|
||
// Экранируем специальные символы regex для безопасного поиска
|
||
const escapedFilter = state.filter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
return new RegExp(escapedFilter, 'i').test(line);
|
||
}catch(e){
|
||
console.error('Filter error:', e);
|
||
return true;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Константы и настройки для работы с ANSI цветами
|
||
* SGR (Select Graphic Rendition): 0/1/3/4, 30-37
|
||
*/
|
||
|
||
/**
|
||
* Настройки экземпляров контейнеров
|
||
* Содержит цвета, фильтры и палитру для визуального различия контейнеров
|
||
*/
|
||
const inst = {
|
||
colors: {}, // Кэш цветов для контейнеров
|
||
filters: {}, // Фильтры для экземпляров
|
||
palette: [ // Палитра цветов для контейнеров
|
||
'#7aa2f7','#9ece6a','#e0af68','#f7768e','#bb9af7','#7dcfff','#c0caf5','#f6bd60',
|
||
'#84cc16','#06b6d4','#fb923c','#ef4444','#22c55e','#a855f7'
|
||
]
|
||
};
|
||
|
||
/**
|
||
* Генерирует уникальный цвет для контейнера на основе его ID
|
||
* Использует хеш-функцию для детерминированного выбора цвета из палитры
|
||
* @param {string} id8 - Первые 8 символов ID контейнера
|
||
* @returns {string} HEX цвет для контейнера
|
||
*/
|
||
function idColor(id8){
|
||
if (inst.colors[id8]) return inst.colors[id8];
|
||
// simple hash to pick from palette
|
||
let h = 0; for (let i=0;i<id8.length;i++){ h = (h*31 + id8.charCodeAt(i))>>>0; }
|
||
const color = inst.palette[h % inst.palette.length];
|
||
inst.colors[id8] = color;
|
||
return color;
|
||
}
|
||
|
||
function updateIdFiltersBar(){
|
||
const bar = document.getElementById('idFilters');
|
||
bar.innerHTML = '';
|
||
const ids = Object.keys(inst.filters);
|
||
if (!ids.length){ bar.style.display='none'; return; }
|
||
bar.style.display='flex';
|
||
ids.forEach(id8=>{
|
||
const wrap = document.createElement('label');
|
||
wrap.style.display='inline-flex'; wrap.style.alignItems='center'; wrap.style.gap='6px';
|
||
const cb = document.createElement('input'); cb.type='checkbox'; cb.checked = inst.filters[id8] !== false;
|
||
cb.onchange = ()=> inst.filters[id8] = cb.checked;
|
||
const chip = document.createElement('span');
|
||
chip.className='inst-tag';
|
||
chip.style.borderColor = idColor(id8);
|
||
chip.style.color = idColor(id8);
|
||
chip.textContent = id8;
|
||
wrap.appendChild(cb); wrap.appendChild(chip);
|
||
bar.appendChild(wrap);
|
||
});
|
||
}
|
||
|
||
function shouldShowInstance(id8){
|
||
if (!Object.keys(inst.filters).length) return true;
|
||
const val = inst.filters[id8];
|
||
return val !== false;
|
||
}
|
||
|
||
function parsePrefixAndStrip(line){
|
||
// Accept "[id]" or "[id service]" prefixes from fan/fan_group
|
||
const m = line.match(/^\[([0-9a-f]{8})(?:\s+[^\]]+)?\]\s(.*)$/i);
|
||
if (!m) return null;
|
||
return {id8: m[1], rest: m[2]};
|
||
}
|
||
|
||
/**
|
||
* Конвертирует ANSI escape-последовательности в HTML
|
||
* Поддерживает цвета (30-37), жирный (1), курсив (3), подчеркивание (4)
|
||
* @param {string} text - Текст с ANSI кодами
|
||
* @returns {string} HTML с CSS классами для стилизации
|
||
*/
|
||
function ansiToHtml(text){
|
||
const ESC = '\u001b[';
|
||
const parts = text.split(ESC);
|
||
if (parts.length === 1) return escapeHtml(text);
|
||
let html = escapeHtml(parts[0]);
|
||
let classes = [];
|
||
for (let i=1;i<parts.length;i++){
|
||
const seg = parts[i];
|
||
const m = seg.match(/^([0-9;]+)m(.*)$/s);
|
||
if(!m){ html += escapeHtml(seg); continue; }
|
||
const codes = m[1].split(';').map(Number);
|
||
let rest = m[2];
|
||
for(const c of codes){
|
||
if (c===0) classes = [];
|
||
else if (c===1) classes.push('ansi-bold');
|
||
else if (c===3) classes.push('ansi-italic');
|
||
else if (c===4) classes.push('ansi-underline');
|
||
else if (c>=30 && c<=37){
|
||
classes = classes.filter(x=>!x.startsWith('ansi-'));
|
||
const map = {30:'black',31:'red',32:'green',33:'yellow',34:'blue',35:'magenta',36:'cyan',37:'white'};
|
||
classes.push('ansi-'+map[c]);
|
||
}
|
||
}
|
||
if (classes.length) html += `<span class="${classes.join(' ')}">` + escapeHtml(rest) + `</span>`;
|
||
else html += escapeHtml(rest);
|
||
}
|
||
return html;
|
||
}
|
||
|
||
function panelTemplate(svc){
|
||
const div = document.createElement('div'); div.className='panel'; div.dataset.cid = svc.id;
|
||
div.innerHTML = `
|
||
<div class="title">
|
||
<span>${escapeHtml((svc.project?`[${svc.project}] `:'')+(svc.service||svc.name))}</span>
|
||
<span class="counter">dbg:<b class="cdbg">0</b> info:<b class="cinfo">0</b> warn:<b class="cwarn">0</b> err:<b class="cerr">0</b></span>
|
||
<div class="toolbar">
|
||
<button class="primary t-reconnect">reconnect</button>
|
||
<button class="t-snapshot">snapshot</button>
|
||
<button class="t-close">close</button>
|
||
</div>
|
||
</div>
|
||
<div class="logwrap"><pre class="log"></pre></div>`;
|
||
return div;
|
||
}
|
||
|
||
function buildTabs(){
|
||
// Legacy tabs (hidden)
|
||
els.tabs.innerHTML='';
|
||
state.services.forEach(svc=>{
|
||
const b = document.createElement('button');
|
||
b.className='tab' + ((state.current && svc.id===state.current.id) ? ' active':'');
|
||
b.textContent = (svc.project?(`[${svc.project}] `):'') + (svc.service||svc.name);
|
||
b.title = `${svc.name} • ${svc.image} • ${svc.status}`;
|
||
b.onclick = async ()=> await switchToSingle(svc);
|
||
els.tabs.appendChild(b);
|
||
});
|
||
|
||
// Modern container list
|
||
els.containerList.innerHTML = '';
|
||
|
||
// Миникарточки контейнеров
|
||
const miniContainerList = document.getElementById('miniContainerList');
|
||
if (miniContainerList) {
|
||
miniContainerList.innerHTML = '';
|
||
}
|
||
|
||
state.services.forEach(svc => {
|
||
// Создаем обычную карточку контейнера
|
||
const item = document.createElement('div');
|
||
item.className = 'container-item';
|
||
if (state.current && svc.id === state.current.id) {
|
||
item.classList.add('active');
|
||
}
|
||
item.setAttribute('data-cid', svc.id);
|
||
|
||
const statusClass = svc.status === 'running' ? 'running' :
|
||
svc.status === 'stopped' ? 'stopped' : 'paused';
|
||
|
||
item.innerHTML = `
|
||
<div class="container-name">
|
||
<i class="fas fa-cube"></i>
|
||
${escapeHtml(svc.name)}
|
||
</div>
|
||
<div class="container-service">
|
||
${escapeHtml(svc.service || svc.name)}
|
||
• ${escapeHtml(svc.project || 'standalone')}
|
||
</div>
|
||
<div class="container-status">
|
||
<span class="status-indicator ${statusClass}"></span>
|
||
${escapeHtml(svc.status)}
|
||
${svc.status === 'running' && svc.host_port ? ` on ${svc.host_port} port` : ''}
|
||
${svc.url ? `<a href="${svc.url}" target="_blank" class="container-link" title="Открыть сайт"><i class="fas fa-external-link-alt"></i></a>` : ''}
|
||
</div>
|
||
<div class="container-select">
|
||
<input type="checkbox" id="select-${svc.id}" class="container-checkbox" data-container-id="${svc.id}" ${state.selectedContainers.includes(svc.id) ? 'checked' : ''}>
|
||
<label for="select-${svc.id}" class="container-checkbox-label" title="Выбрать для мультипросмотра"></label>
|
||
</div>
|
||
`;
|
||
|
||
// Устанавливаем состояние selected для контейнера
|
||
if (state.selectedContainers.includes(svc.id)) {
|
||
item.classList.add('selected');
|
||
}
|
||
|
||
item.onclick = async (e) => {
|
||
// Не переключаем контейнер, если кликнули на ссылку или чекбокс
|
||
if (e.target.closest('.container-link') || e.target.closest('.container-select')) {
|
||
e.stopPropagation();
|
||
return;
|
||
}
|
||
await switchToSingle(svc);
|
||
};
|
||
els.containerList.appendChild(item);
|
||
|
||
// Создаем миникарточку контейнера
|
||
if (miniContainerList) {
|
||
const miniItem = document.createElement('div');
|
||
miniItem.className = 'mini-container-item';
|
||
if (state.current && svc.id === state.current.id) {
|
||
miniItem.classList.add('active');
|
||
}
|
||
miniItem.setAttribute('data-cid', svc.id);
|
||
|
||
// Сокращаем имя для миникарточки
|
||
const shortName = svc.name.length > 8 ? svc.name.substring(0, 6) + '..' : svc.name;
|
||
|
||
miniItem.innerHTML = `
|
||
<div class="mini-container-icon">
|
||
<i class="fas fa-cube"></i>
|
||
</div>
|
||
<div class="mini-container-name">${escapeHtml(shortName)}</div>
|
||
<div class="mini-container-status ${statusClass}"></div>
|
||
`;
|
||
|
||
// Добавляем обработчики для всплывающих подсказок
|
||
miniItem.addEventListener('mouseenter', (e) => {
|
||
showMiniContainerTooltip(e, svc);
|
||
});
|
||
|
||
miniItem.addEventListener('mouseleave', () => {
|
||
// Добавляем задержку перед скрытием подсказки
|
||
const tooltip = document.getElementById('miniContainerTooltip');
|
||
if (tooltip) {
|
||
tooltip.hideTimer = setTimeout(() => {
|
||
if (tooltip && !tooltip.matches(':hover')) {
|
||
hideMiniContainerTooltip();
|
||
}
|
||
}, 150);
|
||
}
|
||
});
|
||
|
||
// Обработчик клика для миникарточек с поддержкой Shift+клик и Ctrl+клик
|
||
miniItem.addEventListener('click', async (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
if (e.shiftKey && lastSelectedContainerId && lastSelectedContainerId !== svc.id) {
|
||
// Shift+клик с предыдущим выбором - диапазонный выбор
|
||
console.log('Shift+клик для диапазонного выбора:', lastSelectedContainerId, 'to', svc.id);
|
||
selectContainerRange(lastSelectedContainerId, svc.id);
|
||
} else if (e.shiftKey) {
|
||
// Shift+клик - добавляем/убираем из мультивыбора
|
||
console.log('Shift+клик на миникарточку:', svc.name);
|
||
toggleContainerSelection(svc.id);
|
||
lastSelectedContainerId = svc.id;
|
||
} else if (e.ctrlKey || e.metaKey) {
|
||
// Ctrl/Cmd+клик - добавляем/убираем из мультивыбора
|
||
console.log('Ctrl+клик на миникарточку:', svc.name);
|
||
toggleContainerSelection(svc.id);
|
||
lastSelectedContainerId = svc.id;
|
||
} else {
|
||
// Обычный клик - переключаемся в single view
|
||
console.log('Обычный клик на миникарточку:', svc.name);
|
||
lastSelectedContainerId = svc.id;
|
||
await switchToSingle(svc);
|
||
}
|
||
});
|
||
|
||
miniContainerList.appendChild(miniItem);
|
||
}
|
||
});
|
||
}
|
||
|
||
function setLayout(cls){
|
||
state.layout = cls;
|
||
if (els.layoutBadge) {
|
||
els.layoutBadge.textContent = 'view: ' + (cls==='tabs'?'tabs':cls);
|
||
}
|
||
els.grid.className = cls==='tabs' ? 'grid-1' : (cls==='grid2'?'grid-2':cls==='grid3'?'grid-3':'grid-4');
|
||
}
|
||
|
||
async function fetchProjects(){
|
||
try {
|
||
console.log('Fetching projects...');
|
||
const url = new URL(location.origin + '/api/containers/projects');
|
||
const token = localStorage.getItem('access_token');
|
||
if (!token) {
|
||
console.error('No access token found');
|
||
window.location.href = '/login';
|
||
return;
|
||
}
|
||
|
||
const res = await fetch(url, {
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`
|
||
}
|
||
});
|
||
if (!res.ok){
|
||
if (res.status === 401) {
|
||
console.error('Unauthorized, redirecting to login');
|
||
window.location.href = '/login';
|
||
return;
|
||
}
|
||
console.error('Failed to fetch projects:', res.status, res.statusText);
|
||
return;
|
||
}
|
||
const projects = await res.json();
|
||
console.log('Projects loaded:', projects);
|
||
|
||
|
||
|
||
// Обновляем мультивыбор проектов в заголовке
|
||
const dropdown = document.getElementById('projectSelectDropdown');
|
||
const display = document.getElementById('projectSelectDisplay');
|
||
const displayText = display?.querySelector('.multi-select-text');
|
||
|
||
console.log('Multi-select elements found:', {dropdown: !!dropdown, display: !!display, displayText: !!displayText});
|
||
|
||
if (dropdown && displayText) {
|
||
// Очищаем dropdown
|
||
dropdown.innerHTML = '';
|
||
|
||
// Добавляем опцию "All Projects"
|
||
const allOption = document.createElement('div');
|
||
allOption.className = 'multi-select-option';
|
||
allOption.setAttribute('data-value', 'all');
|
||
allOption.innerHTML = `
|
||
<input type="checkbox" id="project-all" checked>
|
||
<label for="project-all">All Projects</label>
|
||
`;
|
||
dropdown.appendChild(allOption);
|
||
|
||
// Добавляем проекты
|
||
console.log('Adding projects to multi-select:', projects);
|
||
projects.forEach(project => {
|
||
const option = document.createElement('div');
|
||
option.className = 'multi-select-option';
|
||
option.setAttribute('data-value', project);
|
||
option.innerHTML = `
|
||
<input type="checkbox" id="project-${project}">
|
||
<label for="project-${project}">${escapeHtml(project)}</label>
|
||
`;
|
||
dropdown.appendChild(option);
|
||
});
|
||
|
||
// Восстанавливаем сохраненные выбранные проекты
|
||
const savedProjects = JSON.parse(localStorage.getItem('lb_selected_projects') || '["all"]');
|
||
updateMultiSelect(savedProjects);
|
||
|
||
console.log('Multi-select updated, current selection:', savedProjects);
|
||
} else {
|
||
console.error('Multi-select elements not found!');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error fetching projects:', error);
|
||
}
|
||
}
|
||
|
||
// Функция для обновления мультивыбора проектов
|
||
function updateMultiSelect(selectedProjects) {
|
||
const dropdown = document.getElementById('projectSelectDropdown');
|
||
const displayText = document.querySelector('.multi-select-text');
|
||
|
||
if (!dropdown || !displayText) return;
|
||
|
||
// Фильтруем выбранные проекты, оставляя только те, которые есть в dropdown
|
||
const availableProjects = [];
|
||
dropdown.querySelectorAll('.multi-select-option').forEach(option => {
|
||
const value = option.getAttribute('data-value');
|
||
availableProjects.push(value);
|
||
});
|
||
|
||
const filteredProjects = selectedProjects.filter(project =>
|
||
project === 'all' || availableProjects.includes(project)
|
||
);
|
||
|
||
// Если все выбранные проекты исчезли, выбираем "All Projects"
|
||
if (filteredProjects.length === 0 || (filteredProjects.length === 1 && filteredProjects[0] === 'all')) {
|
||
filteredProjects.length = 0;
|
||
filteredProjects.push('all');
|
||
}
|
||
|
||
// Обновляем чекбоксы
|
||
dropdown.querySelectorAll('.multi-select-option').forEach(option => {
|
||
const value = option.getAttribute('data-value');
|
||
const checkbox = option.querySelector('input[type="checkbox"]');
|
||
if (checkbox) {
|
||
checkbox.checked = filteredProjects.includes(value);
|
||
}
|
||
});
|
||
|
||
// Обновляем текст отображения
|
||
if (filteredProjects.includes('all') || filteredProjects.length === 0) {
|
||
displayText.textContent = 'All Projects';
|
||
} else if (filteredProjects.length === 1) {
|
||
displayText.textContent = filteredProjects[0];
|
||
} else {
|
||
displayText.textContent = `${filteredProjects.length} Projects`;
|
||
}
|
||
|
||
// Сохраняем в localStorage
|
||
localStorage.setItem('lb_selected_projects', JSON.stringify(filteredProjects));
|
||
}
|
||
|
||
// Функция для получения выбранных проектов
|
||
function getSelectedProjects() {
|
||
const dropdown = document.getElementById('projectSelectDropdown');
|
||
if (!dropdown) return ['all'];
|
||
|
||
const selectedProjects = [];
|
||
dropdown.querySelectorAll('.multi-select-option input[type="checkbox"]:checked').forEach(checkbox => {
|
||
const option = checkbox.closest('.multi-select-option');
|
||
const value = option.getAttribute('data-value');
|
||
selectedProjects.push(value);
|
||
});
|
||
|
||
return selectedProjects.length > 0 ? selectedProjects : ['all'];
|
||
}
|
||
|
||
// Функции для работы с исключенными контейнерами
|
||
async function loadExcludedContainers() {
|
||
try {
|
||
const token = localStorage.getItem('access_token');
|
||
if (!token) {
|
||
console.error('No access token found');
|
||
return [];
|
||
}
|
||
|
||
const response = await fetch('/api/containers/excluded', {
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`
|
||
}
|
||
});
|
||
if (!response.ok) {
|
||
if (response.status === 401) {
|
||
console.error('Unauthorized, redirecting to login');
|
||
window.location.href = '/login';
|
||
return [];
|
||
}
|
||
console.error('Ошибка загрузки исключенных контейнеров:', response.status);
|
||
return [];
|
||
}
|
||
const data = await response.json();
|
||
return data.excluded_containers || [];
|
||
} catch (error) {
|
||
console.error('Ошибка загрузки исключенных контейнеров:', error);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
async function saveExcludedContainers(containers) {
|
||
try {
|
||
const token = localStorage.getItem('access_token');
|
||
if (!token) {
|
||
console.error('No access token found');
|
||
return false;
|
||
}
|
||
|
||
const response = await fetch('/api/containers/excluded', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${token}`
|
||
},
|
||
body: JSON.stringify(containers)
|
||
});
|
||
|
||
if (!response.ok) {
|
||
if (response.status === 401) {
|
||
console.error('Unauthorized, redirecting to login');
|
||
window.location.href = '/login';
|
||
return false;
|
||
}
|
||
console.error('Ошибка сохранения исключенных контейнеров:', response.status);
|
||
return false;
|
||
}
|
||
|
||
const data = await response.json();
|
||
console.log('Исключенные контейнеры сохранены:', data);
|
||
return true;
|
||
} catch (error) {
|
||
console.error('Ошибка сохранения исключенных контейнеров:', error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function renderExcludedContainers(containers) {
|
||
const list = document.getElementById('excludedContainersList');
|
||
if (!list) return;
|
||
|
||
list.innerHTML = '';
|
||
|
||
if (containers.length === 0) {
|
||
list.innerHTML = '<div class="excluded-container-item"><span class="excluded-container-name">Нет исключенных контейнеров</span></div>';
|
||
return;
|
||
}
|
||
|
||
containers.forEach(container => {
|
||
const item = document.createElement('div');
|
||
item.className = 'excluded-container-item';
|
||
item.innerHTML = `
|
||
<span class="excluded-container-name">${escapeHtml(container)}</span>
|
||
<button class="remove-excluded-btn" onclick="removeExcludedContainer('${escapeHtml(container)}')">×</button>
|
||
`;
|
||
list.appendChild(item);
|
||
});
|
||
}
|
||
|
||
async function addExcludedContainer() {
|
||
const input = document.getElementById('newExcludedContainer');
|
||
const containerName = input.value.trim();
|
||
|
||
if (!containerName) {
|
||
alert('Введите имя контейнера');
|
||
return;
|
||
}
|
||
|
||
const currentContainers = await loadExcludedContainers();
|
||
if (currentContainers.includes(containerName)) {
|
||
alert('Контейнер уже в списке исключенных');
|
||
return;
|
||
}
|
||
|
||
currentContainers.push(containerName);
|
||
const success = await saveExcludedContainers(currentContainers);
|
||
|
||
if (success) {
|
||
renderExcludedContainers(currentContainers);
|
||
input.value = '';
|
||
// Обновляем список проектов и контейнеров
|
||
await fetchProjects();
|
||
await fetchServices();
|
||
} else {
|
||
alert('Ошибка сохранения');
|
||
}
|
||
}
|
||
|
||
async function removeExcludedContainer(containerName) {
|
||
const currentContainers = await loadExcludedContainers();
|
||
const updatedContainers = currentContainers.filter(name => name !== containerName);
|
||
|
||
const success = await saveExcludedContainers(updatedContainers);
|
||
|
||
if (success) {
|
||
renderExcludedContainers(updatedContainers);
|
||
// Обновляем список проектов и контейнеров
|
||
await fetchProjects();
|
||
await fetchServices();
|
||
} else {
|
||
alert('Ошибка удаления');
|
||
}
|
||
}
|
||
|
||
// Функции для мультивыбора контейнеров
|
||
function toggleContainerSelection(containerId) {
|
||
console.log('toggleContainerSelection called for:', containerId);
|
||
console.log('Current state.selectedContainers before:', [...state.selectedContainers]);
|
||
|
||
const index = state.selectedContainers.indexOf(containerId);
|
||
if (index > -1) {
|
||
state.selectedContainers.splice(index, 1);
|
||
console.log('Removed container from selection:', containerId);
|
||
} else {
|
||
state.selectedContainers.push(containerId);
|
||
console.log('Added container to selection:', containerId);
|
||
}
|
||
|
||
console.log('Current selected containers after:', state.selectedContainers);
|
||
|
||
updateContainerSelectionUI();
|
||
updateMultiViewMode();
|
||
}
|
||
|
||
function updateContainerSelectionUI() {
|
||
console.log('updateContainerSelectionUI called, selected containers:', state.selectedContainers);
|
||
|
||
// Обновляем чекбоксы и обычные карточки контейнеров
|
||
const checkboxes = document.querySelectorAll('.container-checkbox');
|
||
console.log('Found checkboxes:', checkboxes.length);
|
||
|
||
checkboxes.forEach(checkbox => {
|
||
const containerId = checkbox.getAttribute('data-container-id');
|
||
const containerItem = checkbox.closest('.container-item');
|
||
|
||
console.log('Processing checkbox for container:', containerId, 'checked:', checkbox.checked, 'should be:', state.selectedContainers.includes(containerId));
|
||
|
||
if (state.selectedContainers.includes(containerId)) {
|
||
checkbox.checked = true;
|
||
containerItem.classList.add('selected');
|
||
console.log('Container selected:', containerId);
|
||
} else {
|
||
checkbox.checked = false;
|
||
containerItem.classList.remove('selected');
|
||
console.log('Container deselected:', containerId);
|
||
}
|
||
});
|
||
|
||
// Обновляем миникарточки контейнеров
|
||
const miniContainerItems = document.querySelectorAll('.mini-container-item');
|
||
miniContainerItems.forEach(miniItem => {
|
||
const containerId = miniItem.getAttribute('data-cid');
|
||
if (state.selectedContainers.includes(containerId)) {
|
||
miniItem.classList.add('selected');
|
||
} else {
|
||
miniItem.classList.remove('selected');
|
||
}
|
||
});
|
||
|
||
// Обновляем single-view-title если он существует
|
||
const singleViewTitle = document.getElementById('singleViewTitle');
|
||
if (singleViewTitle && state.selectedContainers.length === 1) {
|
||
const service = state.services.find(s => s.id === state.selectedContainers[0]);
|
||
if (service) {
|
||
singleViewTitle.textContent = `${service.name} (${service.service || service.name})`;
|
||
}
|
||
} else if (singleViewTitle && state.selectedContainers.length === 0) {
|
||
singleViewTitle.textContent = 'No container selected';
|
||
} else if (singleViewTitle && state.selectedContainers.length > 1) {
|
||
singleViewTitle.textContent = `Multi-view: ${state.selectedContainers.length} containers`;
|
||
}
|
||
|
||
// Если есть сохраненный контейнер, убеждаемся что его чекбокс отмечен
|
||
const savedContainerId = getSelectedContainerFromStorage();
|
||
if (savedContainerId && state.selectedContainers.includes(savedContainerId)) {
|
||
const checkbox = document.querySelector(`.container-checkbox[data-container-id="${savedContainerId}"]`);
|
||
if (checkbox) {
|
||
checkbox.checked = true;
|
||
const containerItem = checkbox.closest('.container-item');
|
||
if (containerItem) {
|
||
containerItem.classList.add('selected');
|
||
}
|
||
}
|
||
}
|
||
|
||
// Обновляем видимость кнопок LogLevels
|
||
updateLogLevelsVisibility();
|
||
}
|
||
|
||
// Функция для обновления активного состояния контейнеров в UI
|
||
function updateActiveContainerUI(activeContainerId) {
|
||
console.log('updateActiveContainerUI called for:', activeContainerId);
|
||
|
||
// Обновляем обычные карточки контейнеров
|
||
const containerItems = document.querySelectorAll('.container-item');
|
||
containerItems.forEach(item => {
|
||
const containerId = item.getAttribute('data-cid');
|
||
if (activeContainerId && containerId === activeContainerId) {
|
||
item.classList.add('active');
|
||
} else {
|
||
item.classList.remove('active');
|
||
}
|
||
});
|
||
|
||
// Обновляем миникарточки контейнеров
|
||
const miniContainerItems = document.querySelectorAll('.mini-container-item');
|
||
miniContainerItems.forEach(miniItem => {
|
||
const containerId = miniItem.getAttribute('data-cid');
|
||
if (activeContainerId && containerId === activeContainerId) {
|
||
miniItem.classList.add('active');
|
||
} else {
|
||
miniItem.classList.remove('active');
|
||
}
|
||
});
|
||
|
||
// Обновляем legacy tabs
|
||
const tabButtons = document.querySelectorAll('.tab');
|
||
tabButtons.forEach(tab => {
|
||
const tabText = tab.textContent;
|
||
const service = state.services.find(s =>
|
||
(s.project ? `[${s.project}] ` : '') + (s.service || s.name) === tabText
|
||
);
|
||
if (service && activeContainerId && service.id === activeContainerId) {
|
||
tab.classList.add('active');
|
||
} else {
|
||
tab.classList.remove('active');
|
||
}
|
||
});
|
||
}
|
||
|
||
// Функции для всплывающих подсказок миникарточек
|
||
function showMiniContainerTooltip(event, service) {
|
||
// Удаляем существующую подсказку
|
||
hideMiniContainerTooltip();
|
||
|
||
// Очищаем все таймеры скрытия
|
||
const existingTooltips = document.querySelectorAll('.mini-container-tooltip');
|
||
existingTooltips.forEach(tooltip => {
|
||
if (tooltip.hideTimer) {
|
||
clearTimeout(tooltip.hideTimer);
|
||
}
|
||
});
|
||
|
||
// Создаем новую подсказку
|
||
const tooltip = document.createElement('div');
|
||
tooltip.className = 'mini-container-tooltip';
|
||
tooltip.id = 'miniContainerTooltip';
|
||
|
||
const statusClass = service.status === 'running' ? 'running' :
|
||
service.status === 'stopped' ? 'stopped' : 'paused';
|
||
|
||
tooltip.innerHTML = `
|
||
<div class="mini-container-tooltip-header">
|
||
<i class="fas fa-cube mini-container-tooltip-icon"></i>
|
||
<span>Контейнер</span>
|
||
</div>
|
||
<div class="mini-container-tooltip-name">${escapeHtml(service.name)}</div>
|
||
<div class="mini-container-tooltip-service">${escapeHtml(service.service || service.name)} • ${escapeHtml(service.project || 'standalone')}</div>
|
||
<div class="mini-container-tooltip-status">
|
||
<span class="mini-container-tooltip-status-indicator ${statusClass}"></span>
|
||
<span>${escapeHtml(service.status)}</span>
|
||
</div>
|
||
${service.status === 'running' && service.host_port ? `<div class="mini-container-tooltip-port">Порт: ${escapeHtml(service.host_port)}</div>` : ''}
|
||
${service.url ? `<a href="${service.url}" target="_blank" class="mini-container-tooltip-url"><i class="fas fa-external-link-alt"></i> Открыть сайт</a>` : ''}
|
||
|
||
`;
|
||
|
||
// Добавляем подсказку в body
|
||
document.body.appendChild(tooltip);
|
||
|
||
// Позиционируем подсказку сразу
|
||
positionTooltip(event, tooltip);
|
||
|
||
// Показываем подсказку сразу
|
||
tooltip.classList.add('show');
|
||
|
||
// Добавляем обработчики для подсказки
|
||
tooltip.addEventListener('mouseenter', () => {
|
||
// Останавливаем таймер скрытия при наведении на подсказку
|
||
clearTimeout(tooltip.hideTimer);
|
||
});
|
||
|
||
tooltip.addEventListener('mouseleave', () => {
|
||
// Скрываем подсказку при уходе курсора с неё
|
||
hideMiniContainerTooltip();
|
||
});
|
||
|
||
// Добавляем обработчик для клика по ссылке
|
||
const link = tooltip.querySelector('.mini-container-tooltip-url');
|
||
if (link) {
|
||
link.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
// Ссылка откроется в новой вкладке благодаря target="_blank"
|
||
});
|
||
}
|
||
}
|
||
|
||
function hideMiniContainerTooltip() {
|
||
const tooltip = document.getElementById('miniContainerTooltip');
|
||
if (tooltip) {
|
||
// Очищаем таймер скрытия
|
||
if (tooltip.hideTimer) {
|
||
clearTimeout(tooltip.hideTimer);
|
||
}
|
||
tooltip.remove();
|
||
}
|
||
}
|
||
|
||
function positionTooltip(event, tooltip) {
|
||
const rect = event.target.getBoundingClientRect();
|
||
const tooltipRect = tooltip.getBoundingClientRect();
|
||
const viewportWidth = window.innerWidth;
|
||
const viewportHeight = window.innerHeight;
|
||
|
||
// Определяем позицию по умолчанию (справа от миникарточки)
|
||
let position = 'right';
|
||
let left = rect.right + 8;
|
||
|
||
// Всегда центрируем по высоте относительно миникарточки
|
||
let top = rect.top + (rect.height / 2) - (tooltipRect.height / 2);
|
||
|
||
// Проверяем, помещается ли подсказка справа
|
||
if (left + tooltipRect.width > viewportWidth - 10) {
|
||
// Не помещается справа, пробуем слева
|
||
position = 'left';
|
||
left = rect.left - tooltipRect.width - 8;
|
||
}
|
||
|
||
// Проверяем, помещается ли подсказка по вертикали
|
||
if (top < 10) {
|
||
// Не помещается сверху, выравниваем по верху с отступом
|
||
top = 10;
|
||
} else if (top + tooltipRect.height > viewportHeight - 10) {
|
||
// Не помещается снизу, выравниваем по низу с отступом
|
||
top = viewportHeight - tooltipRect.height - 10;
|
||
}
|
||
|
||
// Применяем позицию сразу, без анимации
|
||
tooltip.className = `mini-container-tooltip ${position}`;
|
||
tooltip.style.left = `${left}px`;
|
||
tooltip.style.top = `${top}px`;
|
||
|
||
// Принудительно применяем стили без анимации
|
||
tooltip.style.transition = 'none';
|
||
// Сбрасываем transition после применения позиции
|
||
setTimeout(() => {
|
||
tooltip.style.transition = '';
|
||
}, 10);
|
||
}
|
||
|
||
|
||
|
||
// Функции для help modal
|
||
function showHelpTooltip() {
|
||
const helpTooltip = document.getElementById('helpTooltip');
|
||
|
||
if (!helpTooltip) return;
|
||
|
||
// Показываем модальное окно
|
||
helpTooltip.classList.add('show');
|
||
|
||
// Блокируем скролл страницы
|
||
document.body.style.overflow = 'hidden';
|
||
}
|
||
|
||
function hideHelpTooltip() {
|
||
const helpTooltip = document.getElementById('helpTooltip');
|
||
if (helpTooltip) {
|
||
helpTooltip.classList.remove('show');
|
||
|
||
// Восстанавливаем скролл страницы
|
||
document.body.style.overflow = '';
|
||
}
|
||
}
|
||
|
||
// Глобальные переменные для выбора контейнеров
|
||
let lastSelectedContainerId = null;
|
||
|
||
// Функция для диапазонного выбора контейнеров
|
||
function selectContainerRange(startId, endId) {
|
||
console.log('selectContainerRange:', startId, 'to', endId);
|
||
|
||
const miniContainerItems = document.querySelectorAll('.mini-container-item');
|
||
const containerIds = Array.from(miniContainerItems).map(item => item.getAttribute('data-cid'));
|
||
|
||
const startIndex = containerIds.indexOf(startId);
|
||
const endIndex = containerIds.indexOf(endId);
|
||
|
||
if (startIndex === -1 || endIndex === -1) {
|
||
console.error('Container not found in range selection');
|
||
return;
|
||
}
|
||
|
||
const minIndex = Math.min(startIndex, endIndex);
|
||
const maxIndex = Math.max(startIndex, endIndex);
|
||
|
||
// Выбираем все контейнеры в диапазоне
|
||
for (let i = minIndex; i <= maxIndex; i++) {
|
||
const containerId = containerIds[i];
|
||
if (!state.selectedContainers.includes(containerId)) {
|
||
state.selectedContainers.push(containerId);
|
||
}
|
||
}
|
||
|
||
// Обновляем UI
|
||
updateContainerSelectionUI();
|
||
updateMultiViewMode();
|
||
}
|
||
|
||
// Глобальные обработчики для подсказок
|
||
document.addEventListener('click', (event) => {
|
||
const tooltip = document.getElementById('miniContainerTooltip');
|
||
if (tooltip && !tooltip.contains(event.target) && !event.target.closest('.mini-container-item')) {
|
||
hideMiniContainerTooltip();
|
||
}
|
||
|
||
|
||
});
|
||
|
||
window.addEventListener('resize', () => {
|
||
hideMiniContainerTooltip();
|
||
hideHelpTooltip();
|
||
});
|
||
|
||
window.addEventListener('scroll', () => {
|
||
hideMiniContainerTooltip();
|
||
hideHelpTooltip();
|
||
});
|
||
|
||
// Обработчик для скрытия подсказки при клике на ссылку
|
||
document.addEventListener('click', (event) => {
|
||
if (event.target.closest('.mini-container-tooltip-url')) {
|
||
// Не скрываем подсказку при клике на ссылку
|
||
event.stopPropagation();
|
||
}
|
||
});
|
||
|
||
// Функция для сохранения выбранного контейнера в localStorage
|
||
function saveSelectedContainer(containerId) {
|
||
if (containerId) {
|
||
localStorage.setItem('lb_selected_container', containerId);
|
||
console.log('Saved selected container to localStorage:', containerId);
|
||
} else {
|
||
localStorage.removeItem('lb_selected_container');
|
||
console.log('Removed selected container from localStorage');
|
||
}
|
||
}
|
||
|
||
// Функция для восстановления выбранного контейнера из localStorage
|
||
function getSelectedContainerFromStorage() {
|
||
const containerId = localStorage.getItem('lb_selected_container');
|
||
console.log('Retrieved selected container from localStorage:', containerId);
|
||
return containerId;
|
||
}
|
||
|
||
// Функция для сохранения режима просмотра в localStorage
|
||
function saveViewMode(multiViewMode, selectedContainers) {
|
||
const viewModeData = {
|
||
multiViewMode: multiViewMode,
|
||
selectedContainers: selectedContainers || []
|
||
};
|
||
localStorage.setItem('lb_view_mode', JSON.stringify(viewModeData));
|
||
console.log('Saved view mode to localStorage:', viewModeData);
|
||
}
|
||
|
||
// Функция для восстановления режима просмотра из localStorage
|
||
function getViewModeFromStorage() {
|
||
const viewModeData = localStorage.getItem('lb_view_mode');
|
||
if (viewModeData) {
|
||
try {
|
||
const data = JSON.parse(viewModeData);
|
||
console.log('Retrieved view mode from localStorage:', data);
|
||
return data;
|
||
} catch (error) {
|
||
console.error('Error parsing view mode from localStorage:', error);
|
||
return null;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Функция для сохранения состояния кнопок loglevels в localStorage
|
||
function saveLogLevelsState() {
|
||
const levelsData = {
|
||
globalLevels: state.levels,
|
||
containerLevels: state.containerLevels
|
||
};
|
||
localStorage.setItem('lb_log_levels', JSON.stringify(levelsData));
|
||
console.log('Saved log levels state to localStorage:', levelsData);
|
||
}
|
||
|
||
// Функция для восстановления состояния кнопок loglevels из localStorage
|
||
function getLogLevelsStateFromStorage() {
|
||
const levelsData = localStorage.getItem('lb_log_levels');
|
||
if (levelsData) {
|
||
try {
|
||
const data = JSON.parse(levelsData);
|
||
console.log('Retrieved log levels state from localStorage:', data);
|
||
return data;
|
||
} catch (error) {
|
||
console.error('Error parsing log levels state from localStorage:', error);
|
||
return null;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
async function updateMultiViewMode() {
|
||
console.log(`updateMultiViewMode called: selectedContainers.length = ${state.selectedContainers.length}, containers:`, state.selectedContainers);
|
||
|
||
if (state.selectedContainers.length > 1) {
|
||
state.multiViewMode = true;
|
||
state.current = null; // Сбрасываем текущий контейнер
|
||
console.log('Setting up multi-view mode');
|
||
|
||
// Сохраняем режим просмотра в localStorage
|
||
saveViewMode(true, state.selectedContainers);
|
||
|
||
await setupMultiView();
|
||
} else if (state.selectedContainers.length === 1) {
|
||
// Переключаемся в single view для одного контейнера
|
||
console.log('Switching from multi-view to single view');
|
||
state.multiViewMode = false;
|
||
const selectedService = state.services.find(s => s.id === state.selectedContainers[0]);
|
||
if (selectedService) {
|
||
console.log('Switching to single view for:', selectedService.name);
|
||
console.log('updateMultiViewMode: About to call switchToSingle - VERSION 2');
|
||
|
||
// Сохраняем режим просмотра в localStorage
|
||
saveViewMode(false, [selectedService.id]);
|
||
|
||
// Сохраняем выбранный контейнер в localStorage
|
||
saveSelectedContainer(selectedService.id);
|
||
|
||
// Обновляем страницу для полного сброса состояния
|
||
console.log('Refreshing page to switch to single view');
|
||
window.location.reload();
|
||
return; // Прерываем выполнение, так как страница перезагрузится
|
||
}
|
||
} else {
|
||
// Когда снимаем все галочки, переключаемся в single view
|
||
state.multiViewMode = false;
|
||
state.current = null;
|
||
|
||
// Сохраняем режим просмотра в localStorage
|
||
saveViewMode(false, []);
|
||
|
||
clearLogArea();
|
||
|
||
// Очищаем область логов и показываем пустое состояние
|
||
const logArea = document.querySelector('.log-area');
|
||
if (logArea) {
|
||
const logContent = logArea.querySelector('.log-content');
|
||
if (logContent) {
|
||
logContent.innerHTML = '<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: var(--muted); font-size: 14px;">Выберите контейнер для просмотра логов</div>';
|
||
}
|
||
}
|
||
|
||
// Очищаем активное состояние всех контейнеров
|
||
updateActiveContainerUI(null);
|
||
}
|
||
|
||
console.log(`Multi-view mode updated: multiViewMode = ${state.multiViewMode}`);
|
||
|
||
// Сохраняем состояние кнопок loglevels при переключении режимов
|
||
saveLogLevelsState();
|
||
|
||
// Обновляем состояние кнопок уровней логирования при переключении режимов
|
||
setTimeout(() => {
|
||
initializeLevelButtons();
|
||
}, 100);
|
||
|
||
// Обновляем видимость кнопок LogLevels
|
||
updateLogLevelsVisibility();
|
||
}
|
||
|
||
/**
|
||
* Настраивает интерфейс для режима мультипросмотра (multi-view)
|
||
* Создает сетку панелей для одновременного просмотра нескольких контейнеров
|
||
* Открывает WebSocket соединения для всех выбранных контейнеров
|
||
*/
|
||
async function setupMultiView() {
|
||
console.log('setupMultiView called');
|
||
|
||
// Проверяем, что у нас действительно больше одного контейнера
|
||
if (state.selectedContainers.length <= 1) {
|
||
console.log('setupMultiView: Not enough containers for multi-view, switching to single view');
|
||
if (state.selectedContainers.length === 1) {
|
||
const selectedService = state.services.find(s => s.id === state.selectedContainers[0]);
|
||
if (selectedService) {
|
||
console.log('setupMultiView: Calling switchToSingle for:', selectedService.name);
|
||
await switchToSingle(selectedService);
|
||
}
|
||
} else {
|
||
console.log('setupMultiView: No containers selected, clearing log area');
|
||
clearLogArea();
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Дополнительная проверка - если уже есть мультипросмотр, удаляем его для пересоздания
|
||
const existingMultiView = document.getElementById('multiViewGrid');
|
||
if (existingMultiView) {
|
||
console.log('setupMultiView: Multi-view already exists, removing for recreation');
|
||
existingMultiView.remove();
|
||
}
|
||
|
||
const logArea = document.querySelector('.log-area');
|
||
if (!logArea) {
|
||
console.log('Log area not found');
|
||
return;
|
||
}
|
||
|
||
// Очищаем область логов
|
||
const logContent = logArea.querySelector('.log-content');
|
||
if (logContent) {
|
||
logContent.innerHTML = '';
|
||
}
|
||
|
||
// Удаляем single-view-panel если он существует
|
||
const singleViewPanel = document.getElementById('singleViewPanel');
|
||
if (singleViewPanel) {
|
||
singleViewPanel.remove();
|
||
}
|
||
|
||
// Создаем сетку для мультипросмотра
|
||
const gridContainer = document.createElement('div');
|
||
gridContainer.className = 'multi-view-grid';
|
||
gridContainer.id = 'multiViewGrid';
|
||
|
||
// Определяем количество колонок в зависимости от количества контейнеров
|
||
let columns = 1;
|
||
if (state.selectedContainers.length === 1) columns = 1;
|
||
else if (state.selectedContainers.length === 2) columns = 2;
|
||
else if (state.selectedContainers.length <= 4) columns = 2;
|
||
else if (state.selectedContainers.length <= 6) columns = 3;
|
||
else columns = 4;
|
||
|
||
console.log(`setupMultiView: Creating grid with ${columns} columns for ${state.selectedContainers.length} containers`);
|
||
gridContainer.style.gridTemplateColumns = `repeat(${columns}, 1fr)`;
|
||
console.log(`setupMultiView: Grid template columns set to: repeat(${columns}, 1fr)`);
|
||
|
||
// Создаем панели для каждого выбранного контейнера
|
||
console.log(`setupMultiView: Creating panels for ${state.selectedContainers.length} containers:`, state.selectedContainers);
|
||
state.selectedContainers.forEach((containerId, index) => {
|
||
const service = state.services.find(s => s.id === containerId);
|
||
if (!service) {
|
||
console.error(`setupMultiView: Service not found for container ID: ${containerId}`);
|
||
return;
|
||
}
|
||
|
||
console.log(`setupMultiView: Creating panel ${index + 1} for service: ${service.name} (${containerId})`);
|
||
const panel = createMultiViewPanel(service);
|
||
gridContainer.appendChild(panel);
|
||
console.log(`setupMultiView: Panel ${index + 1} added to grid, total children: ${gridContainer.children.length}`);
|
||
});
|
||
|
||
if (logContent) {
|
||
logContent.appendChild(gridContainer);
|
||
console.log(`setupMultiView: Grid added to log content, grid children: ${gridContainer.children.length}`);
|
||
|
||
// Проверяем, что все панели созданы правильно
|
||
const panels = gridContainer.querySelectorAll('.multi-view-panel');
|
||
console.log(`setupMultiView: Total panels found in grid: ${panels.length}`);
|
||
panels.forEach((panel, index) => {
|
||
const containerId = panel.getAttribute('data-container-id');
|
||
const title = panel.querySelector('.multi-view-title');
|
||
console.log(`setupMultiView: Panel ${index + 1}: containerId=${containerId}, title="${title?.textContent}"`);
|
||
});
|
||
} else {
|
||
console.error('setupMultiView: logContent not found');
|
||
}
|
||
|
||
// Применяем настройки wrap lines
|
||
applyWrapSettings();
|
||
|
||
// Очищаем активное состояние всех контейнеров в мультипросмотре
|
||
updateActiveContainerUI(null);
|
||
|
||
// Принудительно обновляем стили логов для multi-view
|
||
setTimeout(() => {
|
||
updateLogStyles();
|
||
|
||
// Дополнительная проверка для multi-view логов
|
||
console.log('setupMultiView: Force fixing multi-view styles');
|
||
forceFixMultiViewStyles();
|
||
}, 200);
|
||
|
||
// Подключаем WebSocket для каждого контейнера
|
||
console.log(`setupMultiView: Setting up WebSockets for ${state.selectedContainers.length} containers`);
|
||
state.selectedContainers.forEach((containerId, index) => {
|
||
const service = state.services.find(s => s.id === containerId);
|
||
if (service) {
|
||
console.log(`setupMultiView: Setting up WebSocket ${index + 1} for multi-view container: ${service.name} (${containerId})`);
|
||
openMultiViewWs(service);
|
||
} else {
|
||
console.error(`setupMultiView: Service not found for container ID: ${containerId}`);
|
||
}
|
||
});
|
||
|
||
console.log(`setupMultiView: Multi-view setup completed for ${state.selectedContainers.length} containers`);
|
||
|
||
// Обновляем счетчики для multi view
|
||
setTimeout(() => {
|
||
recalculateMultiViewCounters();
|
||
}, 1000); // Небольшая задержка для завершения загрузки логов
|
||
|
||
// Применяем стили логов после настройки multi view
|
||
setTimeout(() => {
|
||
updateLogStyles();
|
||
}, 1500); // Задержка после настройки счетчиков
|
||
|
||
// Обновляем видимость кнопок LogLevels
|
||
updateLogLevelsVisibility();
|
||
}
|
||
|
||
/**
|
||
* Создает панель для мультипросмотра контейнера
|
||
* Генерирует HTML структуру с заголовком, кнопками уровней и областью логов
|
||
* @param {Object} service - Объект сервиса/контейнера
|
||
* @returns {HTMLElement} Созданная панель мультипросмотра
|
||
*/
|
||
function createMultiViewPanel(service) {
|
||
console.log(`Creating multi-view panel for service: ${service.name} (${service.id})`);
|
||
const panel = document.createElement('div');
|
||
panel.className = 'multi-view-panel';
|
||
panel.setAttribute('data-container-id', service.id);
|
||
console.log(`createMultiViewPanel: Panel element created with data-container-id: ${service.id}`);
|
||
|
||
panel.innerHTML = `
|
||
<div class="multi-view-header">
|
||
<h4 class="multi-view-title">${escapeHtml(service.name)}</h4>
|
||
<div class="multi-view-levels">
|
||
<button class="level-btn debug-btn" data-level="debug" data-container-id="${service.id}" title="DEBUG">
|
||
<span class="level-label">DEBUG</span>
|
||
<span class="level-value" data-container="${service.id}">0</span>
|
||
</button>
|
||
<button class="level-btn info-btn" data-level="info" data-container-id="${service.id}" title="INFO">
|
||
<span class="level-label">INFO</span>
|
||
<span class="level-value" data-container="${service.id}">0</span>
|
||
</button>
|
||
<button class="level-btn warn-btn" data-level="warn" data-container-id="${service.id}" title="WARN">
|
||
<span class="level-label">WARN</span>
|
||
<span class="level-value" data-container="${service.id}">0</span>
|
||
</button>
|
||
<button class="level-btn error-btn" data-level="err" data-container-id="${service.id}" title="ERROR">
|
||
<span class="level-label">ERROR</span>
|
||
<span class="level-value" data-container="${service.id}">0</span>
|
||
</button>
|
||
<button class="level-btn other-btn" data-level="other" data-container-id="${service.id}" title="OTHER">
|
||
<span class="level-label">OTHER</span>
|
||
<span class="level-value" data-container="${service.id}">0</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="multi-view-content">
|
||
<div class="multi-view-log" data-container-id="${service.id}"></div>
|
||
</div>
|
||
`;
|
||
|
||
// Проверяем, что элемент создался правильно
|
||
const logElement = panel.querySelector(`.multi-view-log[data-container-id="${service.id}"]`);
|
||
if (logElement) {
|
||
console.log(`Multi-view log element created successfully for ${service.name}`);
|
||
// Очищаем пустые строки после создания панели
|
||
cleanMultiViewEmptyLines(logElement);
|
||
// Очищаем дублированные строки после создания панели
|
||
cleanMultiViewDuplicateLines(logElement);
|
||
} else {
|
||
console.error(`Failed to create multi-view log element for ${service.name}`);
|
||
}
|
||
|
||
// Инициализируем состояние кнопок уровней логирования для этого контейнера
|
||
setTimeout(() => {
|
||
const levelBtns = panel.querySelectorAll('.level-btn');
|
||
levelBtns.forEach(btn => {
|
||
const level = btn.getAttribute('data-level');
|
||
const containerId = btn.getAttribute('data-container-id');
|
||
|
||
// Инициализируем настройки контейнера, если их нет
|
||
if (containerId && (!state.containerLevels || !state.containerLevels[containerId])) {
|
||
if (!state.containerLevels) {
|
||
state.containerLevels = {};
|
||
}
|
||
state.containerLevels[containerId] = {debug: true, info: true, warn: true, err: true, other: true};
|
||
}
|
||
|
||
// Используем настройки контейнера
|
||
const isActive = state.containerLevels && state.containerLevels[containerId] ?
|
||
state.containerLevels[containerId][level] : true;
|
||
|
||
btn.classList.toggle('active', isActive);
|
||
btn.classList.toggle('disabled', !isActive);
|
||
});
|
||
}, 100);
|
||
|
||
console.log(`Multi-view panel created for ${service.name}`);
|
||
|
||
// Применяем стили к новой панели
|
||
setTimeout(() => {
|
||
updateLogStyles();
|
||
}, 200);
|
||
|
||
return panel;
|
||
}
|
||
|
||
/**
|
||
* Открывает WebSocket соединение для контейнера в режиме мультипросмотра
|
||
* Настраивает обработчики сообщений и управляет отображением логов
|
||
* @param {Object} service - Объект сервиса/контейнера
|
||
*/
|
||
function openMultiViewWs(service) {
|
||
const containerId = service.id;
|
||
console.log(`openMultiViewWs: Starting WebSocket setup for ${service.name} (${containerId})`);
|
||
console.log(`openMultiViewWs: Current multiViewMode: ${state.multiViewMode}`);
|
||
console.log(`openMultiViewWs: Selected containers: ${state.selectedContainers.join(', ')}`);
|
||
|
||
// Закрываем существующее соединение
|
||
closeWs(containerId);
|
||
|
||
// Создаем новое WebSocket соединение
|
||
const ws = new WebSocket(wsUrl(containerId, service.service, service.project));
|
||
|
||
ws.onopen = () => {
|
||
console.log(`Multi-view WebSocket connected for ${service.name}`);
|
||
const logEl = document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`);
|
||
if (logEl) {
|
||
// Убираем сообщение "Connected..." для MultiView режима
|
||
logEl.textContent = '';
|
||
// Очищаем пустые строки после установки соединения
|
||
setTimeout(() => {
|
||
cleanMultiViewEmptyLines(logEl);
|
||
cleanMultiViewDuplicateLines(logEl);
|
||
}, 100);
|
||
}
|
||
};
|
||
|
||
ws.onmessage = (event) => {
|
||
console.log(`Multi-view WebSocket received message for ${service.name}: ${event.data.substring(0, 100)}...`);
|
||
|
||
// Устанавливаем состояние 'on' при получении сообщений
|
||
setWsState('on');
|
||
|
||
const parts = (event.data||'').split(/\r?\n/);
|
||
|
||
// Проверяем на дублирование в исходных данных
|
||
if (event.data.includes('FoundINFO:')) {
|
||
console.log('🚨 WebSocket: ОБНАРУЖЕНЫ данные с FoundINFO:');
|
||
console.log('🚨 WebSocket: Полные данные:', event.data);
|
||
}
|
||
|
||
// Проверяем на дублирование строк и убираем дубликаты
|
||
const lines = event.data.split(/\r?\n/).filter(line => line.trim().length > 0);
|
||
const uniqueLines = [...new Set(lines)];
|
||
if (lines.length !== uniqueLines.length) {
|
||
console.log('🚨 WebSocket: ОБНАРУЖЕНО ДУБЛИРОВАНИЕ строк!');
|
||
console.log('🚨 WebSocket: Всего строк:', lines.length);
|
||
console.log('🚨 WebSocket: Уникальных строк:', uniqueLines.length);
|
||
console.log('🚨 WebSocket: Дублированные строки:', lines.filter((line, index) => lines.indexOf(line) !== index));
|
||
|
||
// Используем только уникальные строки
|
||
const uniqueParts = uniqueLines.map(line => line.trim()).filter(line => line.length > 0);
|
||
|
||
for (let i=0;i<uniqueParts.length;i++){
|
||
// Проверяем каждую часть на FoundINFO:
|
||
if (uniqueParts[i].includes('FoundINFO:')) {
|
||
console.log('🚨 WebSocket: Часть с FoundINFO:', uniqueParts[i]);
|
||
}
|
||
|
||
// harvest instance ids if present
|
||
const pr = parsePrefixAndStrip(uniqueParts[i]);
|
||
if (pr){ if (!(pr.id8 in inst.filters)) { inst.filters[pr.id8] = true; updateIdFiltersBar(); } }
|
||
handleLine(containerId, uniqueParts[i]);
|
||
}
|
||
} else {
|
||
// Если дублирования нет, обрабатываем как обычно
|
||
for (let i=0;i<parts.length;i++){
|
||
if (parts[i].length===0 && i===parts.length-1) continue;
|
||
|
||
// Проверяем каждую часть на FoundINFO:
|
||
if (parts[i].includes('FoundINFO:')) {
|
||
console.log('🚨 WebSocket: Часть с FoundINFO:', parts[i]);
|
||
}
|
||
|
||
// harvest instance ids if present
|
||
const pr = parsePrefixAndStrip(parts[i]);
|
||
if (pr){ if (!(pr.id8 in inst.filters)) { inst.filters[pr.id8] = true; updateIdFiltersBar(); } }
|
||
handleLine(containerId, parts[i]);
|
||
}
|
||
}
|
||
};
|
||
|
||
ws.onclose = () => {
|
||
console.log(`Multi-view WebSocket closed for ${service.name}`);
|
||
};
|
||
|
||
ws.onerror = (error) => {
|
||
console.error(`Multi-view WebSocket error for ${service.name}:`, error);
|
||
};
|
||
|
||
// Сохраняем соединение с полным набором полей как в openWs
|
||
const logEl = document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`);
|
||
console.log(`openMultiViewWs: Found log element for ${service.name}:`, !!logEl);
|
||
|
||
state.open[containerId] = {
|
||
ws: ws,
|
||
serviceName: service.service,
|
||
logEl: logEl,
|
||
wrapEl: logEl,
|
||
counters: {dbg:0, info:0, warn:0, err:0, other:0},
|
||
pausedBuffer: [],
|
||
allLogs: [] // Добавляем буфер для логов
|
||
};
|
||
|
||
console.log(`openMultiViewWs: WebSocket setup completed for ${service.name} (${containerId})`);
|
||
}
|
||
|
||
function clearLogArea() {
|
||
console.log('clearLogArea called');
|
||
|
||
// Очищаем мультипросмотр если он был активен
|
||
if (state.multiViewMode) {
|
||
console.log('Clearing multi-view grid');
|
||
const multiViewGrid = document.getElementById('multiViewGrid');
|
||
if (multiViewGrid) {
|
||
multiViewGrid.remove();
|
||
}
|
||
}
|
||
|
||
const logContent = document.querySelector('.log-content');
|
||
if (logContent) {
|
||
logContent.innerHTML = `
|
||
<div class="single-view-panel" id="singleViewPanel">
|
||
<div class="single-view-header">
|
||
<h4 class="single-view-title" id="singleViewTitle">No container selected</h4>
|
||
</div>
|
||
<div class="single-view-content">
|
||
<pre class="log" id="logContent">No container selected</pre>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
const logTitle = document.getElementById('logTitle');
|
||
if (logTitle) {
|
||
logTitle.textContent = 'LogBoard+';
|
||
}
|
||
|
||
// Обновляем видимость кнопок LogLevels
|
||
updateLogLevelsVisibility();
|
||
}
|
||
|
||
|
||
|
||
function applyWrapSettings() {
|
||
const wrapEnabled = els.wrapToggle && els.wrapToggle.checked;
|
||
const wrapStyle = wrapEnabled ? 'pre-wrap' : 'pre';
|
||
|
||
// Применяем к обычному просмотру (только к логам в основном контенте)
|
||
document.querySelectorAll('.main-content .log').forEach(el => {
|
||
el.style.whiteSpace = wrapStyle;
|
||
});
|
||
|
||
// Применяем к мультипросмотру
|
||
document.querySelectorAll('.multi-view-content .multi-view-log').forEach(el => {
|
||
el.style.whiteSpace = wrapStyle;
|
||
});
|
||
|
||
// Применяем к single-view
|
||
document.querySelectorAll('.single-view-content .log').forEach(el => {
|
||
el.style.whiteSpace = wrapStyle;
|
||
});
|
||
}
|
||
|
||
async function fetchServices(){
|
||
try {
|
||
console.log('Fetching services...');
|
||
const url = new URL(location.origin + '/api/containers/services');
|
||
const selectedProjects = getSelectedProjects();
|
||
|
||
// Если выбраны конкретные проекты (не "all"), добавляем их в URL как строку через запятую
|
||
if (selectedProjects.length > 0 && !selectedProjects.includes('all')) {
|
||
url.searchParams.set('projects', selectedProjects.join(','));
|
||
}
|
||
|
||
const token = localStorage.getItem('access_token');
|
||
if (!token) {
|
||
console.error('No access token found');
|
||
window.location.href = '/login';
|
||
return;
|
||
}
|
||
|
||
const res = await fetch(url, {
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`
|
||
}
|
||
});
|
||
if (!res.ok){
|
||
if (res.status === 401) {
|
||
console.error('Unauthorized, redirecting to login');
|
||
window.location.href = '/login';
|
||
return;
|
||
}
|
||
console.error('Auth failed (HTTP):', res.status, res.statusText);
|
||
alert('Auth failed (HTTP)');
|
||
return;
|
||
}
|
||
const data = await res.json();
|
||
console.log('Services loaded:', data);
|
||
state.services = data;
|
||
|
||
buildTabs();
|
||
|
||
// Восстанавливаем режим просмотра из localStorage
|
||
const savedViewMode = getViewModeFromStorage();
|
||
if (savedViewMode) {
|
||
console.log('Restoring view mode from localStorage:', savedViewMode);
|
||
|
||
if (savedViewMode.multiViewMode && savedViewMode.selectedContainers.length > 1) {
|
||
// Восстанавливаем Multi View режим
|
||
console.log('Restoring Multi View mode with containers:', savedViewMode.selectedContainers);
|
||
state.multiViewMode = true;
|
||
state.selectedContainers = savedViewMode.selectedContainers;
|
||
|
||
// Отмечаем чекбоксы для выбранных контейнеров
|
||
savedViewMode.selectedContainers.forEach(containerId => {
|
||
const checkbox = document.querySelector(`.container-checkbox[data-container-id="${containerId}"]`);
|
||
if (checkbox) {
|
||
checkbox.checked = true;
|
||
const containerItem = checkbox.closest('.container-item');
|
||
if (containerItem) {
|
||
containerItem.classList.add('selected');
|
||
}
|
||
}
|
||
});
|
||
|
||
// Настраиваем Multi View
|
||
await setupMultiView();
|
||
} else if (savedViewMode.selectedContainers.length === 1) {
|
||
// Восстанавливаем Single View режим
|
||
console.log('Restoring Single View mode for container:', savedViewMode.selectedContainers[0]);
|
||
state.multiViewMode = false;
|
||
const selectedService = state.services.find(s => s.id === savedViewMode.selectedContainers[0]);
|
||
if (selectedService) {
|
||
await switchToSingle(selectedService);
|
||
}
|
||
} else {
|
||
// Нет сохраненного режима, не открываем автоматически первый контейнер
|
||
console.log('No saved view mode, not auto-opening first container');
|
||
// Пользователь сам выберет нужный контейнер
|
||
}
|
||
} else {
|
||
// Нет сохраненного режима, не открываем автоматически первый контейнер
|
||
console.log('No saved view mode found, not auto-opening first container');
|
||
// Пользователь сам выберет нужный контейнер
|
||
}
|
||
|
||
// Добавляем обработчики для счетчиков после загрузки сервисов
|
||
addCounterClickHandlers();
|
||
} catch (error) {
|
||
console.error('Error fetching services:', error);
|
||
}
|
||
}
|
||
|
||
function wsUrl(containerId, service, project){
|
||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||
const tail = els.tail.value || '500';
|
||
const token = encodeURIComponent(localStorage.getItem('access_token') || '');
|
||
const sp = service?`&service=${encodeURIComponent(service)}`:'';
|
||
const pj = project?`&project=${encodeURIComponent(project)}`:'';
|
||
if (els.aggregate && els.aggregate.checked && service){
|
||
// fan-in by service
|
||
return `${proto}://${location.host}/api/websocket/fan/${encodeURIComponent(service)}?tail=${tail}&token=${token}${pj}`;
|
||
}
|
||
return `${proto}://${location.host}/api/websocket/logs/${encodeURIComponent(containerId)}?tail=${tail}&token=${token}${sp}${pj}`;
|
||
}
|
||
|
||
/**
|
||
* Закрывает WebSocket соединение для контейнера
|
||
* @param {string} id - ID контейнера
|
||
*/
|
||
function closeWs(id){
|
||
const o = state.open[id];
|
||
if (!o) return;
|
||
|
||
try { o.ws.close(); } catch(e){}
|
||
delete state.open[id];
|
||
}
|
||
|
||
/**
|
||
* Создает и скачивает снимок логов контейнера
|
||
* В режиме мультипросмотра создает отдельные файлы для каждого контейнера
|
||
* @param {string} id - ID контейнера
|
||
*/
|
||
async function sendSnapshot(id){
|
||
const o = state.open[id];
|
||
if (!o){ alert('not open'); return; }
|
||
|
||
const token = localStorage.getItem('access_token');
|
||
if (!token) {
|
||
console.error('No access token found');
|
||
window.location.href = '/login';
|
||
return;
|
||
}
|
||
|
||
if (state.multiViewMode && state.selectedContainers.length > 0) {
|
||
// В Multi View режиме создаем отдельный файл для каждого контейнера
|
||
console.log('Creating snapshots for Multi View mode with containers:', state.selectedContainers);
|
||
|
||
let hasLogs = false;
|
||
|
||
// Создаем отдельный файл для каждого выбранного контейнера
|
||
for (const containerId of state.selectedContainers) {
|
||
const multiViewLog = document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`);
|
||
if (multiViewLog && multiViewLog.textContent.trim()) {
|
||
const service = state.services.find(s => s.id === containerId);
|
||
const serviceName = service ? (service.service || service.name) : containerId;
|
||
const text = multiViewLog.textContent;
|
||
|
||
console.log(`Saving snapshot for ${serviceName} with content length:`, text.length);
|
||
|
||
const payload = {container_id: containerId, service: serviceName, content: text};
|
||
|
||
try {
|
||
const res = await fetch('/api/logs/snapshot', {
|
||
method:'POST',
|
||
headers:{
|
||
'Content-Type':'application/json',
|
||
'Authorization': `Bearer ${token}`
|
||
},
|
||
body: JSON.stringify(payload)
|
||
});
|
||
|
||
if (!res.ok){
|
||
if (res.status === 401) {
|
||
console.error('Unauthorized, redirecting to login');
|
||
window.location.href = '/login';
|
||
return;
|
||
}
|
||
console.error(`Snapshot failed for ${serviceName}:`, res.status, res.statusText);
|
||
alert(`snapshot failed for ${serviceName}`);
|
||
return;
|
||
}
|
||
|
||
const js = await res.json();
|
||
const a = document.createElement('a');
|
||
a.href = js.url; a.download = js.file; a.click();
|
||
|
||
hasLogs = true;
|
||
|
||
// Небольшая задержка между скачиваниями файлов
|
||
await new Promise(resolve => setTimeout(resolve, 100));
|
||
|
||
} catch (error) {
|
||
console.error(`Error saving snapshot for ${serviceName}:`, error);
|
||
alert(`Error saving snapshot for ${serviceName}`);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!hasLogs) {
|
||
alert('No logs to save in Multi View mode');
|
||
return;
|
||
}
|
||
|
||
} else {
|
||
// Обычный режим просмотра
|
||
let text = '';
|
||
if (state.current && state.current.id === id && els.logContent) {
|
||
text = els.logContent.textContent;
|
||
} else if (o.logEl) {
|
||
text = o.logEl.textContent;
|
||
}
|
||
|
||
if (!text || text.trim() === '') {
|
||
alert('No logs to save');
|
||
return;
|
||
}
|
||
|
||
console.log('Saving snapshot with content length:', text.length);
|
||
|
||
const serviceName = o.serviceName || id;
|
||
const payload = {container_id: id, service: serviceName, content: text};
|
||
|
||
const res = await fetch('/api/logs/snapshot', {
|
||
method:'POST',
|
||
headers:{
|
||
'Content-Type':'application/json',
|
||
'Authorization': `Bearer ${token}`
|
||
},
|
||
body: JSON.stringify(payload)
|
||
});
|
||
if (!res.ok){
|
||
if (res.status === 401) {
|
||
console.error('Unauthorized, redirecting to login');
|
||
window.location.href = '/login';
|
||
return;
|
||
}
|
||
console.error('Snapshot failed:', res.status, res.statusText);
|
||
alert('snapshot failed');
|
||
return;
|
||
}
|
||
const js = await res.json();
|
||
const a = document.createElement('a');
|
||
a.href = js.url; a.download = js.file; a.click();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Открывает WebSocket соединение для контейнера
|
||
* Настраивает обработчики событий и управляет отображением логов
|
||
* @param {Object} svc - Объект сервиса/контейнера
|
||
* @param {HTMLElement} panel - Панель для отображения логов
|
||
*/
|
||
function openWs(svc, panel){
|
||
const id = svc.id;
|
||
console.log(`openWs: Called for ${svc.name} (${id}) in multiViewMode: ${state.multiViewMode}`);
|
||
console.log(`openWs: Selected containers: ${state.selectedContainers.join(', ')}`);
|
||
|
||
const logEl = panel.querySelector('.log');
|
||
const wrapEl = panel.querySelector('.logwrap');
|
||
|
||
// Ищем счетчики в panel или в глобальных элементах
|
||
const cdbg = panel.querySelector('.cdbg') || document.querySelector('.cdbg');
|
||
const cinfo = panel.querySelector('.cinfo') || document.querySelector('.cinfo');
|
||
const cwarn = panel.querySelector('.cwarn') || document.querySelector('.cwarn');
|
||
const cerr = panel.querySelector('.cerr') || document.querySelector('.cerr');
|
||
const cother = panel.querySelector('.cother') || document.querySelector('.cother');
|
||
const counters = {dbg:0,info:0,warn:0,err:0,other:0};
|
||
|
||
const ws = new WebSocket(wsUrl(id, (svc.service||svc.name), svc.project||''));
|
||
state.open[id] = {ws, logEl, wrapEl, counters, pausedBuffer:[], serviceName: (svc.service||svc.name)};
|
||
console.log(`openWs: Created state.open[${id}] with logEl:`, !!logEl, 'wrapEl:', !!wrapEl);
|
||
|
||
ws.onopen = ()=> {
|
||
console.log(`WebSocket ${id}: Соединение открыто`);
|
||
setWsState('on');
|
||
// Очищаем сообщение "Connecting..." когда соединение установлено
|
||
if (state.current && state.current.id === id && els.logContent) {
|
||
els.logContent.innerHTML = '';
|
||
}
|
||
// Также очищаем legacy элемент лога
|
||
if (obj.logEl) {
|
||
obj.logEl.innerHTML = '';
|
||
}
|
||
|
||
// Принудительно проверяем состояние через AJAX через 500мс и 1 секунду
|
||
setTimeout(() => {
|
||
console.log(`WebSocket ${id}: Принудительная проверка состояния после открытия (500мс)`);
|
||
checkWebSocketStatus();
|
||
}, 500);
|
||
|
||
setTimeout(() => {
|
||
console.log(`WebSocket ${id}: Принудительная проверка состояния после открытия (1с)`);
|
||
checkWebSocketStatus();
|
||
}, 1000);
|
||
};
|
||
ws.onclose = ()=> {
|
||
console.log(`WebSocket ${id}: Соединение закрыто`);
|
||
setWsState(determineWsState());
|
||
|
||
// Принудительно проверяем состояние через AJAX через 500мс
|
||
setTimeout(() => {
|
||
console.log(`WebSocket ${id}: Принудительная проверка состояния после закрытия`);
|
||
checkWebSocketStatus();
|
||
}, 500);
|
||
};
|
||
ws.onerror = (error)=> {
|
||
console.log(`WebSocket ${id}: Ошибка соединения:`, error);
|
||
setWsState('err');
|
||
};
|
||
ws.onmessage = (ev)=>{
|
||
console.log(`Received WebSocket message: ${ev.data.substring(0, 100)}...`);
|
||
|
||
// Устанавливаем состояние 'on' при получении сообщений
|
||
setWsState('on');
|
||
|
||
const parts = (ev.data||'').split(/\r?\n/);
|
||
console.log(`openWs: Processing ${parts.length} lines for container ${id}`);
|
||
|
||
// Проверяем на дублирование в исходных данных для Single View
|
||
if (ev.data.includes('FoundINFO:')) {
|
||
console.log('🚨 Single View WebSocket: ОБНАРУЖЕНЫ данные с FoundINFO:');
|
||
console.log('🚨 Single View WebSocket: Полные данные:', ev.data);
|
||
}
|
||
|
||
// Проверяем на дублирование строк и убираем дубликаты
|
||
const lines = ev.data.split(/\r?\n/).filter(line => line.trim().length > 0);
|
||
const uniqueLines = [...new Set(lines)];
|
||
if (lines.length !== uniqueLines.length) {
|
||
console.log('🚨 Single View WebSocket: ОБНАРУЖЕНО ДУБЛИРОВАНИЕ строк!');
|
||
console.log('🚨 Single View WebSocket: Всего строк:', lines.length);
|
||
console.log('🚨 Single View WebSocket: Уникальных строк:', uniqueLines.length);
|
||
console.log('🚨 Single View WebSocket: Дублированные строки:', lines.filter((line, index) => lines.indexOf(line) !== index));
|
||
|
||
// Используем только уникальные строки
|
||
const uniqueParts = uniqueLines.map(line => line.trim()).filter(line => line.length > 0);
|
||
|
||
for (let i=0;i<uniqueParts.length;i++){
|
||
// Проверяем каждую часть на FoundINFO:
|
||
if (uniqueParts[i].includes('FoundINFO:')) {
|
||
console.log('🚨 Single View WebSocket: Часть с FoundINFO:', uniqueParts[i]);
|
||
}
|
||
|
||
// harvest instance ids if present
|
||
const pr = parsePrefixAndStrip(uniqueParts[i]);
|
||
if (pr){ if (!(pr.id8 in inst.filters)) { inst.filters[pr.id8] = true; updateIdFiltersBar(); } }
|
||
console.log(`openWs: Calling handleLine for container ${id}, line: "${uniqueParts[i].substring(0, 50)}..."`);
|
||
handleLine(id, uniqueParts[i]);
|
||
}
|
||
} else {
|
||
// Если дублирования нет, обрабатываем как обычно
|
||
for (let i=0;i<parts.length;i++){
|
||
if (parts[i].length===0 && i===parts.length-1) continue;
|
||
|
||
// Проверяем каждую часть на FoundINFO:
|
||
if (parts[i].includes('FoundINFO:')) {
|
||
console.log('🚨 Single View WebSocket: Часть с FoundINFO:', parts[i]);
|
||
}
|
||
|
||
// harvest instance ids if present
|
||
const pr = parsePrefixAndStrip(parts[i]);
|
||
if (pr){ if (!(pr.id8 in inst.filters)) { inst.filters[pr.id8] = true; updateIdFiltersBar(); } }
|
||
console.log(`openWs: Calling handleLine for container ${id}, line: "${parts[i].substring(0, 50)}..."`);
|
||
handleLine(id, parts[i]);
|
||
}
|
||
}
|
||
|
||
// Обновляем счетчики после обработки всех строк
|
||
cdbg.textContent = counters.dbg;
|
||
cinfo.textContent = counters.info;
|
||
cwarn.textContent = counters.warn;
|
||
cerr.textContent = counters.err;
|
||
if (cother) cother.textContent = counters.other;
|
||
};
|
||
|
||
// Убираем автоматический refresh - теперь только по кнопке
|
||
}
|
||
|
||
/**
|
||
* Функция для обработки переноса строк в multi view
|
||
* Если символов больше 5, то перенос строк работает
|
||
* Если меньше 5, то переноса строк нет
|
||
* @param {string} text - исходный текст
|
||
* @returns {string} - обработанный текст с правильными переносами
|
||
*/
|
||
function processMultiViewLineBreaks(text) {
|
||
// Если символов меньше или равно 5, возвращаем без переносов
|
||
if (text.length <= 5) {
|
||
return text;
|
||
}
|
||
|
||
// Если символов больше 5, добавляем перенос строки в конце
|
||
return text + '\n';
|
||
}
|
||
|
||
/**
|
||
* Функция для радикальной очистки пустых строк в multi view
|
||
* Удаляет все пустые строки и лишние переносы строк
|
||
* @param {HTMLElement} multiViewLog - элемент лога multi view
|
||
*/
|
||
function cleanMultiViewEmptyLines(multiViewLog) {
|
||
if (!multiViewLog) return;
|
||
|
||
let removedCount = 0;
|
||
|
||
// Удаляем все пустые строки (элементы .line без текста)
|
||
const lines = Array.from(multiViewLog.querySelectorAll('.line'));
|
||
lines.forEach(line => {
|
||
const textContent = line.textContent || line.innerText || '';
|
||
if (textContent.trim() === '') {
|
||
line.remove();
|
||
removedCount++;
|
||
}
|
||
});
|
||
|
||
// Удаляем все текстовые узлы, которые содержат только пробелы и переносы строк
|
||
const walker = document.createTreeWalker(
|
||
multiViewLog,
|
||
NodeFilter.SHOW_TEXT,
|
||
null,
|
||
false
|
||
);
|
||
|
||
const textNodesToRemove = [];
|
||
let node;
|
||
while (node = walker.nextNode()) {
|
||
const content = node.textContent;
|
||
// Удаляем все узлы, которые содержат только пробелы, переносы строк или табуляцию
|
||
if (content.trim() === '') {
|
||
textNodesToRemove.push(node);
|
||
}
|
||
}
|
||
|
||
textNodesToRemove.forEach(node => node.remove());
|
||
|
||
// Удаляем все пустые текстовые узлы между элементами .line
|
||
const allNodes = Array.from(multiViewLog.childNodes);
|
||
for (let i = allNodes.length - 1; i >= 0; i--) {
|
||
const node = allNodes[i];
|
||
if (node.nodeType === Node.TEXT_NODE && node.textContent.trim() === '') {
|
||
node.remove();
|
||
}
|
||
}
|
||
|
||
if (removedCount > 0) {
|
||
console.log(`cleanMultiViewEmptyLines: Удалено ${removedCount} пустых строк`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Функция для очистки дублированных строк в multi view
|
||
* Удаляет последовательные дублированные строки
|
||
* @param {HTMLElement} multiViewLog - элемент лога multi view
|
||
*/
|
||
function cleanMultiViewDuplicateLines(multiViewLog) {
|
||
if (!multiViewLog) return;
|
||
|
||
const lines = Array.from(multiViewLog.querySelectorAll('.line'));
|
||
let removedCount = 0;
|
||
|
||
// Проходим по строкам с конца, чтобы не нарушить индексы
|
||
for (let i = lines.length - 1; i > 0; i--) {
|
||
const currentLine = lines[i];
|
||
const previousLine = lines[i - 1];
|
||
|
||
if (currentLine && previousLine) {
|
||
const currentText = currentLine.textContent || currentLine.innerText || '';
|
||
const previousText = previousLine.textContent || previousLine.innerText || '';
|
||
|
||
// Удаляем дублированные строки (включая пустые)
|
||
if (currentText.trim() === previousText.trim()) {
|
||
console.log(`cleanMultiViewDuplicateLines: Удаляем дублированную строку: ${currentText.substring(0, 50)}...`);
|
||
currentLine.remove();
|
||
removedCount++;
|
||
}
|
||
}
|
||
}
|
||
|
||
// После удаления дубликатов очищаем лишние пустые строки
|
||
cleanMultiViewEmptyLines(multiViewLog);
|
||
|
||
if (removedCount > 0) {
|
||
console.log(`cleanMultiViewDuplicateLines: Удалено ${removedCount} дублированных строк`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Универсальная функция для очистки дублированных строк
|
||
* Работает как для Single View, так и для MultiView
|
||
* @param {HTMLElement} logElement - элемент лога (любого типа)
|
||
*/
|
||
function cleanDuplicateLines(logElement) {
|
||
if (!logElement) return;
|
||
|
||
const lines = Array.from(logElement.querySelectorAll('.line'));
|
||
let removedCount = 0;
|
||
|
||
// Проходим по строкам с конца, чтобы не нарушить индексы
|
||
for (let i = lines.length - 1; i > 0; i--) {
|
||
const currentLine = lines[i];
|
||
const previousLine = lines[i - 1];
|
||
|
||
if (currentLine && previousLine) {
|
||
const currentText = currentLine.textContent || currentLine.innerText || '';
|
||
const previousText = previousLine.textContent || previousLine.innerText || '';
|
||
|
||
// Удаляем дублированные строки (включая пустые)
|
||
if (currentText.trim() === previousText.trim()) {
|
||
console.log(`cleanDuplicateLines: Удаляем дублированную строку: ${currentText.substring(0, 50)}...`);
|
||
currentLine.remove();
|
||
removedCount++;
|
||
}
|
||
}
|
||
}
|
||
|
||
// После удаления дубликатов очищаем лишние пустые строки
|
||
if (logElement.classList.contains('multi-view-log')) {
|
||
cleanMultiViewEmptyLines(logElement);
|
||
} else {
|
||
cleanSingleViewEmptyLines(logElement);
|
||
}
|
||
|
||
if (removedCount > 0) {
|
||
console.log(`cleanDuplicateLines: Удалено ${removedCount} дублированных строк`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Функция для радикальной очистки пустых строк в Single View
|
||
* Удаляет все пустые строки и лишние переносы строк
|
||
* @param {HTMLElement} logElement - элемент лога Single View
|
||
*/
|
||
function cleanSingleViewEmptyLines(logElement) {
|
||
if (!logElement) return;
|
||
|
||
let removedCount = 0;
|
||
|
||
// Удаляем все пустые строки (элементы .line без текста)
|
||
const lines = Array.from(logElement.querySelectorAll('.line'));
|
||
lines.forEach(line => {
|
||
const textContent = line.textContent || line.innerText || '';
|
||
if (textContent.trim() === '') {
|
||
line.remove();
|
||
removedCount++;
|
||
}
|
||
});
|
||
|
||
// Удаляем все текстовые узлы, которые содержат только пробелы и переносы строк
|
||
const walker = document.createTreeWalker(
|
||
logElement,
|
||
NodeFilter.SHOW_TEXT,
|
||
null,
|
||
false
|
||
);
|
||
|
||
const textNodesToRemove = [];
|
||
let node;
|
||
while (node = walker.nextNode()) {
|
||
const content = node.textContent;
|
||
// Удаляем все узлы, которые содержат только пробелы, переносы строк или табуляцию
|
||
if (content.trim() === '') {
|
||
textNodesToRemove.push(node);
|
||
}
|
||
}
|
||
|
||
textNodesToRemove.forEach(node => node.remove());
|
||
|
||
// Удаляем все пустые текстовые узлы между элементами .line
|
||
const allNodes = Array.from(logElement.childNodes);
|
||
for (let i = allNodes.length - 1; i >= 0; i--) {
|
||
const node = allNodes[i];
|
||
if (node.nodeType === Node.TEXT_NODE && node.textContent.trim() === '') {
|
||
node.remove();
|
||
}
|
||
}
|
||
|
||
if (removedCount > 0) {
|
||
console.log(`cleanSingleViewEmptyLines: Удалено ${removedCount} пустых строк`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Функция для нормализации пробелов в логах
|
||
* Заменяет множественные пробелы на один пробел
|
||
* @param {string} text - исходный текст
|
||
* @returns {string} - текст с нормализованными пробелами
|
||
*/
|
||
function normalizeSpaces(text) {
|
||
if (!text) return text;
|
||
|
||
// Заменяем множественные пробелы на один пробел
|
||
// Используем регулярное выражение для замены 2+ пробелов на один
|
||
return text.replace(/\s{2,}/g, ' ');
|
||
}
|
||
|
||
/**
|
||
* Функция для периодической очистки пустых строк
|
||
* Вызывается автоматически каждые 2 секунды для поддержания чистоты логов
|
||
*/
|
||
function periodicCleanup() {
|
||
// Очищаем пустые строки в Single View
|
||
if (!state.multiViewMode && els.logContent) {
|
||
cleanSingleViewEmptyLines(els.logContent);
|
||
cleanDuplicateLines(els.logContent);
|
||
}
|
||
|
||
// Очищаем пустые строки в мультипросмотре
|
||
if (state.multiViewMode) {
|
||
state.selectedContainers.forEach(containerId => {
|
||
const multiViewLog = document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`);
|
||
if (multiViewLog) {
|
||
cleanMultiViewEmptyLines(multiViewLog);
|
||
cleanMultiViewDuplicateLines(multiViewLog);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Очищаем пустые строки в legacy панелях
|
||
Object.values(state.open).forEach(obj => {
|
||
if (obj.logEl) {
|
||
cleanSingleViewEmptyLines(obj.logEl);
|
||
cleanDuplicateLines(obj.logEl);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Запускаем периодическую очистку каждые 2 секунды
|
||
setInterval(periodicCleanup, 2000);
|
||
|
||
// Запускаем периодическую проверку стилей multi-view логов каждые 5 секунд
|
||
setInterval(() => {
|
||
if (state.multiViewMode) {
|
||
const multiViewLogs = document.querySelectorAll('.multi-view-content .multi-view-log');
|
||
if (multiViewLogs.length > 0) {
|
||
console.log('Periodic check: Force fixing multi-view styles');
|
||
forceFixMultiViewStyles();
|
||
|
||
// Дополнительно исправляем все контейнеры
|
||
console.log('Periodic check: Fixing all containers');
|
||
if (window.fixAllContainers) {
|
||
window.fixAllContainers();
|
||
}
|
||
}
|
||
}
|
||
}, 5000);
|
||
|
||
/**
|
||
* Функция для обработки специальных замен в MultiView логах
|
||
* Выполняет специфичные замены для улучшения читаемости логов
|
||
* @param {string} text - исходный текст
|
||
* @returns {string} - текст с примененными заменами
|
||
*/
|
||
function processMultiViewSpecialReplacements(text) {
|
||
if (!text) return text;
|
||
|
||
let processedText = text;
|
||
|
||
// Добавляем отладочную информацию для проверки
|
||
if (text.includes('FoundINFO:')) {
|
||
console.log('🔍 processMultiViewSpecialReplacements: Найдена строка с FoundINFO:', text);
|
||
}
|
||
|
||
// Проверяем на дублирование строк в исходном тексте
|
||
const lines = processedText.split('\n');
|
||
const uniqueLines = [...new Set(lines)];
|
||
if (lines.length !== uniqueLines.length) {
|
||
console.log('🚨 processMultiViewSpecialReplacements: Обнаружено дублирование в исходном тексте!');
|
||
console.log('🚨 Исходные строки:', lines.length, 'Уникальные строки:', uniqueLines.length);
|
||
// Убираем дублированные строки
|
||
processedText = uniqueLines.join('\n');
|
||
}
|
||
|
||
// Заменяем случаи, где INFO: прилипает к предыдущему тексту
|
||
// Ищем паттерн: любой текст + INFO: (но не в начале строки)
|
||
// Используем более точное регулярное выражение для поиска
|
||
processedText = processedText.replace(/([A-Za-z0-9\s]+)INFO:/g, '$1\nINFO:');
|
||
|
||
// Убираем лишние переносы строк в начале, если они есть
|
||
processedText = processedText.replace(/^\n+/, '');
|
||
|
||
// Проверяем результат
|
||
if (text.includes('FoundINFO:') && processedText !== text) {
|
||
console.log('✅ processMultiViewSpecialReplacements: Замена выполнена:', processedText);
|
||
} else if (text.includes('FoundINFO:') && processedText === text) {
|
||
console.log('❌ processMultiViewSpecialReplacements: Замена НЕ выполнена для:', text);
|
||
}
|
||
|
||
return processedText;
|
||
}
|
||
|
||
/**
|
||
* Функция для обработки специальных замен в Single View логах
|
||
* Не добавляет лишние переносы строк
|
||
* @param {string} text - исходный текст
|
||
* @returns {string} - текст с примененными заменами
|
||
*/
|
||
function processSingleViewSpecialReplacements(text) {
|
||
if (!text) return text;
|
||
|
||
let processedText = text;
|
||
|
||
// Добавляем отладочную информацию для проверки
|
||
if (text.includes('FoundINFO:')) {
|
||
console.log('🔍 processSingleViewSpecialReplacements: Найдена строка с FoundINFO:', text);
|
||
}
|
||
|
||
// Проверяем на дублирование строк в исходном тексте
|
||
const lines = processedText.split('\n');
|
||
const uniqueLines = [...new Set(lines)];
|
||
if (lines.length !== uniqueLines.length) {
|
||
console.log('🚨 processSingleViewSpecialReplacements: Обнаружено дублирование в исходном тексте!');
|
||
console.log('🚨 Исходные строки:', lines.length, 'Уникальные строки:', uniqueLines.length);
|
||
// Убираем дублированные строки
|
||
processedText = uniqueLines.join('\n');
|
||
}
|
||
|
||
// Для Single View НЕ добавляем переносы строк, только убираем дубликаты
|
||
// Убираем лишние переносы строк в начале, если они есть
|
||
processedText = processedText.replace(/^\n+/, '');
|
||
|
||
// Проверяем результат
|
||
if (text.includes('FoundINFO:') && processedText !== text) {
|
||
console.log('✅ processSingleViewSpecialReplacements: Замена выполнена:', processedText);
|
||
} else if (text.includes('FoundINFO:') && processedText === text) {
|
||
console.log('❌ processSingleViewSpecialReplacements: Замена НЕ выполнена для:', text);
|
||
}
|
||
|
||
return processedText;
|
||
}
|
||
|
||
// Тестовая функция для проверки работы processMultiViewLineBreaks
|
||
function testMultiViewLineBreaks() {
|
||
console.log('=== Тест функции processMultiViewLineBreaks ===');
|
||
console.log('Тест 1 (1 символ):', JSON.stringify(processMultiViewLineBreaks('a')));
|
||
console.log('Тест 2 (3 символа):', JSON.stringify(processMultiViewLineBreaks('abc')));
|
||
console.log('Тест 3 (5 символов):', JSON.stringify(processMultiViewLineBreaks('abcde')));
|
||
console.log('Тест 4 (6 символов):', JSON.stringify(processMultiViewLineBreaks('abcdef')));
|
||
console.log('Тест 5 (с переносами):', JSON.stringify(processMultiViewLineBreaks('a\nb\nc')));
|
||
console.log('Тест 6 (длинная строка):', JSON.stringify(processMultiViewLineBreaks('Это длинная строка с текстом')));
|
||
console.log('=== Конец теста ===');
|
||
}
|
||
|
||
// Функция для тестирования исправлений дублирования
|
||
function testDuplicateRemoval() {
|
||
console.log('=== Тест исправлений дублирования ===');
|
||
|
||
// Создаем тестовый элемент
|
||
const testElement = document.createElement('div');
|
||
testElement.innerHTML = `
|
||
<span class="line">Первая строка</span>
|
||
<span class="line">Вторая строка</span>
|
||
<span class="line">Вторая строка</span>
|
||
<span class="line">Третья строка</span>
|
||
<span class="line">Третья строка</span>
|
||
<span class="line">Четвертая строка</span>
|
||
`;
|
||
|
||
console.log('До очистки дубликатов:', testElement.querySelectorAll('.line').length, 'строк');
|
||
console.log('HTML до очистки:', testElement.innerHTML);
|
||
|
||
cleanMultiViewDuplicateLines(testElement);
|
||
|
||
console.log('После очистки дубликатов:', testElement.querySelectorAll('.line').length, 'строк');
|
||
console.log('HTML после очистки:', testElement.innerHTML);
|
||
|
||
console.log('=== Конец теста дублирования ===');
|
||
}
|
||
|
||
// Функция для тестирования Single View дублирования
|
||
function testSingleViewDuplicateRemoval() {
|
||
console.log('=== Тест Single View дублирования ===');
|
||
|
||
// Создаем тестовый элемент для Single View
|
||
const testElement = document.createElement('div');
|
||
testElement.innerHTML = `
|
||
<span class="line">INFO: 172.22.0.1:59030 - "GET /api/logs/stats/b816b3f326b9 HTTP/1.1" 200 OK</span>
|
||
<span class="line">INFO: 172.22.0.1:61392 - "GET / HTTP/1.1" 401 Unauthorized</span>
|
||
<span class="line">INFO: 172.22.0.1:61392 - "GET / HTTP/1.1" 401 Unauthorized</span>
|
||
<span class="line">INFO: 172.22.0.1:61392 - "GET / HTTP/1.1" 200 OK</span>
|
||
<span class="line">INFO: 172.22.0.1:61392 - "GET / HTTP/1.1" 200 OK</span>
|
||
<span class="line">INFO: 172.22.0.1:61392 - "GET /api/projects HTTP/1.1" 200 OK</span>
|
||
`;
|
||
|
||
console.log('До очистки Single View дубликатов:', testElement.querySelectorAll('.line').length, 'строк');
|
||
console.log('HTML до очистки:', testElement.innerHTML);
|
||
|
||
cleanDuplicateLines(testElement);
|
||
|
||
console.log('После очистки Single View дубликатов:', testElement.querySelectorAll('.line').length, 'строк');
|
||
console.log('HTML после очистки:', testElement.innerHTML);
|
||
|
||
console.log('=== Конец теста Single View дублирования ===');
|
||
}
|
||
|
||
// Функция для тестирования очистки пустых строк в Single View
|
||
function testSingleViewEmptyLinesRemoval() {
|
||
console.log('=== Тест очистки пустых строк в Single View ===');
|
||
|
||
// Создаем тестовый элемент с пустыми строками
|
||
const testElement = document.createElement('div');
|
||
testElement.innerHTML = `
|
||
<span class="line">INFO: 172.22.0.1:59030 - "GET /api/logs/stats/b816b3f326b9 HTTP/1.1" 200 OK</span>
|
||
<span class="line"> </span>
|
||
<span class="line"></span>
|
||
<span class="line">INFO: 172.22.0.1:61392 - "GET / HTTP/1.1" 401 Unauthorized</span>
|
||
<span class="line"> </span>
|
||
<span class="line">INFO: 172.22.0.1:61392 - "GET / HTTP/1.1" 200 OK</span>
|
||
<span class="line"></span>
|
||
<span class="line">INFO: 172.22.0.1:61392 - "GET /api/projects HTTP/1.1" 200 OK</span>
|
||
`;
|
||
|
||
console.log('До очистки пустых строк:', testElement.querySelectorAll('.line').length, 'строк');
|
||
console.log('HTML до очистки:', testElement.innerHTML);
|
||
|
||
cleanSingleViewEmptyLines(testElement);
|
||
|
||
console.log('После очистки пустых строк:', testElement.querySelectorAll('.line').length, 'строк');
|
||
console.log('HTML после очистки:', testElement.innerHTML);
|
||
|
||
console.log('=== Конец теста очистки пустых строк ===');
|
||
}
|
||
|
||
// Функция для тестирования правильного отображения переносов строк
|
||
function testSingleViewLineBreaks() {
|
||
console.log('=== Тест правильного отображения переносов строк в Single View ===');
|
||
|
||
// Создаем тестовый элемент с правильными переносами строк
|
||
const testElement = document.createElement('div');
|
||
testElement.innerHTML = `
|
||
<span class="line">INFO: 172.22.0.1:59030 - "GET /api/logs/stats/b816b3f326b9 HTTP/1.1" 200 OK</span>\n
|
||
<span class="line">INFO: 172.22.0.1:61392 - "GET / HTTP/1.1" 401 Unauthorized</span>\n
|
||
<span class="line">INFO: 172.22.0.1:61392 - "GET / HTTP/1.1" 200 OK</span>\n
|
||
<span class="line">INFO: 172.22.0.1:61392 - "GET /api/projects HTTP/1.1" 200 OK</span>\n
|
||
`;
|
||
|
||
console.log('Тестовый элемент с переносами строк:');
|
||
console.log('Количество строк:', testElement.querySelectorAll('.line').length);
|
||
console.log('HTML:', testElement.innerHTML);
|
||
console.log('Текстовое содержимое:', testElement.textContent);
|
||
|
||
// Проверяем, что переносы строк присутствуют
|
||
const textContent = testElement.textContent;
|
||
const lineBreaks = (textContent.match(/\n/g) || []).length;
|
||
console.log('Количество переносов строк в тексте:', lineBreaks);
|
||
|
||
console.log('=== Конец теста переносов строк ===');
|
||
}
|
||
|
||
// Тестовая функция для проверки работы cleanMultiViewEmptyLines
|
||
function testCleanMultiViewEmptyLines() {
|
||
console.log('=== Тест функции cleanMultiViewEmptyLines ===');
|
||
|
||
// Создаем тестовый элемент
|
||
const testElement = document.createElement('div');
|
||
testElement.innerHTML = `
|
||
<span class="line">Первая строка лога</span>
|
||
<span class="line"> </span>
|
||
<span class="line"></span>
|
||
<span class="line">Вторая строка лога</span>
|
||
<span class="line"> </span>
|
||
<span class="line">Третья строка лога</span>
|
||
`;
|
||
|
||
console.log('До очистки:', testElement.innerHTML);
|
||
cleanMultiViewEmptyLines(testElement);
|
||
console.log('После очистки:', testElement.innerHTML);
|
||
console.log('=== Конец теста ===');
|
||
}
|
||
|
||
// Тестовая функция для проверки работы normalizeSpaces
|
||
function testNormalizeSpaces() {
|
||
console.log('=== Тест функции normalizeSpaces ===');
|
||
console.log('Тест 1 (обычная строка):', JSON.stringify(normalizeSpaces('Hello World')));
|
||
console.log('Тест 2 (двойные пробелы):', JSON.stringify(normalizeSpaces('Hello World')));
|
||
console.log('Тест 3 (множественные пробелы):', JSON.stringify(normalizeSpaces('Hello World')));
|
||
console.log('Тест 4 (пробелы в начале и конце):', JSON.stringify(normalizeSpaces(' Hello World ')));
|
||
console.log('Тест 5 (табуляция и пробелы):', JSON.stringify(normalizeSpaces('Hello\t\tWorld')));
|
||
console.log('Тест 6 (смешанные пробелы):', JSON.stringify(normalizeSpaces('Hello \t World')));
|
||
console.log('Тест 7 (пустая строка):', JSON.stringify(normalizeSpaces('')));
|
||
console.log('Тест 8 (null):', JSON.stringify(normalizeSpaces(null)));
|
||
console.log('=== Конец теста ===');
|
||
}
|
||
|
||
// Тестовая функция для проверки работы processMultiViewSpecialReplacements
|
||
function testMultiViewSpecialReplacements() {
|
||
console.log('=== Тест функции processMultiViewSpecialReplacements ===');
|
||
console.log('Тест 1 (обычная строка):', JSON.stringify(processMultiViewSpecialReplacements('Hello World')));
|
||
console.log('Тест 2 (200 OKINFO:):', JSON.stringify(processMultiViewSpecialReplacements('200 OKINFO: Some message')));
|
||
console.log('Тест 3 (404 Not FoundINFO:):', JSON.stringify(processMultiViewSpecialReplacements('404 Not FoundINFO: Some message')));
|
||
console.log('Тест 4 (FoundINFO:):', JSON.stringify(processMultiViewSpecialReplacements('FoundINFO: Some message')));
|
||
console.log('Тест 5 (Found INFO:):', JSON.stringify(processMultiViewSpecialReplacements('Found INFO: Some message')));
|
||
console.log('Тест 6 (500 Internal Server ErrorINFO:):', JSON.stringify(processMultiViewSpecialReplacements('500 Internal Server ErrorINFO: Some message')));
|
||
console.log('Тест 7 (GET /api/usersINFO:):', JSON.stringify(processMultiViewSpecialReplacements('GET /api/usersINFO: Some message')));
|
||
console.log('Тест 8 (POST /api/loginINFO:):', JSON.stringify(processMultiViewSpecialReplacements('POST /api/loginINFO: Some message')));
|
||
console.log('Тест 9 (INFO: в начале):', JSON.stringify(processMultiViewSpecialReplacements('INFO: Some message')));
|
||
console.log('Тест 10 (несколько INFO:):', JSON.stringify(processMultiViewSpecialReplacements('200 OKINFO: First INFO: Second')));
|
||
console.log('Тест 11 (пустая строка):', JSON.stringify(processMultiViewSpecialReplacements('')));
|
||
console.log('Тест 12 (null):', JSON.stringify(processMultiViewSpecialReplacements(null)));
|
||
console.log('=== Конец теста ===');
|
||
}
|
||
|
||
// Комплексная тестовая функция для проверки полного процесса обработки MultiView
|
||
function testFullMultiViewProcessing() {
|
||
console.log('=== Тест полного процесса обработки MultiView ===');
|
||
|
||
const testCases = [
|
||
'200 OKINFO: Some message',
|
||
'404 Not FoundINFO: Another message',
|
||
'500 Internal Server ErrorINFO: Third message',
|
||
'FoundINFO: First FoundINFO: Second',
|
||
'GET /api/usersINFO: API call',
|
||
'POST /api/loginINFO: Login attempt',
|
||
'Short',
|
||
'Long message with 200 OKINFO: inside'
|
||
];
|
||
|
||
testCases.forEach((testCase, index) => {
|
||
console.log(`\nТест ${index + 1}: "${testCase}"`);
|
||
|
||
// 1. Нормализация пробелов
|
||
const normalized = normalizeSpaces(testCase);
|
||
console.log(' 1. Нормализация пробелов:', JSON.stringify(normalized));
|
||
|
||
// 2. Специальные замены
|
||
const specialProcessed = processMultiViewSpecialReplacements(normalized);
|
||
console.log(' 2. Специальные замены:', JSON.stringify(specialProcessed));
|
||
|
||
// 3. Обработка переноса строк
|
||
const finalProcessed = processMultiViewLineBreaks(specialProcessed);
|
||
console.log(' 3. Перенос строк:', JSON.stringify(finalProcessed));
|
||
|
||
console.log(' Результат:', finalProcessed);
|
||
});
|
||
|
||
console.log('\n=== Конец комплексного теста ===');
|
||
}
|
||
|
||
// Быстрая функция для тестирования замены INFO:
|
||
function quickTestINFO() {
|
||
console.log('=== Быстрый тест замены INFO: ===');
|
||
const testStrings = [
|
||
'INFO: 127.0.0.1:53352 - "GET /health HTTP/1.1" 200 OKINFO: 127.0.0.1:53352 - "GET /health HTTP/1.1" 200 OK',
|
||
'INFO: 127.0.0.1:48988 - "GET /health HTTP/1.1" 200 OKINFO: 127.0.0.1:48988 - "GET /health HTTP/1.1" 200 OK',
|
||
'INFO: 127.0.0.1:44606 - "GET /health HTTP/1.1" 200 OKINFO: 127.0.0.1:44606 - "GET /health HTTP/1.1" 200 OK',
|
||
'200 OKINFO:',
|
||
'404 Not FoundINFO:',
|
||
'500 Internal Server ErrorINFO:',
|
||
'FoundINFO:',
|
||
'INFO:'
|
||
];
|
||
|
||
testStrings.forEach((str, index) => {
|
||
const result = processMultiViewSpecialReplacements(str);
|
||
console.log(`Тест ${index + 1}: "${str}" -> "${result}"`);
|
||
});
|
||
console.log('=== Конец быстрого теста ===');
|
||
}
|
||
|
||
// Функция для тестирования регулярного выражения
|
||
function testRegex() {
|
||
console.log('=== Тест регулярного выражения ===');
|
||
|
||
const testString = 'INFO: 172.22.0.10:33860 - "POST /api/v1/auth/verify HTTP/1.1" 404 Not FoundINFO: 172.22.0.10:33860 - "POST /api/v1/auth/verify HTTP/1.1" 404 Not Found';
|
||
|
||
console.log('Исходная строка:', testString);
|
||
|
||
// Тестируем наше регулярное выражение
|
||
const result = testString.replace(/([A-Za-z0-9\s]+)INFO:/g, '$1\nINFO:');
|
||
console.log('Результат замены:', result);
|
||
|
||
// Проверяем, есть ли совпадения
|
||
const matches = testString.match(/([A-Za-z0-9\s]+)INFO:/g);
|
||
console.log('Найденные совпадения:', matches);
|
||
|
||
console.log('=== Конец теста регулярного выражения ===');
|
||
}
|
||
|
||
// Функция для проверки HTML в MultiView на наличие FoundINFO:
|
||
function checkMultiViewHTML() {
|
||
console.log('=== Проверка HTML в MultiView ===');
|
||
|
||
const multiViewLogs = document.querySelectorAll('.multi-view-log');
|
||
console.log('Найдено MultiView логов:', multiViewLogs.length);
|
||
|
||
multiViewLogs.forEach((log, index) => {
|
||
const containerId = log.getAttribute('data-container-id');
|
||
console.log(`MultiView ${index + 1} (${containerId}):`);
|
||
|
||
// Проверяем весь HTML
|
||
const html = log.innerHTML;
|
||
if (html.includes('FoundINFO:')) {
|
||
console.log('🚨 НАЙДЕНО FoundINFO: в HTML!');
|
||
console.log('HTML:', html);
|
||
} else {
|
||
console.log('✅ FoundINFO: не найдено в HTML');
|
||
}
|
||
|
||
// Проверяем текстовое содержимое
|
||
const textContent = log.textContent;
|
||
if (textContent.includes('FoundINFO:')) {
|
||
console.log('🚨 НАЙДЕНО FoundINFO: в тексте!');
|
||
console.log('Текст:', textContent);
|
||
} else {
|
||
console.log('✅ FoundINFO: не найдено в тексте');
|
||
}
|
||
});
|
||
|
||
console.log('=== Конец проверки HTML ===');
|
||
}
|
||
|
||
/**
|
||
* Основная функция обработки строк логов
|
||
* Классифицирует, фильтрует и отображает строки логов в зависимости от режима
|
||
* @param {string} id - ID контейнера
|
||
* @param {string} line - Строка лога для обработки
|
||
*/
|
||
function handleLine(id, line){
|
||
|
||
const obj = state.open[id];
|
||
if (!obj) {
|
||
console.error(`handleLine: Object not found for container ${id}, available containers:`, Object.keys(state.open));
|
||
return;
|
||
}
|
||
|
||
// Отладочная информация для первых нескольких строк
|
||
if (!obj.counters) {
|
||
console.error(`handleLine: Counters not initialized for container ${id}`);
|
||
obj.counters = {dbg:0, info:0, warn:0, err:0, other:0};
|
||
}
|
||
|
||
// Фильтруем сообщение "Connected to container" для всех режимов
|
||
// Это сообщение отправляется сервером при установке WebSocket соединения
|
||
if (line.includes('Connected to container:')) {
|
||
console.log(`handleLine: Фильтруем сообщение "Connected to container" для контейнера ${id}`);
|
||
return; // Пропускаем это сообщение во всех режимах
|
||
}
|
||
|
||
// Нормализуем пробелы в строке лога
|
||
const normalizedLine = normalizeSpaces(line);
|
||
|
||
const cls = classify(normalizedLine);
|
||
|
||
// Обновляем счетчики только для отображаемых логов
|
||
// Проверяем фильтры для отображения в зависимости от режима
|
||
let shouldShow;
|
||
if (state.multiViewMode && state.selectedContainers.includes(id)) {
|
||
// Для multi-view используем настройки конкретного контейнера
|
||
shouldShow = allowedByContainerLevel(cls, id) && applyFilter(normalizedLine);
|
||
} else {
|
||
// Для single-view используем глобальные настройки
|
||
shouldShow = allowedByLevel(cls) && applyFilter(normalizedLine);
|
||
}
|
||
|
||
// Обновляем счетчики только если строка будет отображаться
|
||
if (obj.counters && shouldShow) {
|
||
if (cls==='dbg') obj.counters.dbg++;
|
||
if (cls==='ok') obj.counters.info++;
|
||
if (cls==='warn') obj.counters.warn++;
|
||
if (cls==='err') obj.counters.err++;
|
||
if (cls==='other') obj.counters.other++;
|
||
}
|
||
|
||
// Обновляем счетчики в кнопках заголовков
|
||
updateHeaderCounters(id, obj.counters);
|
||
|
||
// Для Single View НЕ добавляем перенос строки после каждой строки лога
|
||
const html = `<span class="line ${cls}">${ansiToHtml(normalizedLine)}</span>`;
|
||
|
||
// Сохраняем все логи в буфере (всегда)
|
||
if (!obj.allLogs) obj.allLogs = [];
|
||
// Для Single View сохраняем обработанную строку, для MultiView - оригинальную
|
||
const processedLine = !state.multiViewMode ? processSingleViewSpecialReplacements(normalizedLine) : normalizedLine;
|
||
const processedHtml = `<span class="line ${cls}">${ansiToHtml(processedLine)}</span>`;
|
||
obj.allLogs.push({html: processedHtml, line: processedLine, cls: cls});
|
||
|
||
// Ограничиваем размер буфера
|
||
if (obj.allLogs.length > 10000) {
|
||
obj.allLogs = obj.allLogs.slice(-5000);
|
||
}
|
||
|
||
// Добавляем логи в отображение (обычный просмотр) - только если НЕ в multi-view режиме
|
||
if (shouldShow && obj.logEl && !state.multiViewMode) {
|
||
// Обрабатываем строку для Single View (без лишних переносов строк)
|
||
const singleViewProcessedLine = processSingleViewSpecialReplacements(normalizedLine);
|
||
|
||
// Проверяем на дублирование в Single View логах
|
||
const existingLines = Array.from(obj.logEl.querySelectorAll('.line'));
|
||
const lastLine = existingLines[existingLines.length - 1];
|
||
if (lastLine && lastLine.textContent === singleViewProcessedLine) {
|
||
console.log(`handleLine: Пропускаем дублированную строку для Single View контейнера ${id}:`, singleViewProcessedLine.substring(0, 50));
|
||
return; // Пропускаем дублированную строку
|
||
}
|
||
|
||
// Создаем HTML с обработанной строкой для Single View (без переноса строки)
|
||
const singleViewHtml = `<span class="line ${cls}">${ansiToHtml(singleViewProcessedLine)}</span>`;
|
||
|
||
obj.logEl.insertAdjacentHTML('beforeend', singleViewHtml);
|
||
|
||
// Очищаем лишние пустые строки в Single View
|
||
cleanSingleViewEmptyLines(obj.logEl);
|
||
|
||
if (els.autoscroll && els.autoscroll.checked && obj.wrapEl) {
|
||
obj.wrapEl.scrollTop = obj.wrapEl.scrollHeight;
|
||
}
|
||
|
||
// Update modern interface
|
||
console.log(`handleLine: Checking modern interface update - state.current:`, state.current, `id:`, id, `els.logContent:`, !!els.logContent);
|
||
if (state.current && state.current.id === id && els.logContent) {
|
||
console.log(`handleLine: Updating modern interface for container ${id} with html:`, singleViewHtml.substring(0, 100));
|
||
// Добавляем новую строку напрямую в современный интерфейс
|
||
els.logContent.insertAdjacentHTML('beforeend', singleViewHtml);
|
||
|
||
// Очищаем лишние пустые строки в современном интерфейсе
|
||
cleanSingleViewEmptyLines(els.logContent);
|
||
|
||
console.log(`handleLine: Modern interface updated, logContent children count:`, els.logContent.children.length);
|
||
if (els.autoscroll && els.autoscroll.checked) {
|
||
els.logContent.scrollTop = els.logContent.scrollHeight;
|
||
}
|
||
} else {
|
||
console.log(`handleLine: Modern interface update skipped - state.current:`, state.current?.id, `id:`, id, `logContent exists:`, !!els.logContent);
|
||
}
|
||
}
|
||
|
||
// Update multi-view interface
|
||
if (state.multiViewMode && state.selectedContainers.includes(id)) {
|
||
const multiViewLog = document.querySelector(`.multi-view-log[data-container-id="${id}"]`);
|
||
if (multiViewLog) {
|
||
// Проверяем фильтры для конкретного контейнера
|
||
const shouldShowInMultiView = allowedByContainerLevel(cls, id) && applyFilter(normalizedLine);
|
||
|
||
if (shouldShowInMultiView) {
|
||
// Применяем ограничение tail lines в multi view
|
||
const tailLines = parseInt(els.tail.value) || 50;
|
||
|
||
// Порядок обработки строк для MultiView:
|
||
// 1. Нормализация пробелов (уже выполнена выше)
|
||
// 2. Специальные замены (например, "FoundINFO:" -> "Found\nINFO:")
|
||
// 3. Обработка переноса строк
|
||
const specialProcessedLine = processMultiViewSpecialReplacements(normalizedLine);
|
||
|
||
// Обрабатываем перенос строк для multi view
|
||
// Если символов больше 5, то перенос строк работает
|
||
// Если меньше 5, то переноса строк нет
|
||
const processedLine = processMultiViewLineBreaks(specialProcessedLine);
|
||
|
||
// Проверяем на дублирование в multi-view логах
|
||
const existingLines = Array.from(multiViewLog.querySelectorAll('.line'));
|
||
const lastLine = existingLines[existingLines.length - 1];
|
||
if (lastLine && lastLine.textContent === processedLine) {
|
||
console.log(`handleLine: Пропускаем дублированную строку для контейнера ${id}:`, processedLine.substring(0, 50));
|
||
return; // Пропускаем дублированную строку
|
||
}
|
||
|
||
const multiViewHtml = `<span class="line ${cls}">${ansiToHtml(processedLine)}</span>`;
|
||
|
||
// Добавляем новую строку
|
||
multiViewLog.insertAdjacentHTML('beforeend', multiViewHtml);
|
||
|
||
// Очищаем пустые строки в multi view
|
||
cleanMultiViewEmptyLines(multiViewLog);
|
||
|
||
// Очищаем дублированные строки в multi view
|
||
cleanMultiViewDuplicateLines(multiViewLog);
|
||
|
||
// Ограничиваем количество отображаемых строк
|
||
const logLines = Array.from(multiViewLog.querySelectorAll('.line'));
|
||
if (logLines.length > tailLines) {
|
||
// Удаляем лишние строки с начала
|
||
const linesToRemove = logLines.length - tailLines;
|
||
console.log(`handleLine: Trimming ${linesToRemove} lines from container ${id} (tail: ${tailLines})`);
|
||
|
||
// Удаляем первые N строк
|
||
logLines.slice(0, linesToRemove).forEach(line => {
|
||
line.remove();
|
||
});
|
||
}
|
||
|
||
if (els.autoscroll && els.autoscroll.checked) {
|
||
multiViewLog.scrollTop = multiViewLog.scrollHeight;
|
||
}
|
||
console.log(`handleLine: Updated multi-view for container ${id}, log element found: true, tail lines: ${tailLines}`);
|
||
}
|
||
} else {
|
||
console.error(`handleLine: Multi-view log element not found for container ${id}`);
|
||
}
|
||
|
||
// Обновляем счетчики в multi view периодически (каждые 10 строк)
|
||
if (!state.multiViewCounterUpdateTimer) {
|
||
state.multiViewCounterUpdateTimer = setTimeout(() => {
|
||
updateMultiViewCounters();
|
||
|
||
// Периодически очищаем дублированные строки во всех multi-view логах
|
||
state.selectedContainers.forEach(containerId => {
|
||
const multiViewLog = document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`);
|
||
if (multiViewLog) {
|
||
cleanMultiViewDuplicateLines(multiViewLog);
|
||
}
|
||
});
|
||
|
||
state.multiViewCounterUpdateTimer = null;
|
||
}, 1000); // Обновляем каждую секунду
|
||
}
|
||
}
|
||
}
|
||
|
||
function ensurePanel(svc){
|
||
let panel = els.grid.querySelector(`.panel[data-cid="${svc.id}"]`);
|
||
if (!panel){
|
||
panel = panelTemplate(svc);
|
||
els.grid.appendChild(panel);
|
||
panel.querySelector('.t-reconnect').onclick = ()=>{
|
||
const id = svc.id;
|
||
const o = state.open[id];
|
||
if (o){ o.logEl.textContent=''; closeWs(id); }
|
||
openWs(svc, panel);
|
||
};
|
||
panel.querySelector('.t-close').onclick = ()=>{
|
||
closeWs(svc.id);
|
||
panel.remove();
|
||
if (!Object.keys(state.open).length) setWsState('off');
|
||
};
|
||
panel.querySelector('.t-snapshot').onclick = ()=> sendSnapshot(svc.id);
|
||
}
|
||
return panel;
|
||
}
|
||
|
||
/**
|
||
* Переключает интерфейс в режим одиночного просмотра (single view)
|
||
* Закрывает мультипросмотр, открывает WebSocket для выбранного контейнера
|
||
* @param {Object} svc - Объект сервиса/контейнера для просмотра
|
||
*/
|
||
async function switchToSingle(svc){
|
||
console.log('switchToSingle: ENTRY POINT - function called - VERSION 2');
|
||
console.log('switchToSingle: svc parameter:', svc);
|
||
|
||
try {
|
||
console.log('switchToSingle called for:', svc.name);
|
||
console.log('switchToSingle: Starting function execution');
|
||
console.log('switchToSingle: svc name:', svc.name, 'id:', svc.id);
|
||
console.log('switchToSingle: state.current:', state.current?.name, 'multiViewMode:', state.multiViewMode);
|
||
|
||
// Всегда очищаем мультипросмотр при переключении в single view
|
||
console.log('Clearing multi-view mode');
|
||
state.multiViewMode = false;
|
||
|
||
// Закрываем WebSocket соединения для мультипросмотра
|
||
console.log('switchToSingle: Closing WebSocket connections for multi-view');
|
||
state.selectedContainers.forEach(containerId => {
|
||
closeWs(containerId);
|
||
});
|
||
|
||
// Очищаем область логов
|
||
console.log('switchToSingle: Clearing log content');
|
||
if (els.logContent) {
|
||
els.logContent.innerHTML = '';
|
||
}
|
||
|
||
// Воссоздаем single-view-panel если его нет
|
||
const logContent = document.querySelector('.log-content');
|
||
const singleViewPanel = document.getElementById('singleViewPanel');
|
||
if (logContent && !singleViewPanel) {
|
||
console.log('switchToSingle: Recreating single-view-panel');
|
||
logContent.innerHTML = `
|
||
<div class="single-view-panel" id="singleViewPanel">
|
||
<div class="single-view-header">
|
||
<h4 class="single-view-title" id="singleViewTitle">${svc.name} (${svc.service || svc.name})</h4>
|
||
<div class="single-view-levels">
|
||
<button class="level-btn debug-btn" data-level="debug" title="DEBUG">
|
||
<span class="level-label">DEBUG</span>
|
||
<span class="level-value" data-container="single">0</span>
|
||
</button>
|
||
<button class="level-btn info-btn" data-level="info" title="INFO">
|
||
<span class="level-label">INFO</span>
|
||
<span class="level-value" data-container="single">0</span>
|
||
</button>
|
||
<button class="level-btn warn-btn" data-level="warn" title="WARN">
|
||
<span class="level-label">WARN</span>
|
||
<span class="level-value" data-container="single">0</span>
|
||
</button>
|
||
<button class="level-btn error-btn" data-level="err" title="ERROR">
|
||
<span class="level-label">ERROR</span>
|
||
<span class="level-value" data-container="single">0</span>
|
||
</button>
|
||
<button class="level-btn other-btn" data-level="other" title="OTHER">
|
||
<span class="level-label">OTHER</span>
|
||
<span class="level-value" data-container="single">0</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="single-view-content">
|
||
<pre class="log" id="logContent">Connecting...</pre>
|
||
</div>
|
||
</div>
|
||
`;
|
||
// Обновляем ссылки на элементы
|
||
els.singleViewPanel = document.getElementById('singleViewPanel');
|
||
els.singleViewTitle = document.getElementById('singleViewTitle');
|
||
els.logContent = document.getElementById('logContent');
|
||
}
|
||
|
||
// Удаляем мультипросмотр из DOM
|
||
const multiViewGrid = document.getElementById('multiViewGrid');
|
||
if (multiViewGrid) {
|
||
console.log('Removing multi-view grid from DOM');
|
||
multiViewGrid.remove();
|
||
} else {
|
||
console.log('Multi-view grid not found in DOM');
|
||
}
|
||
|
||
// Legacy functionality (скрытая)
|
||
console.log('switchToSingle: Setting up legacy functionality');
|
||
setLayout('tabs');
|
||
els.grid.innerHTML='';
|
||
const panel = ensurePanel(svc);
|
||
panel.querySelector('.log').textContent='';
|
||
closeWs(svc.id);
|
||
console.log('switchToSingle: Calling openWs for:', svc.name, 'id:', svc.id);
|
||
openWs(svc, panel);
|
||
state.current = svc;
|
||
console.log('switchToSingle: Set state.current to:', svc.name, 'id:', svc.id);
|
||
console.log('switchToSingle: state.current after setting:', state.current);
|
||
buildTabs();
|
||
for (const p of [...els.grid.children]) if (p!==panel) p.remove();
|
||
|
||
// Обновляем состояние выбранных контейнеров для корректного отображения заголовка
|
||
state.selectedContainers = [svc.id];
|
||
|
||
// Сохраняем режим просмотра в localStorage
|
||
saveViewMode(false, [svc.id]);
|
||
|
||
// Обновляем активное состояние в UI
|
||
updateActiveContainerUI(svc.id);
|
||
|
||
// Сохраняем состояние кнопок loglevels в localStorage
|
||
saveLogLevelsState();
|
||
|
||
|
||
if (els.multiViewPanelTitle) {
|
||
els.multiViewPanelTitle.textContent = `${svc.name} (${svc.service || svc.name})`;
|
||
}
|
||
if (els.singleViewTitle) {
|
||
els.singleViewTitle.textContent = `${svc.name} (${svc.service || svc.name})`;
|
||
}
|
||
if (els.logContent) {
|
||
els.logContent.innerHTML = '<span class="line info">Connecting...</span>';
|
||
}
|
||
|
||
// Обновляем logEl для современного интерфейса
|
||
const obj = state.open[svc.id];
|
||
if (obj && els.logContent) {
|
||
obj.logEl = els.logContent;
|
||
obj.wrapEl = els.logContent.parentElement;
|
||
console.log('switchToSingle: Updated obj.logEl and obj.wrapEl for modern interface');
|
||
|
||
// Если у нас уже есть логи в буфере, отображаем их
|
||
if (obj.allLogs && obj.allLogs.length > 0) {
|
||
console.log(`switchToSingle: Restoring ${obj.allLogs.length} buffered log lines`);
|
||
els.logContent.innerHTML = '';
|
||
obj.allLogs.forEach(logEntry => {
|
||
if (allowedByLevel(logEntry.cls) && applyFilter(logEntry.line)) {
|
||
els.logContent.insertAdjacentHTML('beforeend', logEntry.html);
|
||
}
|
||
});
|
||
|
||
// Очищаем лишние пустые строки после восстановления логов
|
||
cleanSingleViewEmptyLines(els.logContent);
|
||
cleanDuplicateLines(els.logContent);
|
||
|
||
if (els.autoscroll && els.autoscroll.checked) {
|
||
els.logContent.scrollTop = els.logContent.scrollHeight;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Update active state in container list
|
||
document.querySelectorAll('.container-item').forEach(item => {
|
||
item.classList.remove('active');
|
||
});
|
||
const activeItem = document.querySelector(`.container-item[data-cid="${svc.id}"]`);
|
||
if (activeItem) {
|
||
activeItem.classList.add('active');
|
||
}
|
||
|
||
// Обновляем состояние чекбоксов после переключения контейнера
|
||
updateContainerSelectionUI();
|
||
|
||
// Обновляем счетчики для нового контейнера
|
||
setTimeout(() => {
|
||
recalculateCounters();
|
||
// Применяем настройки wrap text после переключения контейнера
|
||
applyWrapSettings();
|
||
}, 500); // Небольшая задержка для завершения загрузки логов
|
||
await updateCounters(svc.id);
|
||
|
||
// Добавляем обработчики для счетчиков после переключения контейнера
|
||
addCounterClickHandlers();
|
||
|
||
// Обновляем состояние кнопок уровней логирования
|
||
setTimeout(() => {
|
||
initializeLevelButtons();
|
||
}, 100);
|
||
|
||
} catch (error) {
|
||
console.error('switchToSingle: Error occurred:', error);
|
||
console.error('switchToSingle: Error stack:', error.stack);
|
||
}
|
||
}
|
||
|
||
async function openMulti(ids){
|
||
els.grid.innerHTML='';
|
||
const chosen = state.services.filter(s=> ids.includes(s.id));
|
||
const n = chosen.length;
|
||
if (n<=1){ if (n===1) await switchToSingle(chosen[0]); return; }
|
||
setLayout(n>=4 ? 'grid4' : n===3 ? 'grid3' : 'grid2');
|
||
for (const svc of chosen){
|
||
const panel = ensurePanel(svc);
|
||
panel.querySelector('.log').textContent='';
|
||
closeWs(svc.id);
|
||
openWs(svc, panel);
|
||
}
|
||
|
||
// Добавляем обработчики для счетчиков после открытия мульти-контейнеров
|
||
addCounterClickHandlers();
|
||
|
||
// Применяем настройки wrap text после открытия мульти-контейнеров
|
||
applyWrapSettings();
|
||
}
|
||
|
||
// ----- Copy on selection -----
|
||
function getSelectionText(){
|
||
const sel = window.getSelection();
|
||
return sel && sel.rangeCount ? sel.toString() : "";
|
||
}
|
||
function showCopyFabNearSelection(){
|
||
const sel = window.getSelection();
|
||
if (!sel || sel.rangeCount===0) return hideCopyFab();
|
||
const text = sel.toString();
|
||
if (!text.trim()) return hideCopyFab();
|
||
// Only show if selection inside a .log or .logwrap
|
||
const range = sel.getRangeAt(0);
|
||
const common = range.commonAncestorContainer;
|
||
const el = common.nodeType===1 ? common : common.parentElement;
|
||
if (!el || !el.closest('.logwrap')) return hideCopyFab();
|
||
const rect = range.getBoundingClientRect();
|
||
const top = rect.bottom + 8 + window.scrollY;
|
||
const left = rect.right + 8 + window.scrollX;
|
||
els.copyFab.style.top = top + 'px';
|
||
els.copyFab.style.left = left + 'px';
|
||
els.copyFab.classList.add('show');
|
||
}
|
||
function hideCopyFab(){
|
||
els.copyFab.classList.remove('show');
|
||
}
|
||
document.addEventListener('selectionchange', ()=>{
|
||
// throttle-ish using requestAnimationFrame
|
||
window.requestAnimationFrame(showCopyFabNearSelection);
|
||
});
|
||
document.addEventListener('scroll', hideCopyFab, true);
|
||
els.copyFab.addEventListener('click', async ()=>{
|
||
const text = getSelectionText();
|
||
if (!text) return;
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
const old = els.copyFab.textContent;
|
||
els.copyFab.textContent = 'скопировано';
|
||
setTimeout(()=> els.copyFab.textContent = old, 1000);
|
||
hideCopyFab();
|
||
window.getSelection()?.removeAllRanges();
|
||
} catch(e){
|
||
alert('не удалось скопировать: ' + e);
|
||
}
|
||
});
|
||
|
||
|
||
function fanGroupUrl(servicesCsv, project){
|
||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||
const tail = els.tail.value || '500';
|
||
const token = encodeURIComponent(localStorage.getItem('access_token') || '');
|
||
const pj = project?`&project=${encodeURIComponent(project)}`:'';
|
||
return `${proto}://${location.host}/api/websocket/fan_group?services=${encodeURIComponent(servicesCsv)}&tail=${tail}&token=${token}${pj}`;
|
||
}
|
||
|
||
function openFanGroup(services){
|
||
// Build a special panel named after the group
|
||
els.grid.innerHTML='';
|
||
const fake = { id: 'group-'+services.join(','), name:'group', service: 'group', project: (localStorage.lb_project||'') };
|
||
const panel = ensurePanel(fake);
|
||
panel.querySelector('.log').textContent='';
|
||
closeWs(fake.id);
|
||
|
||
// Override ws creation to fan_group
|
||
const id = fake.id;
|
||
const logEl = panel.querySelector('.log');
|
||
const wrapEl = panel.querySelector('.logwrap');
|
||
const cdbg = panel.querySelector('.cdbg') || document.querySelector('.cdbg');
|
||
const cinfo = panel.querySelector('.cinfo') || document.querySelector('.cinfo');
|
||
const cwarn = panel.querySelector('.cwarn') || document.querySelector('.cwarn');
|
||
const cerr = panel.querySelector('.cerr') || document.querySelector('.cerr');
|
||
const cother = panel.querySelector('.cother') || document.querySelector('.cother');
|
||
const counters = {dbg:0,info:0,warn:0,err:0,other:0};
|
||
|
||
const ws = new WebSocket(fanGroupUrl(services.join(','), fake.project||''));
|
||
state.open[id] = {ws, logEl, wrapEl, counters, pausedBuffer:[], serviceName: ('group:'+services.join(','))};
|
||
|
||
ws.onopen = ()=> {
|
||
console.log(`WebSocket ${id}: Соединение открыто`);
|
||
setWsState('on');
|
||
};
|
||
ws.onclose = ()=> {
|
||
console.log(`WebSocket ${id}: Соединение закрыто`);
|
||
setWsState(determineWsState());
|
||
|
||
// Принудительно проверяем состояние через AJAX через 500мс
|
||
setTimeout(() => {
|
||
console.log(`WebSocket ${id}: Принудительная проверка состояния после закрытия`);
|
||
checkWebSocketStatus();
|
||
}, 500);
|
||
};
|
||
ws.onerror = (error)=> {
|
||
console.log(`WebSocket ${id}: Ошибка соединения:`, error);
|
||
setWsState('err');
|
||
};
|
||
ws.onmessage = (ev)=>{
|
||
// Устанавливаем состояние 'on' при получении сообщений
|
||
setWsState('on');
|
||
|
||
const parts = (ev.data||'').split(/\r?\n/);
|
||
for (let i=0;i<parts.length;i++){
|
||
if (parts[i].length===0 && i===parts.length-1) continue;
|
||
const pr = parsePrefixAndStrip(parts[i]);
|
||
if (pr){ if (!(pr.id8 in inst.filters)) { inst.filters[pr.id8] = true; updateIdFiltersBar(); } }
|
||
handleLine(id, parts[i]);
|
||
}
|
||
cdbg.textContent = counters.dbg;
|
||
cinfo.textContent = counters.info;
|
||
cwarn.textContent = counters.warn;
|
||
cerr.textContent = counters.err;
|
||
if (cother) cother.textContent = counters.other;
|
||
|
||
// Обновляем видимость счетчиков
|
||
updateCounterVisibility();
|
||
|
||
// Добавляем обработчики для счетчиков
|
||
addCounterClickHandlers();
|
||
};
|
||
|
||
// Show filter bar and clear previous filters
|
||
inst.filters = {};
|
||
updateIdFiltersBar();
|
||
}
|
||
|
||
if (els.groupBtn && els.groupBtn.onclick !== null) {
|
||
els.groupBtn.onclick = ()=>{
|
||
const list = state.services.map(s=> `${s.service}`).filter((v,i,a)=> a.indexOf(v)===i).join(', ');
|
||
const ans = prompt('Введите имена сервисов через запятую:\n'+list);
|
||
if (ans){
|
||
const services = ans.split(',').map(x=>x.trim()).filter(Boolean);
|
||
if (services.length) openFanGroup(services);
|
||
}
|
||
};
|
||
}
|
||
|
||
// Функция для обновления счетчиков через Ajax
|
||
async function updateCounters(containerId) {
|
||
try {
|
||
const token = localStorage.getItem('access_token');
|
||
if (!token) {
|
||
console.error('No access token found');
|
||
return;
|
||
}
|
||
|
||
const response = await fetch(`/api/logs/stats/${containerId}`, {
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`
|
||
}
|
||
});
|
||
if (response.ok) {
|
||
const stats = await response.json();
|
||
const cdbg = document.querySelector('.cdbg');
|
||
const cinfo = document.querySelector('.cinfo');
|
||
const cwarn = document.querySelector('.cwarn');
|
||
const cerr = document.querySelector('.cerr');
|
||
const cother = document.querySelector('.cother');
|
||
|
||
if (cdbg) cdbg.textContent = stats.debug || 0;
|
||
if (cinfo) cinfo.textContent = stats.info || 0;
|
||
if (cwarn) cwarn.textContent = stats.warn || 0;
|
||
if (cerr) cerr.textContent = stats.error || 0;
|
||
if (cother) cother.textContent = stats.other || 0;
|
||
|
||
// Обновляем видимость счетчиков
|
||
updateCounterVisibility();
|
||
|
||
// Добавляем обработчики для счетчиков
|
||
addCounterClickHandlers();
|
||
|
||
// Очищаем дублированные строки в Single View режиме
|
||
if (!state.multiViewMode && state.current && state.current.id === containerId) {
|
||
const logContent = document.querySelector('.log-content');
|
||
if (logContent) {
|
||
cleanDuplicateLines(logContent);
|
||
cleanSingleViewEmptyLines(logContent);
|
||
}
|
||
|
||
// Также очищаем в legacy панели
|
||
const obj = state.open[containerId];
|
||
if (obj && obj.logEl) {
|
||
cleanDuplicateLines(obj.logEl);
|
||
cleanSingleViewEmptyLines(obj.logEl);
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Error updating counters:', error);
|
||
}
|
||
}
|
||
|
||
// Функция для обновления счетчиков в multi view (суммирует статистику всех контейнеров)
|
||
// Эта функция теперь использует пересчет на основе отображаемых логов
|
||
async function updateMultiViewCounters() {
|
||
if (!state.multiViewMode || state.selectedContainers.length === 0) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
console.log('Updating multi-view counters for containers:', state.selectedContainers);
|
||
|
||
// Используем новую функцию пересчета счетчиков
|
||
recalculateMultiViewCounters();
|
||
|
||
// Добавляем обработчики для счетчиков
|
||
addCounterClickHandlers();
|
||
|
||
} catch (error) {
|
||
console.error('Error updating multi-view counters:', error);
|
||
}
|
||
}
|
||
|
||
// Функция для пересчета счетчиков на основе отображаемых логов (Single View)
|
||
function recalculateCounters() {
|
||
if (!state.current) return;
|
||
|
||
const containerId = state.current.id;
|
||
const obj = state.open[containerId];
|
||
if (!obj || !obj.allLogs) return;
|
||
|
||
// Получаем значение Tail Lines
|
||
const tailLines = parseInt(els.tail.value) || 50;
|
||
|
||
// Берем только последние N логов в соответствии с Tail Lines
|
||
const visibleLogs = obj.allLogs.slice(-tailLines);
|
||
|
||
// Сбрасываем счетчики
|
||
obj.counters = {dbg: 0, info: 0, warn: 0, err: 0, other: 0};
|
||
|
||
// Пересчитываем счетчики только для отображаемых логов
|
||
visibleLogs.forEach(logEntry => {
|
||
const shouldShow = allowedByLevel(logEntry.cls) && applyFilter(logEntry.line);
|
||
if (shouldShow) {
|
||
if (logEntry.cls === 'dbg') obj.counters.dbg++;
|
||
if (logEntry.cls === 'ok') obj.counters.info++;
|
||
if (logEntry.cls === 'warn') obj.counters.warn++;
|
||
if (logEntry.cls === 'err') obj.counters.err++;
|
||
if (logEntry.cls === 'other') obj.counters.other++;
|
||
}
|
||
});
|
||
|
||
// Обновляем отображение счетчиков
|
||
const cdbg = document.querySelector('.cdbg');
|
||
const cinfo = document.querySelector('.cinfo');
|
||
const cwarn = document.querySelector('.cwarn');
|
||
const cerr = document.querySelector('.cerr');
|
||
const cother = document.querySelector('.cother');
|
||
|
||
if (cdbg) cdbg.textContent = obj.counters.dbg;
|
||
if (cinfo) cinfo.textContent = obj.counters.info;
|
||
if (cwarn) cwarn.textContent = obj.counters.warn;
|
||
if (cerr) cerr.textContent = obj.counters.err;
|
||
if (cother) cother.textContent = obj.counters.other;
|
||
|
||
// Обновляем счетчики в кнопках заголовка single-view
|
||
updateHeaderCounters(containerId, obj.counters);
|
||
|
||
console.log(`Counters recalculated for container ${containerId} (tail: ${tailLines}):`, obj.counters);
|
||
}
|
||
|
||
// Функция для пересчета счетчиков в MultiView на основе отображаемых логов
|
||
function recalculateMultiViewCounters() {
|
||
if (!state.multiViewMode || state.selectedContainers.length === 0) {
|
||
return;
|
||
}
|
||
|
||
console.log('Recalculating multi-view counters for containers:', state.selectedContainers);
|
||
|
||
// Получаем значение Tail Lines
|
||
const tailLines = parseInt(els.tail.value) || 50;
|
||
|
||
// Суммируем статистику всех выбранных контейнеров
|
||
let totalDebug = 0;
|
||
let totalInfo = 0;
|
||
let totalWarn = 0;
|
||
let totalError = 0;
|
||
let totalOther = 0;
|
||
|
||
// Пересчитываем счетчики для каждого контейнера
|
||
for (const containerId of state.selectedContainers) {
|
||
const obj = state.open[containerId];
|
||
if (!obj || !obj.allLogs) continue;
|
||
|
||
// Берем только последние N логов в соответствии с Tail Lines
|
||
const visibleLogs = obj.allLogs.slice(-tailLines);
|
||
|
||
// Сбрасываем счетчики для этого контейнера
|
||
obj.counters = {dbg: 0, info: 0, warn: 0, err: 0, other: 0};
|
||
|
||
// Пересчитываем счетчики только для отображаемых логов
|
||
visibleLogs.forEach(logEntry => {
|
||
const shouldShow = allowedByContainerLevel(logEntry.cls, containerId) && applyFilter(logEntry.line);
|
||
if (shouldShow) {
|
||
if (logEntry.cls === 'dbg') obj.counters.dbg++;
|
||
if (logEntry.cls === 'ok') obj.counters.info++;
|
||
if (logEntry.cls === 'warn') obj.counters.warn++;
|
||
if (logEntry.cls === 'err') obj.counters.err++;
|
||
if (logEntry.cls === 'other') obj.counters.other++;
|
||
}
|
||
});
|
||
|
||
// Обновляем счетчики в кнопках заголовка для этого контейнера
|
||
updateHeaderCounters(containerId, obj.counters);
|
||
|
||
// Добавляем к общим счетчикам
|
||
totalDebug += obj.counters.dbg;
|
||
totalInfo += obj.counters.info;
|
||
totalWarn += obj.counters.warn;
|
||
totalError += obj.counters.err;
|
||
totalOther += obj.counters.other;
|
||
}
|
||
|
||
// Обновляем отображение счетчиков
|
||
const cdbg = document.querySelector('.cdbg');
|
||
const cinfo = document.querySelector('.cinfo');
|
||
const cwarn = document.querySelector('.cwarn');
|
||
const cerr = document.querySelector('.cerr');
|
||
const cother = document.querySelector('.cother');
|
||
|
||
if (cdbg) cdbg.textContent = totalDebug;
|
||
if (cinfo) cinfo.textContent = totalInfo;
|
||
if (cwarn) cwarn.textContent = totalWarn;
|
||
if (cerr) cerr.textContent = totalError;
|
||
if (cother) cother.textContent = totalOther;
|
||
|
||
console.log(`Multi-view counters recalculated (tail: ${tailLines}):`, { totalDebug, totalInfo, totalWarn, totalError, totalOther });
|
||
}
|
||
|
||
// Функция для обновления видимости счетчиков
|
||
function updateCounterVisibility() {
|
||
// Обновляем старые кнопки счетчиков (только для legacy интерфейса)
|
||
const debugBtn = document.querySelector('.debug-btn');
|
||
const infoBtn = document.querySelector('.info-btn');
|
||
const warnBtn = document.querySelector('.warn-btn');
|
||
const errorBtn = document.querySelector('.error-btn');
|
||
const otherBtn = document.querySelector('.other-btn');
|
||
|
||
if (debugBtn) {
|
||
debugBtn.classList.toggle('disabled', !state.levels.debug);
|
||
}
|
||
if (infoBtn) {
|
||
infoBtn.classList.toggle('disabled', !state.levels.info);
|
||
}
|
||
if (warnBtn) {
|
||
warnBtn.classList.toggle('disabled', !state.levels.warn);
|
||
}
|
||
if (errorBtn) {
|
||
errorBtn.classList.toggle('disabled', !state.levels.err);
|
||
}
|
||
if (otherBtn) {
|
||
otherBtn.classList.toggle('disabled', !state.levels.other);
|
||
}
|
||
}
|
||
|
||
// Функция для управления видимостью кнопок LogLevels
|
||
function updateLogLevelsVisibility() {
|
||
const singleViewLevels = document.querySelector('.single-view-levels');
|
||
const multiViewLevels = document.querySelectorAll('.multi-view-levels');
|
||
|
||
// Проверяем, есть ли выбранные контейнеры
|
||
const hasSelectedContainers = state.selectedContainers.length > 0 || state.current;
|
||
|
||
// Управляем видимостью кнопок в single view
|
||
if (singleViewLevels) {
|
||
if (hasSelectedContainers) {
|
||
singleViewLevels.style.display = 'flex';
|
||
} else {
|
||
singleViewLevels.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// Управляем видимостью кнопок в multi view
|
||
multiViewLevels.forEach(levelsContainer => {
|
||
if (hasSelectedContainers) {
|
||
levelsContainer.style.display = 'flex';
|
||
} else {
|
||
levelsContainer.style.display = 'none';
|
||
}
|
||
});
|
||
}
|
||
|
||
// Функция для обновления логов и счетчиков
|
||
async function refreshLogsAndCounters() {
|
||
if (state.multiViewMode && state.selectedContainers.length > 0) {
|
||
// Обновляем мультипросмотр
|
||
console.log('Refreshing multi-view for containers:', state.selectedContainers);
|
||
|
||
// Очищаем логи в мультипросмотре перед обновлением
|
||
state.selectedContainers.forEach(containerId => {
|
||
const multiViewLog = document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`);
|
||
if (multiViewLog) {
|
||
multiViewLog.textContent = 'Refreshing...';
|
||
}
|
||
// Очищаем буфер логов для мультипросмотра
|
||
const obj = state.open[containerId];
|
||
if (obj && obj.allLogs) {
|
||
obj.allLogs = [];
|
||
}
|
||
});
|
||
|
||
// Перезапускаем WebSocket соединения для всех выбранных контейнеров
|
||
state.selectedContainers.forEach(containerId => {
|
||
closeWs(containerId);
|
||
const service = state.services.find(s => s.id === containerId);
|
||
if (service) {
|
||
openMultiViewWs(service);
|
||
}
|
||
});
|
||
|
||
// Пересчитываем счетчики на основе отображаемых логов
|
||
setTimeout(() => {
|
||
recalculateMultiViewCounters();
|
||
// Применяем настройки wrap text после обновления
|
||
applyWrapSettings();
|
||
}, 1000); // Небольшая задержка для завершения переподключения
|
||
|
||
} else if (state.current) {
|
||
// Обычный режим просмотра
|
||
console.log('Refreshing logs and counters for:', state.current.id);
|
||
|
||
// Очищаем логи перед обновлением
|
||
if (els.logContent) {
|
||
els.logContent.textContent = 'Refreshing...';
|
||
}
|
||
|
||
// Перезапускаем WebSocket соединение для получения свежих логов
|
||
const currentId = state.current.id;
|
||
closeWs(currentId);
|
||
|
||
// Находим обновленный контейнер в списке
|
||
const updatedContainer = state.services.find(s => s.id === currentId);
|
||
if (updatedContainer) {
|
||
// Переключаемся на обновленный контейнер
|
||
await switchToSingle(updatedContainer);
|
||
|
||
// Очищаем лишние пустые строки после переключения
|
||
if (els.logContent) {
|
||
cleanSingleViewEmptyLines(els.logContent);
|
||
cleanDuplicateLines(els.logContent);
|
||
}
|
||
}
|
||
|
||
// Пересчитываем счетчики на основе отображаемых логов
|
||
setTimeout(() => {
|
||
recalculateCounters();
|
||
// Применяем настройки wrap text после обновления
|
||
applyWrapSettings();
|
||
}, 1000); // Небольшая задержка для завершения переподключения
|
||
|
||
} else {
|
||
console.log('No container selected');
|
||
}
|
||
}
|
||
|
||
// Controls
|
||
els.clearBtn.onclick = ()=> {
|
||
// Очищаем обычный просмотр
|
||
Object.values(state.open).forEach(o => {
|
||
if (o.logEl) o.logEl.textContent = '';
|
||
if (o.allLogs) o.allLogs = []; // Очищаем буфер логов
|
||
});
|
||
|
||
// Очищаем современный интерфейс
|
||
if (els.logContent) {
|
||
els.logContent.textContent = '';
|
||
// Очищаем лишние пустые строки после очистки
|
||
cleanSingleViewEmptyLines(els.logContent);
|
||
cleanDuplicateLines(els.logContent);
|
||
}
|
||
|
||
// Очищаем мультипросмотр
|
||
if (state.multiViewMode) {
|
||
state.selectedContainers.forEach(containerId => {
|
||
const multiViewLog = document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`);
|
||
if (multiViewLog) {
|
||
multiViewLog.textContent = '';
|
||
}
|
||
// Очищаем буфер логов для мультипросмотра
|
||
const obj = state.open[containerId];
|
||
if (obj && obj.allLogs) {
|
||
obj.allLogs = [];
|
||
}
|
||
});
|
||
}
|
||
|
||
// Сбрасываем счетчики
|
||
document.querySelectorAll('.cdbg, .cinfo, .cwarn, .cerr').forEach(el => {
|
||
el.textContent = '0';
|
||
});
|
||
|
||
// Сбрасываем счетчики в объектах состояния
|
||
Object.values(state.open).forEach(obj => {
|
||
if (obj.counters) {
|
||
obj.counters = {dbg: 0, info: 0, warn: 0, err: 0};
|
||
}
|
||
});
|
||
};
|
||
|
||
els.refreshBtn.onclick = async () => {
|
||
console.log('Refreshing services...');
|
||
await fetchServices();
|
||
|
||
if (state.multiViewMode && state.selectedContainers.length > 0) {
|
||
// В Multi View режиме обновляем все выбранные контейнеры
|
||
console.log('Refreshing Multi View mode with containers:', state.selectedContainers);
|
||
|
||
// Закрываем все текущие соединения
|
||
state.selectedContainers.forEach(containerId => {
|
||
closeWs(containerId);
|
||
});
|
||
|
||
// Перезапускаем соединения для всех выбранных контейнеров
|
||
state.selectedContainers.forEach(containerId => {
|
||
const service = state.services.find(s => s.id === containerId);
|
||
if (service) {
|
||
openMultiViewWs(service);
|
||
}
|
||
});
|
||
|
||
// Очищаем логи в мультипросмотре
|
||
state.selectedContainers.forEach(containerId => {
|
||
const multiViewLog = document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`);
|
||
if (multiViewLog) {
|
||
multiViewLog.textContent = 'Refreshing...';
|
||
}
|
||
});
|
||
|
||
} else if (state.current) {
|
||
// Обычный режим просмотра
|
||
console.log('Reconnecting to current container:', state.current.id);
|
||
const currentId = state.current.id;
|
||
|
||
// Закрываем текущее соединение
|
||
closeWs(currentId);
|
||
|
||
// Находим обновленный контейнер в списке
|
||
const updatedContainer = state.services.find(s => s.id === currentId);
|
||
if (updatedContainer) {
|
||
// Переключаемся на обновленный контейнер
|
||
await switchToSingle(updatedContainer);
|
||
} else {
|
||
// Если контейнер больше не существует, не открываем автоматически первый доступный
|
||
// Пользователь сам выберет нужный контейнер
|
||
console.log('Container no longer exists, not auto-opening first available container');
|
||
}
|
||
}
|
||
};
|
||
|
||
// Обработчик для кнопок refresh логов (в log-header и в header)
|
||
document.querySelectorAll('.log-refresh-btn').forEach(btn=>{
|
||
btn.addEventListener('click', refreshLogsAndCounters);
|
||
});
|
||
|
||
// Обработчик для кнопки update (AJAX autoupdate toggle)
|
||
if (els.ajaxUpdateBtn) {
|
||
console.log('Инициализация обработчика клика для кнопки update');
|
||
els.ajaxUpdateBtn.addEventListener('click', () => {
|
||
console.log('Клик по кнопке update - вызываем toggleAjaxLogUpdate()');
|
||
toggleAjaxLogUpdate();
|
||
});
|
||
} else {
|
||
console.error('Кнопка ajaxUpdateBtn не найдена при инициализации обработчика!');
|
||
}
|
||
|
||
// Обработчики для счетчиков
|
||
function addCounterClickHandlers() {
|
||
// Назначаем обработчики на все дубликаты кнопок (и в log-header, и в header-compact-controls)
|
||
const debugButtons = document.querySelectorAll('.debug-btn');
|
||
const infoButtons = document.querySelectorAll('.info-btn');
|
||
const warnButtons = document.querySelectorAll('.warn-btn');
|
||
const errorButtons = document.querySelectorAll('.error-btn');
|
||
const otherButtons = document.querySelectorAll('.other-btn');
|
||
|
||
debugButtons.forEach(debugBtn => debugBtn.onclick = () => {
|
||
state.levels.debug = !state.levels.debug;
|
||
updateCounterVisibility();
|
||
refreshAllLogs();
|
||
// Добавляем refresh для обновления логов
|
||
if (state.current) {
|
||
refreshLogsAndCounters();
|
||
}
|
||
// Обновляем multi-view если он активен
|
||
if (state.multiViewMode) {
|
||
refreshAllLogs();
|
||
}
|
||
});
|
||
|
||
infoButtons.forEach(infoBtn => infoBtn.onclick = () => {
|
||
state.levels.info = !state.levels.info;
|
||
updateCounterVisibility();
|
||
refreshAllLogs();
|
||
// Добавляем refresh для обновления логов
|
||
if (state.current) {
|
||
refreshLogsAndCounters();
|
||
}
|
||
// Обновляем multi-view если он активен
|
||
if (state.multiViewMode) {
|
||
refreshAllLogs();
|
||
}
|
||
});
|
||
|
||
warnButtons.forEach(warnBtn => warnBtn.onclick = () => {
|
||
state.levels.warn = !state.levels.warn;
|
||
updateCounterVisibility();
|
||
refreshAllLogs();
|
||
// Добавляем refresh для обновления логов
|
||
if (state.current) {
|
||
refreshLogsAndCounters();
|
||
}
|
||
// Обновляем multi-view если он активен
|
||
if (state.multiViewMode) {
|
||
refreshAllLogs();
|
||
}
|
||
});
|
||
|
||
errorButtons.forEach(errorBtn => errorBtn.onclick = () => {
|
||
state.levels.err = !state.levels.err;
|
||
updateCounterVisibility();
|
||
refreshAllLogs();
|
||
// Добавляем refresh для обновления логов
|
||
if (state.current) {
|
||
refreshLogsAndCounters();
|
||
}
|
||
// Обновляем multi-view если он активен
|
||
if (state.multiViewMode) {
|
||
refreshAllLogs();
|
||
}
|
||
});
|
||
|
||
otherButtons.forEach(otherBtn => otherBtn.onclick = () => {
|
||
state.levels.other = !state.levels.other;
|
||
updateCounterVisibility();
|
||
refreshAllLogs();
|
||
// Добавляем refresh для обновления логов
|
||
if (state.current) {
|
||
refreshLogsAndCounters();
|
||
}
|
||
// Обновляем multi-view если он активен
|
||
if (state.multiViewMode) {
|
||
refreshAllLogs();
|
||
}
|
||
});
|
||
}
|
||
|
||
|
||
// Функция для добавления обработчиков мультивыбора проектов
|
||
function addMultiSelectHandlers() {
|
||
const display = document.getElementById('projectSelectDisplay');
|
||
const dropdown = document.getElementById('projectSelectDropdown');
|
||
|
||
console.log('Adding multi-select handlers, elements found:', {display: !!display, dropdown: !!dropdown});
|
||
|
||
if (display && dropdown) {
|
||
// Обработчик клика по дисплею
|
||
display.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
const isOpen = dropdown.style.display !== 'none';
|
||
|
||
if (isOpen) {
|
||
dropdown.style.display = 'none';
|
||
display.classList.remove('active');
|
||
} else {
|
||
dropdown.style.display = 'block';
|
||
display.classList.add('active');
|
||
}
|
||
});
|
||
|
||
// Обработчики кликов по опциям
|
||
dropdown.addEventListener('click', async (e) => {
|
||
e.stopPropagation();
|
||
|
||
const option = e.target.closest('.multi-select-option');
|
||
if (!option) return;
|
||
|
||
const checkbox = option.querySelector('input[type="checkbox"]');
|
||
if (!checkbox) return;
|
||
|
||
const value = option.getAttribute('data-value');
|
||
|
||
// Специальная логика для "All Projects"
|
||
if (value === 'all') {
|
||
if (checkbox.checked) {
|
||
// Если "All Projects" выбран, снимаем все остальные
|
||
dropdown.querySelectorAll('.multi-select-option input[type="checkbox"]').forEach(cb => {
|
||
if (cb !== checkbox) cb.checked = false;
|
||
});
|
||
}
|
||
} else {
|
||
// Если выбран конкретный проект, снимаем "All Projects"
|
||
const allCheckbox = dropdown.querySelector('#project-all');
|
||
if (allCheckbox) allCheckbox.checked = false;
|
||
}
|
||
|
||
// Обновляем отображение и загружаем сервисы
|
||
const selectedProjects = getSelectedProjects();
|
||
updateMultiSelect(selectedProjects);
|
||
await fetchServices();
|
||
});
|
||
|
||
// Закрытие dropdown при клике вне его
|
||
document.addEventListener('click', (e) => {
|
||
if (!display.contains(e.target) && !dropdown.contains(e.target)) {
|
||
dropdown.style.display = 'none';
|
||
display.classList.remove('active');
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// Функция для показа уведомления о горячих клавишах
|
||
function showHotkeysNotification() {
|
||
// Создаем уведомление
|
||
const notification = document.createElement('div');
|
||
notification.className = 'hotkeys-notification';
|
||
notification.innerHTML = `
|
||
<div class="notification-content">
|
||
<h4><i class="fas fa-keyboard"></i> Горячие клавиши</h4>
|
||
<ul>
|
||
<li><kbd>[</kbd> <kbd>]</kbd> - Навигация между контейнерами</li>
|
||
<li><kbd>Ctrl</kbd> + <kbd>R</kbd> или <kbd>Ctrl</kbd> + <kbd>K</kbd> - Обновить логи</li>
|
||
<li><kbd>Ctrl</kbd> + <kbd>B</kbd> - Свернуть/развернуть панель</li>
|
||
<li>Кнопка <i class="fas fa-chevron-left"></i> - управление панелью</li>
|
||
</ul>
|
||
<button class="notification-close" onclick="this.parentElement.parentElement.remove()">
|
||
<i class="fas fa-times"></i>
|
||
</button>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(notification);
|
||
|
||
// Автоматически скрываем через 8 секунд
|
||
setTimeout(() => {
|
||
if (notification.parentElement) {
|
||
notification.remove();
|
||
}
|
||
}, 8000);
|
||
}
|
||
|
||
// Функция для сворачивания/разворачивания sidebar и header
|
||
function toggleSidebar() {
|
||
if (els.sidebar) {
|
||
const isCollapsed = els.sidebar.classList.contains('collapsed');
|
||
|
||
if (isCollapsed) {
|
||
// Разворачиваем sidebar
|
||
els.sidebar.classList.remove('collapsed');
|
||
els.sidebarToggle.innerHTML = '<i class="fas fa-chevron-left"></i>';
|
||
els.sidebarToggle.title = 'Свернуть панель (Ctrl+B / Ctrl+И)';
|
||
localStorage.setItem('lb_sidebar_collapsed', 'false');
|
||
} else {
|
||
// Сворачиваем sidebar
|
||
els.sidebar.classList.add('collapsed');
|
||
els.sidebarToggle.innerHTML = '<i class="fas fa-chevron-right"></i>';
|
||
els.sidebarToggle.title = 'Развернуть панель (Ctrl+B / Ctrl+И)';
|
||
localStorage.setItem('lb_sidebar_collapsed', 'true');
|
||
}
|
||
|
||
// Принудительно обновляем стили логов после переключения sidebar
|
||
setTimeout(() => {
|
||
updateLogStyles();
|
||
|
||
// Дополнительная проверка для multi-view логов
|
||
if (state.multiViewMode) {
|
||
console.log('Sidebar toggle: Force fixing multi-view styles');
|
||
forceFixMultiViewStyles();
|
||
}
|
||
}, 100);
|
||
}
|
||
}
|
||
|
||
// Функция для принудительного исправления стилей multi-view логов
|
||
function forceFixMultiViewStyles() {
|
||
const multiViewLogs = document.querySelectorAll('.multi-view-content .multi-view-log');
|
||
console.log(`Force fixing styles for ${multiViewLogs.length} multi-view logs`);
|
||
|
||
multiViewLogs.forEach((log, index) => {
|
||
const containerId = log.getAttribute('data-container-id');
|
||
console.log(`Force fixing multi-view log ${index + 1} for container: ${containerId}`);
|
||
|
||
// Универсальное исправление для всех контейнеров
|
||
log.style.setProperty('height', '100%', 'important');
|
||
log.style.setProperty('overflow', 'auto', 'important');
|
||
log.style.setProperty('max-height', 'none', 'important');
|
||
log.style.setProperty('display', 'block', 'important');
|
||
log.style.setProperty('min-height', '200px', 'important');
|
||
log.style.setProperty('position', 'relative', 'important');
|
||
log.style.setProperty('flex', '1', 'important');
|
||
log.style.setProperty('min-height', '0', 'important');
|
||
log.style.setProperty('width', '100%', 'important');
|
||
log.style.setProperty('box-sizing', 'border-box', 'important');
|
||
|
||
// Принудительно вызываем пересчет layout
|
||
log.style.setProperty('transform', 'translateZ(0)', 'important');
|
||
|
||
// Устанавливаем универсальные inline стили для всех контейнеров
|
||
const currentStyle = log.getAttribute('style') || '';
|
||
const newStyle = currentStyle + '; height: 100% !important; overflow: auto !important; flex: 1 !important; min-height: 0 !important; width: 100% !important; box-sizing: border-box !important; display: block !important; position: relative !important;';
|
||
log.setAttribute('style', newStyle);
|
||
|
||
// Проверяем и исправляем родительские элементы для всех контейнеров
|
||
const parentContent = log.closest('.multi-view-content');
|
||
if (parentContent) {
|
||
parentContent.style.setProperty('display', 'flex', 'important');
|
||
parentContent.style.setProperty('flex-direction', 'column', 'important');
|
||
parentContent.style.setProperty('overflow', 'hidden', 'important');
|
||
parentContent.style.setProperty('height', '100%', 'important');
|
||
}
|
||
|
||
const parentPanel = log.closest('.multi-view-panel');
|
||
if (parentPanel) {
|
||
parentPanel.style.setProperty('display', 'flex', 'important');
|
||
parentPanel.style.setProperty('flex-direction', 'column', 'important');
|
||
parentPanel.style.setProperty('overflow', 'hidden', 'important');
|
||
parentPanel.style.setProperty('height', '100%', 'important');
|
||
}
|
||
});
|
||
|
||
// Также исправляем стили для multi-view-content контейнеров
|
||
const multiViewContents = document.querySelectorAll('.multi-view-content');
|
||
multiViewContents.forEach(content => {
|
||
content.style.setProperty('display', 'flex', 'important');
|
||
content.style.setProperty('flex-direction', 'column', 'important');
|
||
content.style.setProperty('overflow', 'hidden', 'important');
|
||
content.style.setProperty('height', '100%', 'important');
|
||
});
|
||
|
||
// Универсальное исправление для всех контейнеров
|
||
multiViewLogs.forEach(log => {
|
||
console.log(`Universal fix for container:`, log.getAttribute('data-container-id'));
|
||
|
||
// Принудительно устанавливаем все стили заново для всех контейнеров
|
||
log.style.cssText = 'height: 100% !important; overflow: auto !important; max-height: none !important; display: block !important; min-height: 200px !important; position: relative !important; flex: 1 !important; min-height: 0 !important; width: 100% !important; box-sizing: border-box !important;';
|
||
|
||
// Принудительно вызываем пересчет layout
|
||
log.style.setProperty('transform', 'translateZ(0)', 'important');
|
||
});
|
||
|
||
// Применяем настройки wrap text после исправления стилей
|
||
applyWrapSettings();
|
||
}
|
||
|
||
// Функция для обновления стилей логов
|
||
function updateLogStyles() {
|
||
const isCollapsed = els.sidebar && els.sidebar.classList.contains('collapsed');
|
||
|
||
// Обновляем стили для single-view логов
|
||
const singleViewLogs = document.querySelectorAll('.single-view-content .log');
|
||
singleViewLogs.forEach(log => {
|
||
if (isCollapsed) {
|
||
log.style.height = 'calc(100vh - var(--header-height))';
|
||
log.style.overflow = 'auto';
|
||
} else {
|
||
log.style.height = '100%';
|
||
log.style.overflow = 'auto';
|
||
}
|
||
});
|
||
|
||
// Обновляем стили для multi-view логов (более агрессивно)
|
||
const multiViewLogs = document.querySelectorAll('.multi-view-content .multi-view-log');
|
||
console.log(`Found ${multiViewLogs.length} multi-view logs to update`);
|
||
|
||
multiViewLogs.forEach((log, index) => {
|
||
const containerId = log.getAttribute('data-container-id');
|
||
console.log(`Updating multi-view log ${index + 1}/${multiViewLogs.length} for container: ${containerId}`);
|
||
|
||
// Принудительно устанавливаем правильные стили независимо от состояния sidebar
|
||
log.style.setProperty('height', '100%', 'important');
|
||
log.style.setProperty('overflow', 'auto', 'important');
|
||
log.style.setProperty('max-height', 'none', 'important');
|
||
log.style.setProperty('display', 'block', 'important');
|
||
log.style.setProperty('min-height', '200px', 'important');
|
||
log.style.setProperty('position', 'relative', 'important');
|
||
log.style.setProperty('flex', '1', 'important');
|
||
log.style.setProperty('min-height', '0', 'important');
|
||
|
||
// Принудительно вызываем пересчет layout
|
||
log.style.setProperty('transform', 'translateZ(0)', 'important');
|
||
});
|
||
|
||
// Также обновляем стили для multi-view-content контейнеров
|
||
const multiViewContents = document.querySelectorAll('.multi-view-content');
|
||
multiViewContents.forEach(content => {
|
||
if (isCollapsed) {
|
||
// В свернутом состоянии multi-view-content должен иметь правильную высоту
|
||
content.style.setProperty('height', 'calc(100vh - var(--header-height) - 60px)', 'important');
|
||
} else {
|
||
content.style.setProperty('height', '100%', 'important');
|
||
}
|
||
content.style.setProperty('overflow', 'hidden', 'important');
|
||
content.style.setProperty('display', 'flex', 'important');
|
||
content.style.setProperty('flex-direction', 'column', 'important');
|
||
});
|
||
|
||
// Применяем настройки wrap text
|
||
applyWrapSettings();
|
||
|
||
console.log('Log styles updated, sidebar collapsed:', isCollapsed, 'multi-view logs found:', multiViewLogs.length);
|
||
|
||
// Принудительно исправляем стили multi-view логов
|
||
forceFixMultiViewStyles();
|
||
|
||
// Дополнительная проверка через 500ms для multi view логов
|
||
if (multiViewLogs.length > 0) {
|
||
setTimeout(() => {
|
||
console.log('Performing delayed update for multi-view logs...');
|
||
forceFixMultiViewStyles();
|
||
}, 500);
|
||
}
|
||
}
|
||
|
||
// Mobile menu toggle
|
||
if (els.mobileToggle) {
|
||
els.mobileToggle.onclick = () => {
|
||
const sidebar = document.querySelector('.sidebar');
|
||
if (sidebar) {
|
||
sidebar.classList.toggle('open');
|
||
}
|
||
};
|
||
}
|
||
|
||
// Функция для показа/скрытия модального окна с горячими клавишами
|
||
function toggleHotkeysModal() {
|
||
if (els.hotkeysModal) {
|
||
const isVisible = els.hotkeysModal.classList.contains('show');
|
||
if (isVisible) {
|
||
els.hotkeysModal.classList.remove('show');
|
||
} else {
|
||
els.hotkeysModal.classList.add('show');
|
||
}
|
||
}
|
||
}
|
||
|
||
// Sidebar toggle button
|
||
if (els.sidebarToggle) {
|
||
els.sidebarToggle.onclick = toggleSidebar;
|
||
}
|
||
|
||
|
||
|
||
// Modal close button
|
||
if (els.hotkeysModalClose) {
|
||
els.hotkeysModalClose.onclick = toggleHotkeysModal;
|
||
}
|
||
|
||
// Close modal on background click
|
||
if (els.hotkeysModal) {
|
||
els.hotkeysModal.onclick = (e) => {
|
||
if (e.target === els.hotkeysModal) {
|
||
toggleHotkeysModal();
|
||
}
|
||
};
|
||
}
|
||
|
||
// Collapsible sections
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
// Обработчики для сворачивания секций
|
||
document.querySelectorAll('.control-header').forEach(header => {
|
||
header.addEventListener('click', (e) => {
|
||
if (e.target.closest('.collapse-btn')) return; // Не сворачиваем при клике на кнопку
|
||
|
||
const group = header.closest('.control-group');
|
||
|
||
// Если секция минимизирована, сначала разворачиваем
|
||
if (group.classList.contains('minimized')) {
|
||
group.classList.remove('minimized');
|
||
group.classList.add('collapsed');
|
||
const section = group.dataset.section;
|
||
localStorage.setItem(`lb_minimized_${section}`, 'false');
|
||
localStorage.setItem(`lb_collapsed_${section}`, 'true');
|
||
} else {
|
||
// Обычное сворачивание/разворачивание
|
||
group.classList.toggle('collapsed');
|
||
const section = group.dataset.section;
|
||
localStorage.setItem(`lb_collapsed_${section}`, group.classList.contains('collapsed'));
|
||
localStorage.setItem(`lb_minimized_${section}`, 'false');
|
||
}
|
||
});
|
||
});
|
||
|
||
// Обработчики для кнопок сворачивания
|
||
document.querySelectorAll('.collapse-btn').forEach(btn => {
|
||
btn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
const group = btn.closest('.control-group');
|
||
|
||
// Если секция минимизирована, сначала разворачиваем
|
||
if (group.classList.contains('minimized')) {
|
||
group.classList.remove('minimized');
|
||
group.classList.add('collapsed');
|
||
const section = group.dataset.section;
|
||
localStorage.setItem(`lb_minimized_${section}`, 'false');
|
||
localStorage.setItem(`lb_collapsed_${section}`, 'true');
|
||
} else {
|
||
// Обычное сворачивание/разворачивание
|
||
group.classList.toggle('collapsed');
|
||
const section = group.dataset.section;
|
||
localStorage.setItem(`lb_collapsed_${section}`, group.classList.contains('collapsed'));
|
||
localStorage.setItem(`lb_minimized_${section}`, 'false');
|
||
}
|
||
});
|
||
});
|
||
|
||
// Восстанавливаем состояние секций из localStorage или сворачиваем по умолчанию
|
||
document.querySelectorAll('.control-group.collapsible').forEach(group => {
|
||
const section = group.dataset.section;
|
||
const savedCollapsed = localStorage.getItem(`lb_collapsed_${section}`);
|
||
const savedMinimized = localStorage.getItem(`lb_minimized_${section}`);
|
||
|
||
// Если состояние не сохранено, сворачиваем по умолчанию
|
||
if (savedCollapsed === null && savedMinimized === null) {
|
||
group.classList.add('collapsed');
|
||
localStorage.setItem(`lb_collapsed_${section}`, 'true');
|
||
localStorage.setItem(`lb_minimized_${section}`, 'false');
|
||
} else if (savedMinimized === 'true') {
|
||
group.classList.add('minimized');
|
||
group.classList.remove('collapsed');
|
||
} else if (savedCollapsed === 'true') {
|
||
group.classList.add('collapsed');
|
||
group.classList.remove('minimized');
|
||
}
|
||
});
|
||
|
||
// Обработчик для кнопки Options
|
||
if (els.optionsBtn) {
|
||
els.optionsBtn.addEventListener('click', () => {
|
||
const sidebarControls = document.querySelector('.sidebar-controls');
|
||
const isHidden = sidebarControls.classList.contains('hidden');
|
||
|
||
if (isHidden) {
|
||
// Если сайдбар свернут, сначала разворачиваем его
|
||
if (els.sidebar.classList.contains('collapsed')) {
|
||
toggleSidebar();
|
||
}
|
||
// Показываем настройки
|
||
sidebarControls.classList.remove('hidden');
|
||
els.optionsBtn.classList.remove('active');
|
||
els.optionsBtn.title = 'Скрыть настройки';
|
||
localStorage.setItem('lb_options_hidden', 'false');
|
||
} else {
|
||
// Скрываем настройки
|
||
sidebarControls.classList.add('hidden');
|
||
els.optionsBtn.classList.add('active');
|
||
els.optionsBtn.title = 'Показать настройки';
|
||
localStorage.setItem('lb_options_hidden', 'true');
|
||
}
|
||
});
|
||
|
||
// Восстанавливаем состояние кнопки Options (по умолчанию скрыто)
|
||
const optionsHidden = localStorage.getItem('lb_options_hidden');
|
||
if (optionsHidden === null || optionsHidden === 'true') {
|
||
document.querySelector('.sidebar-controls').classList.add('hidden');
|
||
els.optionsBtn.classList.add('active');
|
||
els.optionsBtn.title = 'Показать настройки';
|
||
localStorage.setItem('lb_options_hidden', 'true');
|
||
}
|
||
|
||
// Инициализируем состояние кнопок уровней логирования
|
||
initializeLevelButtons();
|
||
}
|
||
|
||
// Обработчик для кнопки выхода
|
||
if (els.logoutBtn) {
|
||
els.logoutBtn.addEventListener('click', async () => {
|
||
if (confirm('Вы уверены, что хотите выйти?')) {
|
||
try {
|
||
// Вызываем API для выхода
|
||
await fetch('/api/auth/logout', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
|
||
}
|
||
});
|
||
} catch (error) {
|
||
console.error('Logout error:', error);
|
||
} finally {
|
||
// Останавливаем автоматическую проверку WebSocket
|
||
stopWebSocketStatusCheck();
|
||
// Очищаем localStorage
|
||
localStorage.removeItem('access_token');
|
||
// Перенаправляем на страницу входа
|
||
window.location.href = '/login';
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
|
||
|
||
// Инициализируем стили логов при загрузке страницы
|
||
updateLogStyles();
|
||
|
||
// Применяем настройки wrap text при загрузке
|
||
applyWrapSettings();
|
||
|
||
// Дополнительная проверка для multi-view логов при загрузке
|
||
setTimeout(() => {
|
||
if (state.multiViewMode) {
|
||
console.log('Initialization: Force fixing multi-view styles');
|
||
forceFixMultiViewStyles();
|
||
}
|
||
}, 1000);
|
||
|
||
// Обработчик для кнопки помощи
|
||
if (els.helpBtn) {
|
||
const helpTooltip = document.getElementById('helpTooltip');
|
||
let tooltipTimeout;
|
||
|
||
// Показ модального окна при клике
|
||
els.helpBtn.addEventListener('click', () => {
|
||
showHelpTooltip();
|
||
});
|
||
|
||
// Кнопка закрытия модального окна
|
||
const helpTooltipClose = document.getElementById('helpTooltipClose');
|
||
if (helpTooltipClose) {
|
||
helpTooltipClose.addEventListener('click', () => {
|
||
hideHelpTooltip();
|
||
});
|
||
}
|
||
|
||
// Закрытие по Escape
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') {
|
||
hideHelpTooltip();
|
||
}
|
||
});
|
||
|
||
// Закрытие по клику вне модального окна
|
||
helpTooltip.addEventListener('click', (e) => {
|
||
if (e.target === helpTooltip) {
|
||
hideHelpTooltip();
|
||
}
|
||
});
|
||
}
|
||
|
||
});
|
||
if (els.snapshotBtn) {
|
||
els.snapshotBtn.onclick = ()=>{
|
||
if (state.multiViewMode && state.selectedContainers.length > 0) {
|
||
// В Multi View режиме используем первый выбранный контейнер как ID для sendSnapshot
|
||
// Функция sendSnapshot сама определит, что нужно скачать логи всех контейнеров
|
||
sendSnapshot(state.selectedContainers[0]);
|
||
} else if (state.current) {
|
||
sendSnapshot(state.current.id);
|
||
} else {
|
||
alert('No container selected');
|
||
}
|
||
};
|
||
}
|
||
if (els.tail) {
|
||
els.tail.onchange = ()=> {
|
||
Object.keys(state.open).forEach(id=>{
|
||
const svc = state.services.find(s=> s.id===id);
|
||
if (!svc) return;
|
||
|
||
// В multi view режиме используем openMultiViewWs
|
||
if (state.multiViewMode && state.selectedContainers.includes(id)) {
|
||
console.log(`Refresh: Using openMultiViewWs for ${svc.name} in multi view mode`);
|
||
closeWs(id);
|
||
openMultiViewWs(svc);
|
||
} else {
|
||
// В обычном режиме используем openWs
|
||
const panel = els.grid.querySelector(`.panel[data-cid="${id}"]`);
|
||
if (!panel) return;
|
||
state.open[id].logEl.textContent='';
|
||
closeWs(id);
|
||
openWs(svc, panel);
|
||
}
|
||
});
|
||
|
||
// Обновляем современный интерфейс
|
||
if (state.current && els.logContent) {
|
||
els.logContent.textContent = 'Reconnecting...';
|
||
}
|
||
|
||
// Пересчитываем счетчики после изменения Tail Lines
|
||
setTimeout(() => {
|
||
if (state.multiViewMode) {
|
||
recalculateMultiViewCounters();
|
||
} else {
|
||
recalculateCounters();
|
||
}
|
||
}, 1000); // Небольшая задержка для завершения переподключения
|
||
};
|
||
}
|
||
if (els.wrapToggle) {
|
||
els.wrapToggle.onchange = ()=> {
|
||
applyWrapSettings();
|
||
};
|
||
}
|
||
|
||
// Добавляем обработчики для autoscroll и pause
|
||
if (els.autoscroll) {
|
||
els.autoscroll.onchange = ()=> {
|
||
// Обновляем настройку автопрокрутки для всех открытых логов
|
||
Object.keys(state.open).forEach(id => {
|
||
const obj = state.open[id];
|
||
if (obj && obj.wrapEl) {
|
||
if (els.autoscroll.checked) {
|
||
obj.wrapEl.scrollTop = obj.wrapEl.scrollHeight;
|
||
}
|
||
}
|
||
});
|
||
|
||
// Обновляем современный интерфейс
|
||
if (state.current && els.logContent) {
|
||
const logContent = document.querySelector('.log-content');
|
||
if (logContent && els.autoscroll.checked) {
|
||
logContent.scrollTop = logContent.scrollHeight;
|
||
}
|
||
}
|
||
|
||
// Обновляем мультипросмотр
|
||
if (state.multiViewMode) {
|
||
state.selectedContainers.forEach(containerId => {
|
||
const multiViewLog = document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`);
|
||
if (multiViewLog && els.autoscroll.checked) {
|
||
multiViewLog.scrollTop = multiViewLog.scrollHeight;
|
||
}
|
||
});
|
||
}
|
||
};
|
||
}
|
||
|
||
|
||
// Обработчик для фильтра (если элемент существует)
|
||
if (els.filter) {
|
||
els.filter.oninput = ()=> {
|
||
state.filter = els.filter.value.trim();
|
||
refreshAllLogs();
|
||
// Пересчитываем счетчики в зависимости от режима
|
||
setTimeout(() => {
|
||
if (state.multiViewMode) {
|
||
recalculateMultiViewCounters();
|
||
} else {
|
||
recalculateCounters();
|
||
}
|
||
}, 100);
|
||
};
|
||
}
|
||
// Обработчики для LogLevels (если элементы существуют)
|
||
if (els.lvlDebug) {
|
||
els.lvlDebug.onchange = ()=> {
|
||
state.levels.debug = els.lvlDebug.checked;
|
||
updateCounterVisibility();
|
||
refreshAllLogs();
|
||
// Обновляем multi-view если он активен
|
||
if (state.multiViewMode) {
|
||
refreshAllLogs();
|
||
setTimeout(() => {
|
||
recalculateMultiViewCounters();
|
||
}, 100);
|
||
} else {
|
||
// Пересчитываем счетчики для Single View
|
||
setTimeout(() => {
|
||
recalculateCounters();
|
||
}, 100);
|
||
}
|
||
};
|
||
}
|
||
if (els.lvlInfo) {
|
||
els.lvlInfo.onchange = ()=> {
|
||
state.levels.info = els.lvlInfo.checked;
|
||
updateCounterVisibility();
|
||
refreshAllLogs();
|
||
// Обновляем multi-view если он активен
|
||
if (state.multiViewMode) {
|
||
refreshAllLogs();
|
||
setTimeout(() => {
|
||
recalculateMultiViewCounters();
|
||
}, 100);
|
||
} else {
|
||
// Пересчитываем счетчики для Single View
|
||
setTimeout(() => {
|
||
recalculateCounters();
|
||
}, 100);
|
||
}
|
||
};
|
||
}
|
||
if (els.lvlWarn) {
|
||
els.lvlWarn.onchange = ()=> {
|
||
state.levels.warn = els.lvlWarn.checked;
|
||
updateCounterVisibility();
|
||
refreshAllLogs();
|
||
// Обновляем multi-view если он активен
|
||
if (state.multiViewMode) {
|
||
refreshAllLogs();
|
||
setTimeout(() => {
|
||
recalculateMultiViewCounters();
|
||
}, 100);
|
||
} else {
|
||
// Пересчитываем счетчики для Single View
|
||
setTimeout(() => {
|
||
recalculateCounters();
|
||
}, 100);
|
||
}
|
||
};
|
||
}
|
||
if (els.lvlErr) {
|
||
els.lvlErr.onchange = ()=> {
|
||
state.levels.err = els.lvlErr.checked;
|
||
updateCounterVisibility();
|
||
refreshAllLogs();
|
||
// Обновляем multi-view если он активен
|
||
if (state.multiViewMode) {
|
||
refreshAllLogs();
|
||
setTimeout(() => {
|
||
recalculateMultiViewCounters();
|
||
}, 100);
|
||
} else {
|
||
// Пересчитываем счетчики для Single View
|
||
setTimeout(() => {
|
||
recalculateCounters();
|
||
}, 100);
|
||
}
|
||
};
|
||
}
|
||
if (els.lvlOther) {
|
||
els.lvlOther.onchange = ()=> {
|
||
state.levels.other = els.lvlOther.checked;
|
||
updateCounterVisibility();
|
||
refreshAllLogs();
|
||
// Обновляем multi-view если он активен
|
||
if (state.multiViewMode) {
|
||
refreshAllLogs();
|
||
setTimeout(() => {
|
||
recalculateMultiViewCounters();
|
||
}, 100);
|
||
} else {
|
||
// Пересчитываем счетчики для Single View
|
||
setTimeout(() => {
|
||
recalculateCounters();
|
||
}, 100);
|
||
}
|
||
};
|
||
}
|
||
|
||
// Обработчик изменения размера окна для обновления стилей multi-view логов
|
||
window.addEventListener('resize', () => {
|
||
if (state.multiViewMode) {
|
||
console.log('Window resize: Force fixing multi-view styles');
|
||
setTimeout(() => {
|
||
forceFixMultiViewStyles();
|
||
|
||
// Дополнительно исправляем все контейнеры
|
||
console.log('Window resize: Fixing all containers');
|
||
if (window.fixAllContainers) {
|
||
window.fixAllContainers();
|
||
}
|
||
}, 100);
|
||
}
|
||
});
|
||
|
||
// Hotkeys: [ ] х ъ — navigation between containers, Ctrl/Cmd+R/K — refresh logs, Ctrl/Cmd+B/И — toggle sidebar
|
||
window.addEventListener('keydown', async (e)=>{
|
||
// Проверяем, не находится ли фокус в поле ввода
|
||
const activeElement = document.activeElement;
|
||
const isInputActive = activeElement && (
|
||
activeElement.tagName === 'INPUT' ||
|
||
activeElement.tagName === 'TEXTAREA' ||
|
||
activeElement.contentEditable === 'true'
|
||
);
|
||
|
||
// Если фокус в поле ввода, не обрабатываем горячие клавиши
|
||
if (isInputActive) {
|
||
return;
|
||
}
|
||
|
||
// Навигация между контейнерами по [ ] х ъ
|
||
if (e.key==='[' || e.key==='х'){
|
||
e.preventDefault();
|
||
const idx = state.services.findIndex(s=> s.id===state.current?.id);
|
||
if (idx>0) await switchToSingle(state.services[idx-1]);
|
||
}
|
||
if (e.key===']' || e.key==='ъ'){
|
||
e.preventDefault();
|
||
const idx = state.services.findIndex(s=> s.id===state.current?.id);
|
||
if (idx>=0 && idx<state.services.length-1) await switchToSingle(state.services[idx+1]);
|
||
}
|
||
|
||
// Обновление логов по Ctrl/Cmd+R или Ctrl/Cmd+K
|
||
if ((e.ctrlKey || e.metaKey) && (e.key==='r' || e.key==='к')){
|
||
e.preventDefault();
|
||
console.log('Hotkey refresh triggered:', e.key);
|
||
await refreshLogsAndCounters();
|
||
}
|
||
|
||
// Сворачивание/разворачивание sidebar по Ctrl/Cmd+B/И
|
||
if ((e.ctrlKey || e.metaKey) && (e.key==='b' || e.key==='и' || e.code==='KeyB')){
|
||
e.preventDefault();
|
||
toggleSidebar();
|
||
}
|
||
|
||
// Фокус на фильтр по Ctrl/Cmd+F
|
||
if ((e.ctrlKey || e.metaKey) && (e.key==='f' || e.key==='а')){
|
||
e.preventDefault();
|
||
console.log('Ctrl+F pressed, els.filter:', els.filter);
|
||
|
||
// Функция для фокусировки на фильтре
|
||
const focusFilter = () => {
|
||
// Сначала попробуем использовать els.filter
|
||
let filterElement = els.filter;
|
||
|
||
// Если els.filter не найден, попробуем найти элемент напрямую
|
||
if (!filterElement) {
|
||
console.log('els.filter not found, searching directly...');
|
||
filterElement = document.getElementById('filter');
|
||
}
|
||
|
||
// Если элемент найден, фокусируемся на нем
|
||
if (filterElement) {
|
||
console.log('Focusing on filter element:', filterElement);
|
||
try {
|
||
filterElement.focus();
|
||
filterElement.select();
|
||
console.log('Filter focused successfully');
|
||
} catch (error) {
|
||
console.error('Error focusing filter:', error);
|
||
}
|
||
} else {
|
||
console.error('Filter element not found anywhere!');
|
||
// Попробуем еще раз через небольшую задержку
|
||
setTimeout(() => {
|
||
const retryElement = document.getElementById('filter');
|
||
if (retryElement) {
|
||
console.log('Filter found on retry, focusing...');
|
||
retryElement.focus();
|
||
retryElement.select();
|
||
}
|
||
}, 100);
|
||
}
|
||
};
|
||
|
||
// Вызываем функцию фокусировки
|
||
focusFilter();
|
||
}
|
||
|
||
|
||
|
||
});
|
||
|
||
// Функция для переинициализации элементов
|
||
function reinitializeElements() {
|
||
// Переинициализируем элементы, которые могут быть не найдены при первой загрузке
|
||
els.filter = document.getElementById('filter');
|
||
els.containerList = document.getElementById('containerList');
|
||
els.logContent = document.getElementById('logContent');
|
||
els.mobileToggle = document.getElementById('mobileToggle');
|
||
els.optionsBtn = document.getElementById('optionsBtn');
|
||
els.helpBtn = document.getElementById('helpBtn');
|
||
els.logoutBtn = document.getElementById('logoutBtn');
|
||
els.sidebar = document.getElementById('sidebar');
|
||
els.sidebarToggle = document.getElementById('sidebarToggle');
|
||
els.header = document.getElementById('header');
|
||
|
||
console.log('Elements reinitialized:', {
|
||
filter: !!els.filter,
|
||
containerList: !!els.containerList,
|
||
logContent: !!els.logContent,
|
||
sidebar: !!els.sidebar,
|
||
sidebarToggle: !!els.sidebarToggle
|
||
});
|
||
}
|
||
|
||
// Инициализация
|
||
(async function init() {
|
||
console.log('Initializing LogBoard+...');
|
||
|
||
// Переинициализируем элементы
|
||
reinitializeElements();
|
||
|
||
// Инициализируем состояние WebSocket
|
||
setWsState('off');
|
||
|
||
// Дополнительно инициализируем элементы после полной загрузки DOM
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', reinitializeElements);
|
||
}
|
||
|
||
// Инициализируем элементы после полной загрузки страницы
|
||
window.addEventListener('load', reinitializeElements);
|
||
|
||
// Обработчик для правильной очистки при перезагрузке страницы
|
||
window.addEventListener('beforeunload', () => {
|
||
// Останавливаем автоматическую проверку WebSocket
|
||
stopWebSocketStatusCheck();
|
||
|
||
// Закрываем все WebSocket соединения
|
||
Object.keys(state.open).forEach(id => {
|
||
const obj = state.open[id];
|
||
if (obj && obj.ws) {
|
||
try {
|
||
obj.ws.close();
|
||
} catch (e) {
|
||
// Игнорируем ошибки при закрытии
|
||
}
|
||
}
|
||
});
|
||
|
||
// Очищаем состояние
|
||
state.open = {};
|
||
});
|
||
|
||
// Проверяем авторизацию
|
||
const token = localStorage.getItem('access_token');
|
||
if (!token) {
|
||
console.log('No access token found, redirecting to login');
|
||
window.location.href = '/login';
|
||
return;
|
||
}
|
||
|
||
// Проверяем валидность токена
|
||
try {
|
||
const response = await fetch('/api/auth/me', {
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`
|
||
}
|
||
});
|
||
|
||
if (!response.ok) {
|
||
console.log('Invalid token, redirecting to login');
|
||
localStorage.removeItem('access_token');
|
||
window.location.href = '/login';
|
||
return;
|
||
}
|
||
} catch (error) {
|
||
console.error('Error checking auth:', error);
|
||
localStorage.removeItem('access_token');
|
||
window.location.href = '/login';
|
||
return;
|
||
}
|
||
|
||
console.log('Elements found:', {
|
||
containerList: !!els.containerList,
|
||
|
||
logContent: !!els.logContent,
|
||
mobileToggle: !!els.mobileToggle,
|
||
themeSwitch: !!els.themeSwitch
|
||
});
|
||
|
||
// Проверяем header project select
|
||
const headerSelect = document.getElementById('projectSelectHeader');
|
||
console.log('Header project select found during init:', !!headerSelect);
|
||
|
||
await fetchProjects();
|
||
await fetchServices();
|
||
|
||
// Проверяем состояние WebSocket после загрузки сервисов
|
||
setTimeout(() => {
|
||
console.log('Проверка состояния WebSocket после загрузки сервисов');
|
||
setWsState(determineWsState());
|
||
}, 1000);
|
||
|
||
// Запускаем автоматическую проверку состояния WebSocket
|
||
startWebSocketStatusCheck();
|
||
|
||
// Добавляем обработчик клика для кнопки WebSocket статуса
|
||
if (els.wsstate) {
|
||
els.wsstate.addEventListener('click', () => {
|
||
console.log('Ручная проверка состояния WebSocket');
|
||
checkWebSocketStatus();
|
||
});
|
||
}
|
||
|
||
// Проверяем, есть ли сохраненный контейнер в localStorage
|
||
const savedContainerId = getSelectedContainerFromStorage();
|
||
if (savedContainerId) {
|
||
console.log('Found saved container, switching to it:', savedContainerId);
|
||
const savedService = state.services.find(s => s.id === savedContainerId);
|
||
if (savedService) {
|
||
// Добавляем контейнер в выбранные
|
||
state.selectedContainers = [savedContainerId];
|
||
// Переключаемся на сохраненный контейнер
|
||
await switchToSingle(savedService);
|
||
// Очищаем сохраненный контейнер из localStorage
|
||
saveSelectedContainer(null);
|
||
} else {
|
||
console.log('Saved container not found in services, clearing localStorage');
|
||
saveSelectedContainer(null);
|
||
}
|
||
}
|
||
|
||
// Инициализируем видимость счетчиков
|
||
updateCounterVisibility();
|
||
|
||
// Обновляем состояние чекбоксов после загрузки сервисов
|
||
updateContainerSelectionUI();
|
||
|
||
// Восстанавливаем состояние sidebar
|
||
const sidebarCollapsed = localStorage.getItem('lb_sidebar_collapsed');
|
||
if (sidebarCollapsed === 'true' && els.sidebar && els.sidebarToggle) {
|
||
els.sidebar.classList.add('collapsed');
|
||
els.sidebarToggle.innerHTML = '<i class="fas fa-chevron-right"></i>';
|
||
els.sidebarToggle.title = 'Развернуть панель (Ctrl+B / Ctrl+И)';
|
||
}
|
||
|
||
|
||
|
||
// Показываем подсказку о горячих клавишах при первом запуске
|
||
const hotkeysShown = localStorage.getItem('lb_hotkeys_shown');
|
||
if (!hotkeysShown) {
|
||
setTimeout(() => {
|
||
showHotkeysNotification();
|
||
localStorage.setItem('lb_hotkeys_shown', 'true');
|
||
}, 2000);
|
||
}
|
||
|
||
// Добавляем обработчики для счетчиков
|
||
addCounterClickHandlers();
|
||
|
||
// Добавляем обработчик для выпадающего списка проектов в заголовке
|
||
addMultiSelectHandlers();
|
||
|
||
// Загружаем и отображаем исключенные контейнеры
|
||
loadExcludedContainers().then(containers => {
|
||
renderExcludedContainers(containers);
|
||
});
|
||
|
||
// Добавляем обработчики для исключенных контейнеров
|
||
const addExcludedBtn = document.getElementById('addExcludedContainer');
|
||
const newExcludedInput = document.getElementById('newExcludedContainer');
|
||
|
||
if (addExcludedBtn) {
|
||
addExcludedBtn.onclick = addExcludedContainer;
|
||
}
|
||
|
||
if (newExcludedInput) {
|
||
newExcludedInput.onkeypress = (e) => {
|
||
if (e.key === 'Enter') {
|
||
addExcludedContainer();
|
||
}
|
||
};
|
||
}
|
||
|
||
// Добавляем обработчики для чекбоксов контейнеров
|
||
document.addEventListener('change', (e) => {
|
||
if (e.target.classList.contains('container-checkbox')) {
|
||
const containerId = e.target.getAttribute('data-container-id');
|
||
toggleContainerSelection(containerId);
|
||
}
|
||
|
||
// Обработчик изменения tail lines
|
||
if (e.target.id === 'tail') {
|
||
console.log('Tail lines changed to:', e.target.value);
|
||
if (state.multiViewMode) {
|
||
// В multi view применяем новое ограничение к уже отображаемым логам
|
||
const tailLines = parseInt(e.target.value) || 50;
|
||
console.log(`Applying tail lines limit ${tailLines} to ${state.selectedContainers.length} containers:`, state.selectedContainers);
|
||
|
||
// Проверяем все элементы multi-view-log на странице
|
||
const allMultiViewLogs = document.querySelectorAll('.multi-view-log');
|
||
console.log(`Found ${allMultiViewLogs.length} multi-view-log elements on page:`, Array.from(allMultiViewLogs).map(el => el.getAttribute('data-container-id')));
|
||
|
||
state.selectedContainers.forEach(containerId => {
|
||
console.log(`Processing container ${containerId}...`);
|
||
|
||
// Ищем элемент несколькими способами
|
||
let multiViewLog = document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`);
|
||
|
||
if (!multiViewLog) {
|
||
console.warn(`Container ${containerId} not found with data-container-id, trying alternative search...`);
|
||
// Попробуем найти по другому селектору
|
||
multiViewLog = document.querySelector(`[data-container-id="${containerId}"]`);
|
||
}
|
||
|
||
if (multiViewLog) {
|
||
console.log(`Found multi-view-log for container ${containerId}, current lines:`, multiViewLog.querySelectorAll('.line').length);
|
||
|
||
// Получаем все строки логов
|
||
const logLines = Array.from(multiViewLog.querySelectorAll('.line'));
|
||
console.log(`Container ${containerId}: ${logLines.length} log lines found`);
|
||
|
||
if (logLines.length > tailLines) {
|
||
// Удаляем лишние строки с начала
|
||
const linesToRemove = logLines.length - tailLines;
|
||
console.log(`Removing ${linesToRemove} lines from container ${containerId}`);
|
||
|
||
// Удаляем первые N строк
|
||
logLines.slice(0, linesToRemove).forEach(line => {
|
||
line.remove();
|
||
});
|
||
|
||
const remainingLines = multiViewLog.querySelectorAll('.line').length;
|
||
console.log(`Container ${containerId} now has ${remainingLines} lines after trimming`);
|
||
} else {
|
||
console.log(`Container ${containerId} has ${logLines.length} lines, no trimming needed (limit: ${tailLines})`);
|
||
}
|
||
} else {
|
||
console.error(`Multi-view log element not found for container ${containerId}`);
|
||
console.error(`Available multi-view-log elements:`, Array.from(document.querySelectorAll('.multi-view-log')).map(el => ({
|
||
containerId: el.getAttribute('data-container-id'),
|
||
className: el.className,
|
||
parent: el.parentElement?.className
|
||
})));
|
||
}
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
// Добавляем обработчики кликов на label чекбоксов
|
||
document.addEventListener('click', (e) => {
|
||
if (e.target.classList.contains('container-checkbox-label')) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const label = e.target;
|
||
const checkbox = label.previousElementSibling;
|
||
if (checkbox && checkbox.classList.contains('container-checkbox')) {
|
||
checkbox.checked = !checkbox.checked;
|
||
const containerId = checkbox.getAttribute('data-container-id');
|
||
toggleContainerSelection(containerId);
|
||
}
|
||
}
|
||
});
|
||
|
||
// Обработчики для кнопок уровней логирования в заголовках
|
||
document.addEventListener('click', (e) => {
|
||
if (e.target.closest('.level-btn')) {
|
||
const levelBtn = e.target.closest('.level-btn');
|
||
const level = levelBtn.getAttribute('data-level');
|
||
const containerId = levelBtn.getAttribute('data-container-id');
|
||
|
||
console.log(`Клик по кнопке уровня логирования: level=${level}, containerId=${containerId}, multiViewMode=${state.multiViewMode}`);
|
||
|
||
// Переключаем состояние кнопки
|
||
const isActive = levelBtn.classList.contains('active');
|
||
levelBtn.classList.toggle('active');
|
||
|
||
// Обновляем состояние уровней логирования
|
||
if (containerId) {
|
||
// Для multi-view: конкретный контейнер
|
||
if (!state.containerLevels) {
|
||
state.containerLevels = {};
|
||
}
|
||
if (!state.containerLevels[containerId]) {
|
||
state.containerLevels[containerId] = {debug: true, info: true, warn: true, err: true, other: true};
|
||
}
|
||
state.containerLevels[containerId][level] = !isActive;
|
||
|
||
// Сохраняем состояние кнопок loglevels в localStorage
|
||
saveLogLevelsState();
|
||
|
||
// Обновляем видимость логов только для этого контейнера
|
||
updateContainerLogVisibility(containerId);
|
||
|
||
// Пересчитываем счетчики только для этого контейнера
|
||
setTimeout(() => {
|
||
updateContainerCounters(containerId);
|
||
}, 100);
|
||
|
||
// Обновляем видимость логов для всех контейнеров в multi-view
|
||
// чтобы убедиться, что изменения применились только к нужному контейнеру
|
||
state.selectedContainers.forEach(id => {
|
||
if (id !== containerId) {
|
||
updateContainerLogVisibility(id);
|
||
}
|
||
});
|
||
} else {
|
||
// Для single-view: глобальные настройки
|
||
state.levels[level] = !isActive;
|
||
|
||
// Сохраняем состояние кнопок loglevels в localStorage
|
||
saveLogLevelsState();
|
||
|
||
// Обновляем видимость логов только для текущего контейнера
|
||
if (state.current) {
|
||
updateLogVisibility(els.logContent);
|
||
}
|
||
|
||
// Пересчитываем счетчики только для текущего контейнера
|
||
setTimeout(() => {
|
||
recalculateCounters();
|
||
}, 100);
|
||
}
|
||
}
|
||
});
|
||
|
||
// Добавляем тестовые функции в глобальную область для отладки
|
||
window.testDuplicateRemoval = testDuplicateRemoval;
|
||
window.testSingleViewDuplicateRemoval = testSingleViewDuplicateRemoval;
|
||
window.testSingleViewEmptyLinesRemoval = testSingleViewEmptyLinesRemoval;
|
||
window.testSingleViewLineBreaks = testSingleViewLineBreaks;
|
||
window.testMultiViewLineBreaks = testMultiViewLineBreaks;
|
||
window.testMultiViewSpecialReplacements = testMultiViewSpecialReplacements;
|
||
window.testFullMultiViewProcessing = testFullMultiViewProcessing;
|
||
window.quickTestINFO = quickTestINFO;
|
||
window.testRegex = testRegex;
|
||
window.checkMultiViewHTML = checkMultiViewHTML;
|
||
window.cleanMultiViewDuplicateLines = cleanMultiViewDuplicateLines;
|
||
window.cleanDuplicateLines = cleanDuplicateLines;
|
||
window.cleanSingleViewEmptyLines = cleanSingleViewEmptyLines;
|
||
|
||
// Добавляем функции для исправления стилей в глобальную область
|
||
window.forceFixMultiViewStyles = forceFixMultiViewStyles;
|
||
window.updateLogStyles = updateLogStyles;
|
||
|
||
// Универсальная функция для исправления всех контейнеров
|
||
window.fixAllContainers = function() {
|
||
console.log('Fixing all multi-view containers');
|
||
|
||
const allLogs = document.querySelectorAll('.multi-view-content .multi-view-log');
|
||
allLogs.forEach(log => {
|
||
const containerId = log.getAttribute('data-container-id');
|
||
console.log(`Fixing container:`, containerId);
|
||
|
||
// Принудительно устанавливаем все стили заново
|
||
log.style.cssText = 'height: 100% !important; overflow: auto !important; max-height: none !important; display: block !important; min-height: 200px !important; position: relative !important; flex: 1 !important; min-height: 0 !important; width: 100% !important; box-sizing: border-box !important;';
|
||
|
||
// Принудительно вызываем пересчет layout
|
||
log.style.setProperty('transform', 'translateZ(0)', 'important');
|
||
|
||
// Проверяем родительские элементы
|
||
const parentContent = log.closest('.multi-view-content');
|
||
if (parentContent) {
|
||
parentContent.style.cssText = 'display: flex !important; flex-direction: column !important; overflow: hidden !important; height: 100% !important;';
|
||
}
|
||
|
||
const parentPanel = log.closest('.multi-view-panel');
|
||
if (parentPanel) {
|
||
parentPanel.style.cssText = 'display: flex !important; flex-direction: column !important; overflow: hidden !important; height: 100% !important;';
|
||
}
|
||
});
|
||
|
||
// Применяем настройки wrap text после исправления всех контейнеров
|
||
applyWrapSettings();
|
||
};
|
||
|
||
// Оставляем старую функцию для обратной совместимости
|
||
window.fixProblematicContainers = function() {
|
||
console.log('fixProblematicContainers is deprecated, use fixAllContainers instead');
|
||
window.fixAllContainers();
|
||
};
|
||
|
||
console.log('LogBoard+ инициализирован с исправлениями дублирования строк и правильными переносами строк в Single View и MultiView режимах');
|
||
console.log('Для тестирования используйте: testDuplicateRemoval(), testSingleViewDuplicateRemoval(), testSingleViewEmptyLinesRemoval() или testSingleViewLineBreaks()');
|
||
|
||
// Запускаем первоначальную очистку пустых строк
|
||
setTimeout(() => {
|
||
if (!state.multiViewMode && els.logContent) {
|
||
cleanSingleViewEmptyLines(els.logContent);
|
||
cleanDuplicateLines(els.logContent);
|
||
}
|
||
}, 1000);
|
||
|
||
// Инициализируем видимость кнопок LogLevels
|
||
updateLogLevelsVisibility();
|
||
|
||
// ========================================
|
||
// AJAX ОБНОВЛЕНИЕ ЛОГОВ
|
||
// ========================================
|
||
|
||
// Глобальные переменные для AJAX обновления
|
||
let ajaxUpdateInterval = null;
|
||
let ajaxUpdateEnabled = true; // По умолчанию включен
|
||
let ajaxUpdateIntervalMs = 2000; // 2 секунды по умолчанию (будет переопределено из env)
|
||
|
||
// Состояние для каждого контейнера (для multi-view)
|
||
let containerStates = new Map(); // containerId -> {lastTimestamp, lastSecondCount}
|
||
|
||
/**
|
||
* Включить периодическое обновление логов через AJAX
|
||
* @param {number} intervalMs - Интервал обновления в миллисекундах
|
||
*/
|
||
function enableAjaxLogUpdate(intervalMs = null) {
|
||
if (ajaxUpdateInterval) {
|
||
clearInterval(ajaxUpdateInterval);
|
||
}
|
||
|
||
// Используем переданный интервал или значение по умолчанию
|
||
if (intervalMs === null) {
|
||
intervalMs = ajaxUpdateIntervalMs;
|
||
}
|
||
|
||
ajaxUpdateEnabled = true;
|
||
ajaxUpdateIntervalMs = intervalMs;
|
||
|
||
console.log(`AJAX обновление логов включено с интервалом ${intervalMs}ms`);
|
||
|
||
// Запускаем первое обновление сразу
|
||
performAjaxLogUpdate();
|
||
|
||
// Устанавливаем интервал
|
||
ajaxUpdateInterval = setInterval(performAjaxLogUpdate, intervalMs);
|
||
|
||
// Обновляем UI
|
||
updateAjaxUpdateCheckbox();
|
||
|
||
// Обновляем видимость кнопки refresh и состояние кнопки update
|
||
updateRefreshButtonVisibility();
|
||
}
|
||
|
||
/**
|
||
* Отключить периодическое обновление логов через AJAX
|
||
*/
|
||
function disableAjaxLogUpdate() {
|
||
if (ajaxUpdateInterval) {
|
||
clearInterval(ajaxUpdateInterval);
|
||
ajaxUpdateInterval = null;
|
||
}
|
||
|
||
ajaxUpdateEnabled = false;
|
||
console.log('AJAX обновление логов отключено');
|
||
|
||
// Обновляем UI
|
||
updateAjaxUpdateCheckbox();
|
||
|
||
// Обновляем видимость кнопки refresh и состояние кнопки update
|
||
updateRefreshButtonVisibility();
|
||
}
|
||
|
||
/**
|
||
* Переключить состояние AJAX обновления
|
||
*/
|
||
function toggleAjaxLogUpdate() {
|
||
console.log('toggleAjaxLogUpdate: Текущее состояние ajaxUpdateEnabled =', ajaxUpdateEnabled);
|
||
|
||
if (ajaxUpdateEnabled) {
|
||
console.log('toggleAjaxLogUpdate: Отключаем AJAX update');
|
||
disableAjaxLogUpdate();
|
||
} else {
|
||
console.log('toggleAjaxLogUpdate: Включаем AJAX update');
|
||
enableAjaxLogUpdate(ajaxUpdateIntervalMs);
|
||
}
|
||
|
||
console.log('toggleAjaxLogUpdate: Новое состояние ajaxUpdateEnabled =', ajaxUpdateEnabled);
|
||
|
||
// Обновляем видимость кнопки refresh и состояние кнопки update при переключении
|
||
updateRefreshButtonVisibility();
|
||
}
|
||
|
||
/**
|
||
* Выполнить обновление логов через AJAX
|
||
*/
|
||
async function performAjaxLogUpdate() {
|
||
if (!ajaxUpdateEnabled) {
|
||
return;
|
||
}
|
||
|
||
// Получаем значение tail, учитывая опцию "all"
|
||
let tailLines = els.tail.value;
|
||
if (tailLines === 'all') {
|
||
tailLines = 'all'; // Оставляем как строку для API
|
||
} else {
|
||
tailLines = parseInt(tailLines) || 50;
|
||
}
|
||
|
||
try {
|
||
const token = localStorage.getItem('access_token');
|
||
if (!token) {
|
||
console.error('AJAX Update: No access token found');
|
||
return;
|
||
}
|
||
|
||
// Определяем контейнеры для обновления
|
||
let containersToUpdate = [];
|
||
|
||
if (state.multiViewMode && state.selectedContainers.length > 0) {
|
||
// Multi-view режим: обновляем все выбранные контейнеры
|
||
containersToUpdate = state.selectedContainers;
|
||
} else if (state.current) {
|
||
// Single-view режим: обновляем текущий контейнер
|
||
containersToUpdate = [state.current.id];
|
||
} else {
|
||
console.log('AJAX Update: Нет контейнеров для обновления');
|
||
return;
|
||
}
|
||
|
||
console.log(`AJAX Update: Обновляем ${containersToUpdate.length} контейнеров:`, containersToUpdate);
|
||
|
||
// Обновляем каждый контейнер
|
||
for (const containerId of containersToUpdate) {
|
||
await updateContainerLogs(containerId, tailLines, token);
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('AJAX Update Error:', error);
|
||
// Не отключаем обновление при ошибке, просто логируем
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Обновить логи для конкретного контейнера
|
||
*/
|
||
async function updateContainerLogs(containerId, tailLines, token) {
|
||
try {
|
||
// Формируем URL с параметрами
|
||
const url = new URL(`/api/logs/${containerId}`, window.location.origin);
|
||
|
||
// Передаем tail параметр как строку (для поддержки "all")
|
||
url.searchParams.set('tail', String(tailLines));
|
||
|
||
// Получаем состояние контейнера
|
||
const containerState = containerStates.get(containerId) || { lastTimestamp: null, lastSecondCount: 0 };
|
||
|
||
// Если у нас есть временная метка последнего обновления, используем её
|
||
if (containerState.lastTimestamp) {
|
||
url.searchParams.set('since', containerState.lastTimestamp);
|
||
}
|
||
|
||
console.log(`AJAX Update: Запрашиваем логи для ${containerId} с tail=${tailLines}`);
|
||
|
||
// Формируем заголовки запроса
|
||
const headers = {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Cache-Control': 'no-cache'
|
||
};
|
||
const response = await fetch(url.toString(), { headers });
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.error) {
|
||
throw new Error(data.error);
|
||
}
|
||
|
||
// Клиентская дедупликация: пропускаем уже учтённые строки в ту же секунду
|
||
let newPortion = data.logs || [];
|
||
|
||
// Извлекаем секундную часть из timestamp ответа сервера
|
||
const serverTs = (data.timestamp || '').split('.')[0]; // отбрасываем миллисекунды
|
||
if (!containerState.lastTimestamp || serverTs !== containerState.lastTimestamp) {
|
||
// Новая секунда — сбрасываем счётчик
|
||
containerState.lastTimestamp = serverTs;
|
||
containerState.lastSecondCount = 0;
|
||
}
|
||
|
||
if (newPortion.length > 0) {
|
||
// Обрезаем уже учтённые строки в той же секунде
|
||
if (containerState.lastSecondCount > 0 && newPortion.length > containerState.lastSecondCount) {
|
||
newPortion = newPortion.slice(containerState.lastSecondCount);
|
||
} else if (containerState.lastSecondCount >= newPortion.length) {
|
||
newPortion = [];
|
||
}
|
||
|
||
if (newPortion.length > 0) {
|
||
console.log(`AJAX Update: К обработке ${newPortion.length} строк для ${containerId} (из ${data.logs.length}), lastSecondCount=${containerState.lastSecondCount}`);
|
||
appendNewLogsForContainer(containerId, newPortion);
|
||
containerState.lastSecondCount += newPortion.length;
|
||
} else {
|
||
console.log(`AJAX Update: Новых логов нет для ${containerId} после дедупликации по секундам`);
|
||
}
|
||
} else {
|
||
console.log(`AJAX Update: Логи не пришли для ${containerId}`);
|
||
}
|
||
|
||
// Обновляем состояние контейнера
|
||
containerStates.set(containerId, containerState);
|
||
|
||
} catch (error) {
|
||
console.error(`AJAX Update Error for ${containerId}:`, error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Добавить новые логи в конец существующих (универсальная функция для single и multi view)
|
||
* @param {string} containerId - ID контейнера
|
||
* @param {Array} newLogs - Массив новых логов
|
||
*/
|
||
function appendNewLogsForContainer(containerId, newLogs) {
|
||
const obj = state.open[containerId];
|
||
|
||
if (!obj) {
|
||
console.warn(`AJAX Update: Object not found for container ${containerId}`);
|
||
return;
|
||
}
|
||
|
||
// Обрабатываем каждую новую строку лога через handleLine
|
||
let addedCount = 0;
|
||
newLogs.forEach(log => {
|
||
const message = log.message || log.raw || '';
|
||
if (message.trim()) {
|
||
// Используем существующую функцию handleLine для правильной обработки
|
||
handleLine(containerId, message);
|
||
addedCount++;
|
||
}
|
||
});
|
||
|
||
// Прокручиваем к концу, если включена автопрокрутка
|
||
if (state.autoScroll) {
|
||
if (state.multiViewMode) {
|
||
// Для multi-view прокручиваем все контейнеры
|
||
const multiViewLog = document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`);
|
||
if (multiViewLog) {
|
||
multiViewLog.scrollTop = multiViewLog.scrollHeight;
|
||
}
|
||
} else if (els.logContent) {
|
||
// Для single-view прокручиваем основной контент
|
||
els.logContent.scrollTop = els.logContent.scrollHeight;
|
||
}
|
||
}
|
||
|
||
// Обновляем счетчики
|
||
if (state.multiViewMode) {
|
||
// Для multi-view обновляем счетчики конкретного контейнера
|
||
updateContainerCounters(containerId);
|
||
} else {
|
||
// Для single-view обновляем общие счетчики
|
||
recalculateCounters();
|
||
}
|
||
|
||
// Очищаем дублированные строки
|
||
if (state.multiViewMode) {
|
||
const multiViewLog = document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`);
|
||
if (multiViewLog) {
|
||
cleanMultiViewDuplicateLines(multiViewLog);
|
||
cleanMultiViewEmptyLines(multiViewLog);
|
||
}
|
||
} else {
|
||
cleanDuplicateLines(els.logContent);
|
||
cleanSingleViewEmptyLines(els.logContent);
|
||
}
|
||
|
||
console.log(`AJAX Update: Обработано ${addedCount} новых строк логов для ${containerId} через handleLine`);
|
||
}
|
||
|
||
/**
|
||
* Добавить новые логи в конец существующих (для обратной совместимости)
|
||
* @param {Array} newLogs - Массив новых логов
|
||
*/
|
||
function appendNewLogs(newLogs) {
|
||
if (!state.current || !els.logContent) {
|
||
return;
|
||
}
|
||
|
||
const containerId = state.current.id;
|
||
appendNewLogsForContainer(containerId, newLogs);
|
||
}
|
||
|
||
/**
|
||
* Обновить чекбокс AJAX обновления в UI
|
||
*/
|
||
function updateAjaxUpdateCheckbox() {
|
||
const checkbox = document.getElementById('autoupdate');
|
||
if (checkbox) {
|
||
checkbox.checked = ajaxUpdateEnabled;
|
||
}
|
||
|
||
// Обновляем видимость кнопки refresh в зависимости от состояния ajax autoupdate
|
||
updateRefreshButtonVisibility();
|
||
}
|
||
|
||
/**
|
||
* Обновить видимость кнопки refresh в header и состояние кнопки update
|
||
*/
|
||
function updateRefreshButtonVisibility() {
|
||
console.log('updateRefreshButtonVisibility: ajaxUpdateEnabled =', ajaxUpdateEnabled);
|
||
|
||
const refreshButtons = document.querySelectorAll('.log-refresh-btn');
|
||
console.log('updateRefreshButtonVisibility: Найдено кнопок refresh =', refreshButtons.length);
|
||
|
||
refreshButtons.forEach(btn => {
|
||
if (ajaxUpdateEnabled) {
|
||
// Если ajax autoupdate включен, скрываем кнопку refresh
|
||
btn.style.display = 'none';
|
||
console.log('updateRefreshButtonVisibility: Скрываем кнопку refresh');
|
||
} else {
|
||
// Если ajax autoupdate выключен, показываем кнопку refresh
|
||
btn.style.display = 'inline-flex';
|
||
console.log('updateRefreshButtonVisibility: Показываем кнопку refresh');
|
||
}
|
||
});
|
||
|
||
// Обновляем состояние кнопки update
|
||
console.log('updateRefreshButtonVisibility: Обновляем состояние кнопки update');
|
||
setAjaxUpdateState(ajaxUpdateEnabled);
|
||
}
|
||
|
||
/**
|
||
* Инициализировать чекбокс AJAX обновления
|
||
*/
|
||
function initAjaxUpdateCheckbox() {
|
||
const checkbox = document.getElementById('autoupdate');
|
||
if (!checkbox) {
|
||
console.error('AJAX Update Checkbox not found in HTML');
|
||
return;
|
||
}
|
||
|
||
// Настраиваем чекбокс
|
||
checkbox.title = 'Автоматическое обновление логов через AJAX';
|
||
|
||
// Добавляем обработчик изменения
|
||
checkbox.addEventListener('change', function() {
|
||
if (this.checked) {
|
||
enableAjaxLogUpdate();
|
||
} else {
|
||
disableAjaxLogUpdate();
|
||
}
|
||
|
||
// Обновляем видимость кнопки refresh и состояние кнопки update при изменении состояния чекбокса
|
||
updateRefreshButtonVisibility();
|
||
});
|
||
|
||
// Устанавливаем начальное состояние (включен по умолчанию)
|
||
checkbox.checked = true;
|
||
ajaxUpdateEnabled = true;
|
||
|
||
// Обновляем видимость кнопки refresh и состояние кнопки update при инициализации
|
||
updateRefreshButtonVisibility();
|
||
|
||
console.log('AJAX Update Checkbox initialized');
|
||
}
|
||
|
||
/**
|
||
* Инициализация AJAX обновления
|
||
*/
|
||
async function initAjaxUpdate() {
|
||
initAjaxUpdateCheckbox();
|
||
|
||
// Получаем настройки с сервера
|
||
try {
|
||
const token = localStorage.getItem('access_token');
|
||
if (token) {
|
||
const response = await fetch('/api/settings', {
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`
|
||
}
|
||
});
|
||
|
||
if (response.ok) {
|
||
const settings = await response.json();
|
||
ajaxUpdateIntervalMs = settings.ajax_update_interval || 2000;
|
||
console.log(`AJAX Update: Интервал обновления получен с сервера: ${ajaxUpdateIntervalMs}ms`);
|
||
} else {
|
||
console.warn('AJAX Update: Не удалось получить настройки с сервера, используем значение по умолчанию');
|
||
ajaxUpdateIntervalMs = 2000; // 2 секунды по умолчанию
|
||
}
|
||
} else {
|
||
console.warn('AJAX Update: Токен не найден, используем значение по умолчанию');
|
||
ajaxUpdateIntervalMs = 2000; // 2 секунды по умолчанию
|
||
}
|
||
} catch (error) {
|
||
console.error('AJAX Update: Ошибка получения настроек:', error);
|
||
ajaxUpdateIntervalMs = 2000; // 2 секунды по умолчанию
|
||
}
|
||
|
||
console.log(`AJAX Update: Интервал обновления установлен на ${ajaxUpdateIntervalMs}ms`);
|
||
|
||
// НЕ останавливаем AJAX обновление при смене контейнера
|
||
const originalSwitchToSingle = window.switchToSingle;
|
||
window.switchToSingle = function(containerId) {
|
||
// Очищаем состояние для всех контейнеров
|
||
containerStates.clear();
|
||
return originalSwitchToSingle.call(this, containerId);
|
||
};
|
||
|
||
// НЕ останавливаем AJAX обновление при переключении в multi-view
|
||
const originalSwitchToMultiView = window.switchToMultiView;
|
||
window.switchToMultiView = function() {
|
||
// Очищаем состояние для всех контейнеров
|
||
containerStates.clear();
|
||
return originalSwitchToMultiView.call(this);
|
||
};
|
||
|
||
console.log('AJAX обновление логов инициализировано');
|
||
|
||
// Обновляем видимость кнопки refresh и состояние кнопки update после инициализации
|
||
updateRefreshButtonVisibility();
|
||
}
|
||
|
||
// Запускаем инициализацию AJAX обновления
|
||
initAjaxUpdate().then(() => {
|
||
// Автоматически запускаем AJAX обновление (так как чекбокс включен по умолчанию)
|
||
setTimeout(() => {
|
||
if (ajaxUpdateEnabled) {
|
||
console.log('AJAX Update: Автоматический запуск обновления логов');
|
||
enableAjaxLogUpdate();
|
||
}
|
||
}, 1000); // Запускаем через 1 секунду после инициализации
|
||
});
|
||
|
||
// Экспортируем функции в глобальную область для отладки
|
||
window.enableAjaxLogUpdate = enableAjaxLogUpdate;
|
||
window.disableAjaxLogUpdate = disableAjaxLogUpdate;
|
||
window.toggleAjaxLogUpdate = toggleAjaxLogUpdate;
|
||
window.performAjaxLogUpdate = performAjaxLogUpdate;
|
||
window.updateContainerLogs = updateContainerLogs;
|
||
|
||
// Добавляем обработчик изменения выбранных контейнеров в multi-view
|
||
const originalToggleContainerSelection = window.toggleContainerSelection;
|
||
window.toggleContainerSelection = function(containerId) {
|
||
const result = originalToggleContainerSelection.call(this, containerId);
|
||
|
||
// Если AJAX обновление активно, очищаем состояние для измененных контейнеров
|
||
if (ajaxUpdateEnabled) {
|
||
// Очищаем состояние для всех контейнеров, чтобы избежать проблем с синхронизацией
|
||
containerStates.clear();
|
||
console.log('AJAX Update: Очищено состояние контейнеров после изменения выбора');
|
||
}
|
||
|
||
return result;
|
||
};
|
||
|
||
})();
|