diff --git a/Dockerfile b/Dockerfile index 7c42fdd..6db1ff4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,8 @@ COPY requirements.txt /app/requirements.txt RUN pip install --no-cache-dir -r requirements.txt COPY app.py /app/app.py -COPY templates /app/templates +COPY ./app/templates /app/templates +COPY ./app/static /app/static # Создаем пользователя и добавляем в группу docker RUN useradd -m appuser && \ diff --git a/app.py b/app.py index 0b75ca9..6e8dbe7 100644 --- a/app.py +++ b/app.py @@ -239,7 +239,7 @@ async def general_exception_handler(request: Request, exc: Exception): }, status_code=500) # Инициализация шаблонов -templates = Jinja2Templates(directory="templates") +templates = Jinja2Templates(directory="app/templates") # Инициализация безопасности security = HTTPBearer() @@ -253,6 +253,11 @@ SNAP_DIR = os.getenv("LOGBOARD_SNAPSHOT_DIR", "./snapshots") os.makedirs(SNAP_DIR, exist_ok=True) app.mount("/snapshots", StaticFiles(directory=SNAP_DIR), name="snapshots") +# serve static files directory +STATIC_DIR = os.getenv("LOGBOARD_STATIC_DIR", "./app/static") +os.makedirs(STATIC_DIR, exist_ok=True) +app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") + # Модели данных class UserLogin(BaseModel): username: str @@ -541,7 +546,7 @@ def list_containers(projects: Optional[List[str]] = None, include_stopped: bool return items # ---------- HTML ---------- -INDEX_PATH = os.getenv("LOGBOARD_INDEX_HTML", "./templates/index.html") +INDEX_PATH = os.getenv("LOGBOARD_INDEX_HTML", "./app/templates/index.html") def load_index_html() -> str: """Загружает HTML шаблон главной страницы""" with open(INDEX_PATH, "r", encoding="utf-8") as f: @@ -549,7 +554,7 @@ def load_index_html() -> str: def load_login_html() -> str: """Загружает HTML шаблон страницы входа""" - login_path = "./templates/login.html" + login_path = "./app/templates/login.html" with open(login_path, "r", encoding="utf-8") as f: return f.read() diff --git a/app/static/css/login.css b/app/static/css/login.css index 52e8d40..2a2cfd2 100644 --- a/app/static/css/login.css +++ b/app/static/css/login.css @@ -1,279 +1,280 @@ - /* THEME TOKENS */ - :root{ - --bg:#0e0f13; --panel:#151821; --muted:#8b94a8; --accent:#7aa2f7; --ok:#9ece6a; --warn:#e0af68; --err:#f7768e; --fg:#e5e9f0; - --border:#2a2f3a; --tab:#1b2030; --tab-active:#22283a; --chip:#2b3142; --link:#9ab8ff; - } - :root[data-theme="light"]{ - --bg:#f7f9fc; --panel:#ffffff; --muted:#667085; --accent:#3b82f6; --ok:#15803d; --warn:#b45309; --err:#b91c1c; --fg:#0f172a; - --border:#e5e7eb; --tab:#eef2ff; --tab-active:#dbeafe; --chip:#eef2f7; --link:#1d4ed8; - } +/* THEME TOKENS */ +:root{ + --bg:#0e0f13; --panel:#151821; --muted:#8b94a8; --accent:#7aa2f7; --ok:#9ece6a; --warn:#e0af68; --err:#f7768e; --fg:#e5e9f0; + --border:#2a2f3a; --tab:#1b2030; --tab-active:#22283a; --chip:#2b3142; --link:#9ab8ff; +} +:root[data-theme="light"]{ + --bg:#f7f9fc; --panel:#ffffff; --muted:#667085; --accent:#3b82f6; --ok:#15803d; --warn:#b45309; --err:#b91c1c; --fg:#0f172a; + --border:#e5e7eb; --tab:#eef2ff; --tab-active:#dbeafe; --chip:#eef2f7; --link:#1d4ed8; +} - *{box-sizing:border-box} - html,body{height:100%; margin: 0; padding: 0;} - body{background:var(--bg);color:var(--fg);font:13px/1.45 ui-monospace,Menlo,Consolas,monospace;} +*{box-sizing:border-box} +html,body{height:100%; margin: 0; padding: 0;} +body{background:var(--bg);color:var(--fg);font:13px/1.45 ui-monospace,Menlo,Consolas,monospace;} - .login-container { - display: flex; - justify-content: center; - align-items: center; - min-height: 100vh; - padding: 20px; - } +.login-container { + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + padding: 20px; +} - .login-card { - background: var(--panel); - border: 1px solid var(--border); - border-radius: 12px; - padding: 40px; - width: 100%; - max-width: 400px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); - } +.login-card { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 12px; + padding: 40px; + width: 100%; + max-width: 400px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); +} - .login-header { - text-align: center; - margin-bottom: 32px; - } +.login-header { + text-align: center; + margin-bottom: 32px; +} - .login-logo { - font-size: 32px; - color: var(--accent); - margin-bottom: 16px; - } +.login-logo { + font-size: 32px; + color: var(--accent); + margin-bottom: 16px; +} - .login-title { - font-size: 24px; - font-weight: 600; - color: var(--fg); - margin: 0 0 8px 0; - } +.login-title { + font-size: 24px; + font-weight: 600; + color: var(--fg); + margin: 0 0 8px 0; +} - .login-subtitle { - font-size: 14px; - color: var(--muted); - margin: 0; - } +.login-subtitle { + font-size: 14px; + color: var(--muted); + margin: 0; +} - .login-form { - display: flex; - flex-direction: column; - gap: 20px; - } +.login-form { + display: flex; + flex-direction: column; + gap: 20px; +} - .form-group { - display: flex; - flex-direction: column; - gap: 8px; - } +.form-group { + display: flex; + flex-direction: column; + gap: 8px; +} - .form-label { - font-size: 12px; - font-weight: 500; - color: var(--muted); - text-transform: uppercase; - letter-spacing: 0.5px; - } +.form-label { + font-size: 12px; + font-weight: 500; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.5px; +} - .form-input { - background: var(--chip); - border: 1px solid var(--border); - border-radius: 8px; - padding: 12px 16px; - font-size: 14px; - color: var(--fg); - transition: all 0.2s ease; - font-family: inherit; - } +.form-input { + background: var(--chip); + border: 1px solid var(--border); + border-radius: 8px; + padding: 12px 16px; + font-size: 14px; + color: var(--fg); + transition: all 0.2s ease; + font-family: inherit; +} - .form-input:focus { - outline: none; - border-color: var(--accent); - box-shadow: 0 0 0 3px rgba(122, 162, 247, 0.1); - } +.form-input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(122, 162, 247, 0.1); +} - .form-input::placeholder { - color: var(--muted); - } +.form-input::placeholder { + color: var(--muted); +} - .password-input-wrapper { - position: relative; - width: 100%; - } +.password-input-wrapper { + position: relative; + width: 100%; +} - .password-toggle { - position: absolute; - right: 8px; /* Ближе к краю для всех устройств */ - top: 50%; - transform: translateY(-50%); - background: none; - border: none; - color: var(--muted); - cursor: pointer; - padding: 6px; - border-radius: 4px; - transition: all 0.2s ease; - z-index: 10; - display: flex; - align-items: center; - justify-content: center; - min-width: 24px; - min-height: 24px; - } +.password-toggle { + position: absolute; + right: 8px; /* Ближе к краю для всех устройств */ + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + color: var(--muted); + cursor: pointer; + padding: 6px; + border-radius: 4px; + transition: all 0.2s ease; + z-index: 10; + display: flex; + align-items: center; + justify-content: center; + min-width: 24px; + min-height: 24px; +} - .password-toggle:hover { - color: var(--fg); - background: var(--chip); - } +.password-toggle:hover { + color: var(--fg); + background: var(--chip); +} - .password-toggle:active { - transform: translateY(-50%) scale(0.95); - } +.password-toggle:active { + transform: translateY(-50%) scale(0.95); +} - .password-input-wrapper .form-input { - padding-right: 40px; /* Место для кнопки */ - width: 100%; /* Поле на всю ширину */ - } +.password-input-wrapper .form-input { + padding-right: 40px; /* Место для кнопки */ + width: 100%; /* Поле на всю ширину */ +} - .login-button { - background: var(--accent); - color: #0b0d12; - border: none; - border-radius: 8px; - padding: 14px 24px; - font-size: 14px; - font-weight: 600; - cursor: pointer; - transition: all 0.2s ease; - font-family: inherit; - margin-top: 8px; - } +.login-button { + background: var(--accent); + color: #0b0d12; + border: none; + border-radius: 8px; + padding: 14px 24px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + font-family: inherit; + margin-top: 8px; +} - .login-button:hover { - background: #6b8fd8; - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(122, 162, 247, 0.3); - } +.login-button:hover { + background: #6b8fd8; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(122, 162, 247, 0.3); +} - .login-button:disabled { - background: var(--muted); - cursor: not-allowed; - transform: none; - box-shadow: none; - } +.login-button:disabled { + background: var(--muted); + cursor: not-allowed; + transform: none; + box-shadow: none; +} - .error-message { - background: rgba(247, 118, 142, 0.1); - border: 1px solid var(--err); - border-radius: 8px; - padding: 12px 16px; - font-size: 14px; - color: var(--err); - display: none; - } +.error-message { + background: rgba(247, 118, 142, 0.1); + border: 1px solid var(--err); + border-radius: 8px; + padding: 12px 16px; + font-size: 14px; + color: var(--err); + display: none; +} - .error-message.show { - display: block; - } +.error-message.show { + display: block; +} - .loading-spinner { - display: none; - width: 16px; - height: 16px; - border: 2px solid transparent; - border-top: 2px solid currentColor; - border-radius: 50%; - animation: spin 1s linear infinite; - margin-right: 8px; - } +.loading-spinner { + display: none; + width: 16px; + height: 16px; + border: 2px solid transparent; + border-top: 2px solid currentColor; + border-radius: 50%; + animation: spin 1s linear infinite; + margin-right: 8px; +} - .loading-spinner.show { - display: inline-block; - } +.loading-spinner.show { + display: inline-block; +} - @keyframes spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } - } +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} - .footer { - text-align: center; - margin-top: 32px; - padding-top: 24px; - border-top: 1px solid var(--border); - font-size: 12px; - color: var(--muted); - } +.footer { + text-align: center; + margin-top: 32px; + padding-top: 24px; + border-top: 1px solid var(--border); + font-size: 12px; + color: var(--muted); +} - .footer a { - color: var(--link); - text-decoration: none; - } +.footer a { + color: var(--link); + text-decoration: none; +} - .footer a:hover { - text-decoration: underline; - } +.footer a:hover { + text-decoration: underline; +} - /* Theme toggle */ - .theme-toggle { - position: fixed; - top: 20px; - right: 20px; - display: flex; - align-items: center; - gap: 8px; - font-size: 12px; - color: var(--muted); - background: var(--panel); - border: 1px solid var(--border); - border-radius: 8px; - padding: 8px 12px; - } +/* Theme toggle */ +.theme-toggle { + position: fixed; + top: 20px; + right: 20px; + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--muted); + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + padding: 8px 12px; +} - .theme-toggle input { - appearance: none; - width: 36px; - height: 20px; - border-radius: 999px; - position: relative; - background: var(--chip); - border: 1px solid var(--border); - cursor: pointer; - transition: background 0.2s ease; - } +.theme-toggle input { + appearance: none; + width: 36px; + height: 20px; + border-radius: 999px; + position: relative; + background: var(--chip); + border: 1px solid var(--border); + cursor: pointer; + transition: background 0.2s ease; +} - .theme-toggle input::after { - content: ""; - position: absolute; - top: 2px; - left: 2px; - width: 14px; - height: 14px; - border-radius: 50%; - background: var(--fg); - transition: transform 0.2s ease; - } +.theme-toggle input::after { + content: ""; + position: absolute; + top: 2px; + left: 2px; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--fg); + transition: transform 0.2s ease; +} - .theme-toggle input:checked::after { - transform: translateX(16px); - } +.theme-toggle input:checked::after { + transform: translateX(16px); +} - .theme-toggle input:checked { - background: var(--accent); - } +.theme-toggle input:checked { + background: var(--accent); +} - @media (max-width: 480px) { - .login-card { - padding: 24px; - margin: 16px; - } - - .login-title { - font-size: 20px; - } - - .theme-toggle { - top: 16px; - right: 16px; - padding: 6px 10px; - font-size: 11px; - } +@media (max-width: 480px) { + .login-card { + padding: 24px; + margin: 16px; + } + + .login-title { + font-size: 20px; + } + + .theme-toggle { + top: 16px; + right: 16px; + padding: 6px 10px; + font-size: 11px; + } +} \ No newline at end of file diff --git a/app/static/js/error.js b/app/static/js/error.js new file mode 100644 index 0000000..b64e70b --- /dev/null +++ b/app/static/js/error.js @@ -0,0 +1,35 @@ +// Theme toggle functionality +function toggleTheme() { + const html = document.documentElement; + const currentTheme = html.getAttribute('data-theme'); + const newTheme = currentTheme === 'light' ? 'dark' : 'light'; + + html.setAttribute('data-theme', newTheme); + localStorage.setItem('lb_theme', newTheme); + + // Update theme toggle button + const themeButton = document.querySelector('.theme-toggle'); + if (themeButton) { + themeButton.textContent = newTheme === 'light' ? '🌙' : '☀️'; + } +} + +// Initialize theme on page load +document.addEventListener('DOMContentLoaded', function() { + const savedTheme = localStorage.getItem('lb_theme') || 'dark'; + document.documentElement.setAttribute('data-theme', savedTheme); + + // Update theme toggle button + const themeButton = document.querySelector('.theme-toggle'); + if (themeButton) { + themeButton.textContent = savedTheme === 'light' ? '🌙' : '☀️'; + } +}); + +// Keyboard shortcut for theme toggle (Ctrl+T) +document.addEventListener('keydown', function(e) { + if (e.ctrlKey && e.key === 't') { + e.preventDefault(); + toggleTheme(); + } +}); diff --git a/app/static/js/index.js b/app/static/js/index.js new file mode 100644 index 0000000..6aa48a6 --- /dev/null +++ b/app/static/js/index.js @@ -0,0 +1,5685 @@ +console.log('LogBoard+ script loaded - VERSION 2'); + +const state = { + services: [], + current: null, + open: {}, // 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, // Режим мультипросмотра +}; + +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'), + ajaxUpdateBtn: document.getElementById('ajaxUpdateBtn'), + projectBadge: document.getElementById('projectBadge'), + + clearBtn: document.getElementById('clear'), + refreshBtn: document.getElementById('refresh'), + snapshotBtn: document.getElementById('snapshot'), + lvlDebug: document.getElementById('lvlDebug'), + lvlInfo: document.getElementById('lvlInfo'), + lvlWarn: document.getElementById('lvlWarn'), + lvlErr: document.getElementById('lvlErr'), + lvlOther: document.getElementById('lvlOther'), + 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'), +}; + +// ----- Theme toggle ----- +(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; + }); +})(); + +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'); + } +} + +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); +} +function escapeHtml(s){ return s.replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m])); } + +function classify(line){ + const l = line.toLowerCase(); + + // Проверяем различные форматы уровней логирования (более специфичные сначала) + + // DEBUG - ищем точное совпадение уровня логирования + if (/\s- DEBUG -|\s\[debug\]|level=debug|\bdebug\b(?=\s|$)/.test(l)) { + return 'dbg'; + } + + // ERROR - ищем точное совпадение уровня логирования + if (/\s- ERROR -|\s\[error\]|level=error/.test(l)) { + return 'err'; + } + + // WARNING - ищем точное совпадение уровня логирования + if (/\s- WARNING -|\s\[warn\]|level=warn/.test(l)) { + return 'warn'; + } + + // INFO - ищем точное совпадение уровня логирования + if (/\s- INFO -|\s\[info\]|level=info/.test(l)) { + return 'ok'; + } + + // Дополнительные проверки для других форматов (только если не найдены точные совпадения) + if (/\bdebug\b/i.test(l)) return 'dbg'; + if (/\berror\b/i.test(l)) return 'err'; + if (/\bwarning\b/i.test(l)) return 'warn'; + if (/\binfo\b/i.test(l)) return 'ok'; + + return 'other'; +} +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; +} + +// Функция для проверки уровня логирования для конкретного контейнера +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 +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(); +} +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 → HTML (SGR: 0/1/3/4, 30-37) + +// ----- Instance color & filters ----- +const inst = { colors: {}, filters: {}, palette: [ + '#7aa2f7','#9ece6a','#e0af68','#f7768e','#bb9af7','#7dcfff','#c0caf5','#f6bd60', + '#84cc16','#06b6d4','#fb923c','#ef4444','#22c55e','#a855f7' +]}; + +function idColor(id8){ + if (inst.colors[id8]) return inst.colors[id8]; + // simple hash to pick from palette + let h = 0; for (let i=0;i>>0; } + const color = inst.palette[h % inst.palette.length]; + inst.colors[id8] = color; + return color; +} + +function updateIdFiltersBar(){ + const bar = document.getElementById('idFilters'); + bar.innerHTML = ''; + const ids = Object.keys(inst.filters); + if (!ids.length){ bar.style.display='none'; return; } + bar.style.display='flex'; + ids.forEach(id8=>{ + const wrap = document.createElement('label'); + wrap.style.display='inline-flex'; wrap.style.alignItems='center'; wrap.style.gap='6px'; + const cb = document.createElement('input'); cb.type='checkbox'; cb.checked = inst.filters[id8] !== false; + cb.onchange = ()=> inst.filters[id8] = cb.checked; + const chip = document.createElement('span'); + chip.className='inst-tag'; + chip.style.borderColor = idColor(id8); + chip.style.color = idColor(id8); + chip.textContent = id8; + wrap.appendChild(cb); wrap.appendChild(chip); + bar.appendChild(wrap); + }); +} + +function shouldShowInstance(id8){ + if (!Object.keys(inst.filters).length) return true; + const val = inst.filters[id8]; + return val !== false; +} + +function parsePrefixAndStrip(line){ + // Accept "[id]" or "[id service]" prefixes from fan/fan_group + const m = line.match(/^\[([0-9a-f]{8})(?:\s+[^\]]+)?\]\s(.*)$/i); + if (!m) return null; + return {id8: m[1], rest: m[2]}; +} + +function ansiToHtml(text){ + const ESC = '\u001b['; + const parts = text.split(ESC); + if (parts.length === 1) return escapeHtml(text); + let html = escapeHtml(parts[0]); + let classes = []; + for (let i=1;i=30 && c<=37){ + classes = classes.filter(x=>!x.startsWith('ansi-')); + const map = {30:'black',31:'red',32:'green',33:'yellow',34:'blue',35:'magenta',36:'cyan',37:'white'}; + classes.push('ansi-'+map[c]); + } + } + if (classes.length) html += `` + escapeHtml(rest) + ``; + else html += escapeHtml(rest); + } + return html; +} + +function panelTemplate(svc){ + const div = document.createElement('div'); div.className='panel'; div.dataset.cid = svc.id; + div.innerHTML = ` +
+ ${escapeHtml((svc.project?`[${svc.project}] `:'')+(svc.service||svc.name))} + dbg:0 info:0 warn:0 err:0 +
+ + + +
+
+
`; + return div; +} + +function buildTabs(){ + // Legacy tabs (hidden) + els.tabs.innerHTML=''; + state.services.forEach(svc=>{ + const b = document.createElement('button'); + b.className='tab' + ((state.current && svc.id===state.current.id) ? ' active':''); + b.textContent = (svc.project?(`[${svc.project}] `):'') + (svc.service||svc.name); + b.title = `${svc.name} • ${svc.image} • ${svc.status}`; + b.onclick = async ()=> await switchToSingle(svc); + els.tabs.appendChild(b); + }); + + // Modern container list + els.containerList.innerHTML = ''; + + // Миникарточки контейнеров + const miniContainerList = document.getElementById('miniContainerList'); + if (miniContainerList) { + miniContainerList.innerHTML = ''; + } + + state.services.forEach(svc => { + // Создаем обычную карточку контейнера + const item = document.createElement('div'); + item.className = 'container-item'; + if (state.current && svc.id === state.current.id) { + item.classList.add('active'); + } + item.setAttribute('data-cid', svc.id); + + const statusClass = svc.status === 'running' ? 'running' : + svc.status === 'stopped' ? 'stopped' : 'paused'; + + item.innerHTML = ` +
+ + ${escapeHtml(svc.name)} +
+
+ ${escapeHtml(svc.service || svc.name)} + • ${escapeHtml(svc.project || 'standalone')} +
+
+ + ${escapeHtml(svc.status)} + ${svc.status === 'running' && svc.host_port ? ` on ${svc.host_port} port` : ''} + ${svc.url ? `` : ''} +
+
+ + +
+ `; + + // Устанавливаем состояние selected для контейнера + if (state.selectedContainers.includes(svc.id)) { + item.classList.add('selected'); + } + + item.onclick = async (e) => { + // Не переключаем контейнер, если кликнули на ссылку или чекбокс + if (e.target.closest('.container-link') || e.target.closest('.container-select')) { + e.stopPropagation(); + return; + } + await switchToSingle(svc); + }; + els.containerList.appendChild(item); + + // Создаем миникарточку контейнера + if (miniContainerList) { + const miniItem = document.createElement('div'); + miniItem.className = 'mini-container-item'; + if (state.current && svc.id === state.current.id) { + miniItem.classList.add('active'); + } + miniItem.setAttribute('data-cid', svc.id); + + // Сокращаем имя для миникарточки + const shortName = svc.name.length > 8 ? svc.name.substring(0, 6) + '..' : svc.name; + + miniItem.innerHTML = ` +
+ +
+
${escapeHtml(shortName)}
+
+ `; + + // Добавляем обработчики для всплывающих подсказок + miniItem.addEventListener('mouseenter', (e) => { + showMiniContainerTooltip(e, svc); + }); + + miniItem.addEventListener('mouseleave', () => { + // Добавляем задержку перед скрытием подсказки + const tooltip = document.getElementById('miniContainerTooltip'); + if (tooltip) { + tooltip.hideTimer = setTimeout(() => { + if (tooltip && !tooltip.matches(':hover')) { + hideMiniContainerTooltip(); + } + }, 150); + } + }); + + // Обработчик клика для миникарточек с поддержкой Shift+клик и Ctrl+клик + miniItem.addEventListener('click', async (e) => { + e.preventDefault(); + e.stopPropagation(); + + if (e.shiftKey && lastSelectedContainerId && lastSelectedContainerId !== svc.id) { + // Shift+клик с предыдущим выбором - диапазонный выбор + 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/projects'); + const token = localStorage.getItem('access_token'); + if (!token) { + console.error('No access token found'); + window.location.href = '/login'; + return; + } + + const res = await fetch(url, { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + if (!res.ok){ + if (res.status === 401) { + console.error('Unauthorized, redirecting to login'); + window.location.href = '/login'; + return; + } + console.error('Failed to fetch projects:', res.status, res.statusText); + return; + } + const projects = await res.json(); + console.log('Projects loaded:', projects); + + + + // Обновляем мультивыбор проектов в заголовке + const dropdown = document.getElementById('projectSelectDropdown'); + const display = document.getElementById('projectSelectDisplay'); + const displayText = display?.querySelector('.multi-select-text'); + + console.log('Multi-select elements found:', {dropdown: !!dropdown, display: !!display, displayText: !!displayText}); + + if (dropdown && displayText) { + // Очищаем dropdown + dropdown.innerHTML = ''; + + // Добавляем опцию "All Projects" + const allOption = document.createElement('div'); + allOption.className = 'multi-select-option'; + allOption.setAttribute('data-value', 'all'); + allOption.innerHTML = ` + + + `; + dropdown.appendChild(allOption); + + // Добавляем проекты + console.log('Adding projects to multi-select:', projects); + projects.forEach(project => { + const option = document.createElement('div'); + option.className = 'multi-select-option'; + option.setAttribute('data-value', project); + option.innerHTML = ` + + + `; + dropdown.appendChild(option); + }); + + // Восстанавливаем сохраненные выбранные проекты + const savedProjects = JSON.parse(localStorage.getItem('lb_selected_projects') || '["all"]'); + updateMultiSelect(savedProjects); + + console.log('Multi-select updated, current selection:', savedProjects); + } else { + console.error('Multi-select elements not found!'); + } + } catch (error) { + console.error('Error fetching projects:', error); + } +} + +// Функция для обновления мультивыбора проектов +function updateMultiSelect(selectedProjects) { + const dropdown = document.getElementById('projectSelectDropdown'); + const displayText = document.querySelector('.multi-select-text'); + + if (!dropdown || !displayText) return; + + // Фильтруем выбранные проекты, оставляя только те, которые есть в dropdown + const availableProjects = []; + dropdown.querySelectorAll('.multi-select-option').forEach(option => { + const value = option.getAttribute('data-value'); + availableProjects.push(value); + }); + + const filteredProjects = selectedProjects.filter(project => + project === 'all' || availableProjects.includes(project) + ); + + // Если все выбранные проекты исчезли, выбираем "All Projects" + if (filteredProjects.length === 0 || (filteredProjects.length === 1 && filteredProjects[0] === 'all')) { + filteredProjects.length = 0; + filteredProjects.push('all'); + } + + // Обновляем чекбоксы + dropdown.querySelectorAll('.multi-select-option').forEach(option => { + const value = option.getAttribute('data-value'); + const checkbox = option.querySelector('input[type="checkbox"]'); + if (checkbox) { + checkbox.checked = filteredProjects.includes(value); + } + }); + + // Обновляем текст отображения + if (filteredProjects.includes('all') || filteredProjects.length === 0) { + displayText.textContent = 'All Projects'; + } else if (filteredProjects.length === 1) { + displayText.textContent = filteredProjects[0]; + } else { + displayText.textContent = `${filteredProjects.length} Projects`; + } + + // Сохраняем в localStorage + localStorage.setItem('lb_selected_projects', JSON.stringify(filteredProjects)); +} + +// Функция для получения выбранных проектов +function getSelectedProjects() { + const dropdown = document.getElementById('projectSelectDropdown'); + if (!dropdown) return ['all']; + + const selectedProjects = []; + dropdown.querySelectorAll('.multi-select-option input[type="checkbox"]:checked').forEach(checkbox => { + const option = checkbox.closest('.multi-select-option'); + const value = option.getAttribute('data-value'); + selectedProjects.push(value); + }); + + return selectedProjects.length > 0 ? selectedProjects : ['all']; +} + +// Функции для работы с исключенными контейнерами +async function loadExcludedContainers() { + try { + const token = localStorage.getItem('access_token'); + if (!token) { + console.error('No access token found'); + return []; + } + + const response = await fetch('/api/excluded-containers', { + 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/excluded-containers', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify(containers) + }); + + if (!response.ok) { + if (response.status === 401) { + console.error('Unauthorized, redirecting to login'); + window.location.href = '/login'; + return false; + } + console.error('Ошибка сохранения исключенных контейнеров:', response.status); + return false; + } + + const data = await response.json(); + console.log('Исключенные контейнеры сохранены:', data); + return true; + } catch (error) { + console.error('Ошибка сохранения исключенных контейнеров:', error); + return false; + } +} + +function renderExcludedContainers(containers) { + const list = document.getElementById('excludedContainersList'); + if (!list) return; + + list.innerHTML = ''; + + if (containers.length === 0) { + list.innerHTML = '
Нет исключенных контейнеров
'; + return; + } + + containers.forEach(container => { + const item = document.createElement('div'); + item.className = 'excluded-container-item'; + item.innerHTML = ` + ${escapeHtml(container)} + + `; + list.appendChild(item); + }); +} + +async function addExcludedContainer() { + const input = document.getElementById('newExcludedContainer'); + const containerName = input.value.trim(); + + if (!containerName) { + alert('Введите имя контейнера'); + return; + } + + const currentContainers = await loadExcludedContainers(); + if (currentContainers.includes(containerName)) { + alert('Контейнер уже в списке исключенных'); + return; + } + + currentContainers.push(containerName); + const success = await saveExcludedContainers(currentContainers); + + if (success) { + renderExcludedContainers(currentContainers); + input.value = ''; + // Обновляем список проектов и контейнеров + await fetchProjects(); + await fetchServices(); + } else { + alert('Ошибка сохранения'); + } +} + +async function removeExcludedContainer(containerName) { + const currentContainers = await loadExcludedContainers(); + const updatedContainers = currentContainers.filter(name => name !== containerName); + + const success = await saveExcludedContainers(updatedContainers); + + if (success) { + renderExcludedContainers(updatedContainers); + // Обновляем список проектов и контейнеров + await fetchProjects(); + await fetchServices(); + } else { + alert('Ошибка удаления'); + } +} + +// Функции для мультивыбора контейнеров +function toggleContainerSelection(containerId) { + console.log('toggleContainerSelection called for:', containerId); + console.log('Current state.selectedContainers before:', [...state.selectedContainers]); + + const index = state.selectedContainers.indexOf(containerId); + if (index > -1) { + state.selectedContainers.splice(index, 1); + console.log('Removed container from selection:', containerId); + } else { + state.selectedContainers.push(containerId); + console.log('Added container to selection:', containerId); + } + + console.log('Current selected containers after:', state.selectedContainers); + + updateContainerSelectionUI(); + updateMultiViewMode(); +} + +function updateContainerSelectionUI() { + console.log('updateContainerSelectionUI called, selected containers:', state.selectedContainers); + + // Обновляем чекбоксы и обычные карточки контейнеров + const checkboxes = document.querySelectorAll('.container-checkbox'); + 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 = ` +
+ + Контейнер +
+
${escapeHtml(service.name)}
+
${escapeHtml(service.service || service.name)} • ${escapeHtml(service.project || 'standalone')}
+
+ + ${escapeHtml(service.status)} +
+ ${service.status === 'running' && service.host_port ? `
Порт: ${escapeHtml(service.host_port)}
` : ''} + ${service.url ? ` Открыть сайт` : ''} + + `; + + // Добавляем подсказку в body + document.body.appendChild(tooltip); + + // Позиционируем подсказку сразу + positionTooltip(event, tooltip); + + // Показываем подсказку сразу + tooltip.classList.add('show'); + + // Добавляем обработчики для подсказки + tooltip.addEventListener('mouseenter', () => { + // Останавливаем таймер скрытия при наведении на подсказку + clearTimeout(tooltip.hideTimer); + }); + + tooltip.addEventListener('mouseleave', () => { + // Скрываем подсказку при уходе курсора с неё + hideMiniContainerTooltip(); + }); + + // Добавляем обработчик для клика по ссылке + const link = tooltip.querySelector('.mini-container-tooltip-url'); + if (link) { + link.addEventListener('click', (e) => { + e.stopPropagation(); + // Ссылка откроется в новой вкладке благодаря target="_blank" + }); + } +} + +function hideMiniContainerTooltip() { + const tooltip = document.getElementById('miniContainerTooltip'); + if (tooltip) { + // Очищаем таймер скрытия + if (tooltip.hideTimer) { + clearTimeout(tooltip.hideTimer); + } + tooltip.remove(); + } +} + +function positionTooltip(event, tooltip) { + const rect = event.target.getBoundingClientRect(); + const tooltipRect = tooltip.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + // Определяем позицию по умолчанию (справа от миникарточки) + let position = 'right'; + let left = rect.right + 8; + + // Всегда центрируем по высоте относительно миникарточки + let top = rect.top + (rect.height / 2) - (tooltipRect.height / 2); + + // Проверяем, помещается ли подсказка справа + if (left + tooltipRect.width > viewportWidth - 10) { + // Не помещается справа, пробуем слева + position = 'left'; + left = rect.left - tooltipRect.width - 8; + } + + // Проверяем, помещается ли подсказка по вертикали + if (top < 10) { + // Не помещается сверху, выравниваем по верху с отступом + top = 10; + } else if (top + tooltipRect.height > viewportHeight - 10) { + // Не помещается снизу, выравниваем по низу с отступом + top = viewportHeight - tooltipRect.height - 10; + } + + // Применяем позицию сразу, без анимации + tooltip.className = `mini-container-tooltip ${position}`; + tooltip.style.left = `${left}px`; + tooltip.style.top = `${top}px`; + + // Принудительно применяем стили без анимации + tooltip.style.transition = 'none'; + // Сбрасываем transition после применения позиции + setTimeout(() => { + tooltip.style.transition = ''; + }, 10); +} + + + +// Функции для help modal +function showHelpTooltip() { + const helpTooltip = document.getElementById('helpTooltip'); + + if (!helpTooltip) return; + + // Показываем модальное окно + helpTooltip.classList.add('show'); + + // Блокируем скролл страницы + document.body.style.overflow = 'hidden'; +} + +function hideHelpTooltip() { + const helpTooltip = document.getElementById('helpTooltip'); + if (helpTooltip) { + helpTooltip.classList.remove('show'); + + // Восстанавливаем скролл страницы + document.body.style.overflow = ''; + } +} + +// Глобальные переменные для выбора контейнеров +let lastSelectedContainerId = null; + +// Функция для диапазонного выбора контейнеров +function selectContainerRange(startId, endId) { + console.log('selectContainerRange:', startId, 'to', endId); + + const miniContainerItems = document.querySelectorAll('.mini-container-item'); + const containerIds = Array.from(miniContainerItems).map(item => item.getAttribute('data-cid')); + + const startIndex = containerIds.indexOf(startId); + const endIndex = containerIds.indexOf(endId); + + if (startIndex === -1 || endIndex === -1) { + console.error('Container not found in range selection'); + return; + } + + const minIndex = Math.min(startIndex, endIndex); + const maxIndex = Math.max(startIndex, endIndex); + + // Выбираем все контейнеры в диапазоне + for (let i = minIndex; i <= maxIndex; i++) { + const containerId = containerIds[i]; + if (!state.selectedContainers.includes(containerId)) { + state.selectedContainers.push(containerId); + } + } + + // Обновляем UI + updateContainerSelectionUI(); + updateMultiViewMode(); +} + +// Глобальные обработчики для подсказок +document.addEventListener('click', (event) => { + const tooltip = document.getElementById('miniContainerTooltip'); + if (tooltip && !tooltip.contains(event.target) && !event.target.closest('.mini-container-item')) { + hideMiniContainerTooltip(); + } + + +}); + +window.addEventListener('resize', () => { + hideMiniContainerTooltip(); + hideHelpTooltip(); +}); + +window.addEventListener('scroll', () => { + hideMiniContainerTooltip(); + hideHelpTooltip(); +}); + +// Обработчик для скрытия подсказки при клике на ссылку +document.addEventListener('click', (event) => { + if (event.target.closest('.mini-container-tooltip-url')) { + // Не скрываем подсказку при клике на ссылку + event.stopPropagation(); + } +}); + +// Функция для сохранения выбранного контейнера в localStorage +function saveSelectedContainer(containerId) { + if (containerId) { + localStorage.setItem('lb_selected_container', containerId); + console.log('Saved selected container to localStorage:', containerId); + } else { + localStorage.removeItem('lb_selected_container'); + console.log('Removed selected container from localStorage'); + } +} + +// Функция для восстановления выбранного контейнера из localStorage +function getSelectedContainerFromStorage() { + const containerId = localStorage.getItem('lb_selected_container'); + console.log('Retrieved selected container from localStorage:', containerId); + return containerId; +} + +// Функция для сохранения режима просмотра в localStorage +function saveViewMode(multiViewMode, selectedContainers) { + const viewModeData = { + multiViewMode: multiViewMode, + selectedContainers: selectedContainers || [] + }; + localStorage.setItem('lb_view_mode', JSON.stringify(viewModeData)); + console.log('Saved view mode to localStorage:', viewModeData); +} + +// Функция для восстановления режима просмотра из localStorage +function getViewModeFromStorage() { + const viewModeData = localStorage.getItem('lb_view_mode'); + if (viewModeData) { + try { + const data = JSON.parse(viewModeData); + console.log('Retrieved view mode from localStorage:', data); + return data; + } catch (error) { + console.error('Error parsing view mode from localStorage:', error); + return null; + } + } + return null; +} + +// Функция для сохранения состояния кнопок loglevels в localStorage +function saveLogLevelsState() { + const levelsData = { + globalLevels: state.levels, + containerLevels: state.containerLevels + }; + localStorage.setItem('lb_log_levels', JSON.stringify(levelsData)); + console.log('Saved log levels state to localStorage:', levelsData); +} + +// Функция для восстановления состояния кнопок loglevels из localStorage +function getLogLevelsStateFromStorage() { + const levelsData = localStorage.getItem('lb_log_levels'); + if (levelsData) { + try { + const data = JSON.parse(levelsData); + console.log('Retrieved log levels state from localStorage:', data); + return data; + } catch (error) { + console.error('Error parsing log levels state from localStorage:', error); + return null; + } + } + return null; +} + +async function updateMultiViewMode() { + console.log(`updateMultiViewMode called: selectedContainers.length = ${state.selectedContainers.length}, containers:`, state.selectedContainers); + + if (state.selectedContainers.length > 1) { + state.multiViewMode = true; + state.current = null; // Сбрасываем текущий контейнер + console.log('Setting up multi-view mode'); + + // Сохраняем режим просмотра в localStorage + saveViewMode(true, state.selectedContainers); + + await setupMultiView(); + } else if (state.selectedContainers.length === 1) { + // Переключаемся в single view для одного контейнера + console.log('Switching from multi-view to single view'); + state.multiViewMode = false; + const selectedService = state.services.find(s => s.id === state.selectedContainers[0]); + if (selectedService) { + 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 = '
Выберите контейнер для просмотра логов
'; + } + } + + // Очищаем активное состояние всех контейнеров + updateActiveContainerUI(null); + } + + console.log(`Multi-view mode updated: multiViewMode = ${state.multiViewMode}`); + + // Сохраняем состояние кнопок loglevels при переключении режимов + saveLogLevelsState(); + + // Обновляем состояние кнопок уровней логирования при переключении режимов + setTimeout(() => { + initializeLevelButtons(); + }, 100); + + // Обновляем видимость кнопок LogLevels + updateLogLevelsVisibility(); +} + +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(); +} + +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 = ` +
+

${escapeHtml(service.name)}

+
+ + + + + +
+
+
+
+
+ `; + + // Проверяем, что элемент создался правильно + 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; +} + +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 { + 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 = ` +
+
+

No container selected

+
+
+
No container selected
+
+
+ `; + } + + const logTitle = document.getElementById('logTitle'); + if (logTitle) { + logTitle.textContent = 'LogBoard+'; + } + + // Обновляем видимость кнопок LogLevels + updateLogLevelsVisibility(); +} + + + +function applyWrapSettings() { + const wrapEnabled = els.wrapToggle && els.wrapToggle.checked; + const wrapStyle = wrapEnabled ? 'pre-wrap' : 'pre'; + + // Применяем к обычному просмотру (только к логам в основном контенте) + document.querySelectorAll('.main-content .log').forEach(el => { + el.style.whiteSpace = wrapStyle; + }); + + // Применяем к мультипросмотру + document.querySelectorAll('.multi-view-content .multi-view-log').forEach(el => { + el.style.whiteSpace = wrapStyle; + }); + + // Применяем к single-view + document.querySelectorAll('.single-view-content .log').forEach(el => { + el.style.whiteSpace = wrapStyle; + }); +} + +async function fetchServices(){ + try { + console.log('Fetching services...'); + const url = new URL(location.origin + '/api/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}/ws/fan/${encodeURIComponent(service)}?tail=${tail}&token=${token}${pj}`; + } + return `${proto}://${location.host}/ws/logs/${encodeURIComponent(containerId)}?tail=${tail}&token=${token}${sp}${pj}`; +} + +function closeWs(id){ + const o = state.open[id]; + if (!o) return; + + try { o.ws.close(); } catch(e){} + delete state.open[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/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/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(); + } +} + +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 { + 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 = ` + Первая строка + Вторая строка + Вторая строка + Третья строка + Третья строка + Четвертая строка + `; + + 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 = ` + INFO: 172.22.0.1:59030 - "GET /api/logs/stats/b816b3f326b9 HTTP/1.1" 200 OK + INFO: 172.22.0.1:61392 - "GET / HTTP/1.1" 401 Unauthorized + INFO: 172.22.0.1:61392 - "GET / HTTP/1.1" 401 Unauthorized + INFO: 172.22.0.1:61392 - "GET / HTTP/1.1" 200 OK + INFO: 172.22.0.1:61392 - "GET / HTTP/1.1" 200 OK + INFO: 172.22.0.1:61392 - "GET /api/projects HTTP/1.1" 200 OK + `; + + 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 = ` + INFO: 172.22.0.1:59030 - "GET /api/logs/stats/b816b3f326b9 HTTP/1.1" 200 OK + + + INFO: 172.22.0.1:61392 - "GET / HTTP/1.1" 401 Unauthorized + + INFO: 172.22.0.1:61392 - "GET / HTTP/1.1" 200 OK + + INFO: 172.22.0.1:61392 - "GET /api/projects HTTP/1.1" 200 OK + `; + + 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 = ` + INFO: 172.22.0.1:59030 - "GET /api/logs/stats/b816b3f326b9 HTTP/1.1" 200 OK\n + INFO: 172.22.0.1:61392 - "GET / HTTP/1.1" 401 Unauthorized\n + INFO: 172.22.0.1:61392 - "GET / HTTP/1.1" 200 OK\n + INFO: 172.22.0.1:61392 - "GET /api/projects HTTP/1.1" 200 OK\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 = ` + Первая строка лога + + + Вторая строка лога + + Третья строка лога + `; + + 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 ==='); +} + +// Глобальная функция для обработки логов +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 = `${ansiToHtml(normalizedLine)}`; + + // Сохраняем все логи в буфере (всегда) + if (!obj.allLogs) obj.allLogs = []; + // Для Single View сохраняем обработанную строку, для MultiView - оригинальную + const processedLine = !state.multiViewMode ? processSingleViewSpecialReplacements(normalizedLine) : normalizedLine; + const processedHtml = `${ansiToHtml(processedLine)}`; + obj.allLogs.push({html: processedHtml, line: processedLine, cls: cls}); + + // Ограничиваем размер буфера + if (obj.allLogs.length > 10000) { + obj.allLogs = obj.allLogs.slice(-5000); + } + + // Добавляем логи в отображение (обычный просмотр) - только если НЕ в multi-view режиме + if (shouldShow && obj.logEl && !state.multiViewMode) { + // Обрабатываем строку для Single View (без лишних переносов строк) + const singleViewProcessedLine = processSingleViewSpecialReplacements(normalizedLine); + + // Проверяем на дублирование в Single View логах + const existingLines = Array.from(obj.logEl.querySelectorAll('.line')); + const lastLine = existingLines[existingLines.length - 1]; + if (lastLine && lastLine.textContent === singleViewProcessedLine) { + console.log(`handleLine: Пропускаем дублированную строку для Single View контейнера ${id}:`, singleViewProcessedLine.substring(0, 50)); + return; // Пропускаем дублированную строку + } + + // Создаем HTML с обработанной строкой для Single View (без переноса строки) + const singleViewHtml = `${ansiToHtml(singleViewProcessedLine)}`; + + obj.logEl.insertAdjacentHTML('beforeend', singleViewHtml); + + // Очищаем лишние пустые строки в Single View + cleanSingleViewEmptyLines(obj.logEl); + + if (els.autoscroll && els.autoscroll.checked && obj.wrapEl) { + obj.wrapEl.scrollTop = obj.wrapEl.scrollHeight; + } + + // Update modern interface + 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 = `${ansiToHtml(processedLine)}`; + + // Добавляем новую строку + multiViewLog.insertAdjacentHTML('beforeend', multiViewHtml); + + // Очищаем пустые строки в multi view + cleanMultiViewEmptyLines(multiViewLog); + + // Очищаем дублированные строки в multi view + cleanMultiViewDuplicateLines(multiViewLog); + + // Ограничиваем количество отображаемых строк + const logLines = Array.from(multiViewLog.querySelectorAll('.line')); + if (logLines.length > tailLines) { + // Удаляем лишние строки с начала + const linesToRemove = logLines.length - tailLines; + 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; +} + +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 = ` +
+
+

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

+
+ + + + + +
+
+
+
Connecting...
+
+
+ `; + // Обновляем ссылки на элементы + els.singleViewPanel = document.getElementById('singleViewPanel'); + els.singleViewTitle = document.getElementById('singleViewTitle'); + els.logContent = document.getElementById('logContent'); + } + + // Удаляем мультипросмотр из DOM + const multiViewGrid = document.getElementById('multiViewGrid'); + if (multiViewGrid) { + console.log('Removing multi-view grid from DOM'); + multiViewGrid.remove(); + } 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 = 'Connecting...'; + } + + // Обновляем 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}/ws/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{ + 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 = ` +
+

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

+
    +
  • [ ] - Навигация между контейнерами
  • +
  • Ctrl + R или Ctrl + K - Обновить логи
  • +
  • Ctrl + B - Свернуть/развернуть панель
  • +
  • Кнопка - управление панелью
  • +
+ +
+ `; + + document.body.appendChild(notification); + + // Автоматически скрываем через 8 секунд + setTimeout(() => { + if (notification.parentElement) { + notification.remove(); + } + }, 8000); +} + +// Функция для сворачивания/разворачивания sidebar и header +function toggleSidebar() { + if (els.sidebar) { + const isCollapsed = els.sidebar.classList.contains('collapsed'); + + if (isCollapsed) { + // Разворачиваем sidebar + els.sidebar.classList.remove('collapsed'); + els.sidebarToggle.innerHTML = ''; + els.sidebarToggle.title = 'Свернуть панель (Ctrl+B / Ctrl+И)'; + localStorage.setItem('lb_sidebar_collapsed', 'false'); + } else { + // Сворачиваем sidebar + els.sidebar.classList.add('collapsed'); + els.sidebarToggle.innerHTML = ''; + els.sidebarToggle.title = 'Развернуть панель (Ctrl+B / Ctrl+И)'; + localStorage.setItem('lb_sidebar_collapsed', 'true'); + } + + // Принудительно обновляем стили логов после переключения sidebar + setTimeout(() => { + updateLogStyles(); + + // Дополнительная проверка для multi-view логов + if (state.multiViewMode) { + 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 { + // Сначала попробуем использовать 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 = ''; + 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; + }; + +})(); diff --git a/app/static/js/login.js b/app/static/js/login.js index 5051a3c..f1728cc 100644 --- a/app/static/js/login.js +++ b/app/static/js/login.js @@ -1,105 +1,106 @@ - flex-direction: column; - gap: 8px; - } +// Theme toggle +(function initTheme(){ + const saved = localStorage.lb_theme || 'dark'; + document.documentElement.setAttribute('data-theme', saved); + document.getElementById('themeSwitch').checked = (saved==='light'); + document.getElementById('themeSwitch').addEventListener('change', ()=>{ + const t = document.getElementById('themeSwitch').checked ? 'light' : 'dark'; + document.documentElement.setAttribute('data-theme', t); + localStorage.lb_theme = t; + }); +})(); - .form-label { - font-size: 12px; - font-weight: 500; - color: var(--muted); - text-transform: uppercase; - letter-spacing: 0.5px; - } +// Password toggle +document.getElementById('passwordToggle').addEventListener('click', function() { + const passwordInput = document.getElementById('password'); + const icon = this.querySelector('i'); + + if (passwordInput.type === 'password') { + passwordInput.type = 'text'; + icon.className = 'fas fa-eye-slash'; + } else { + passwordInput.type = 'password'; + icon.className = 'fas fa-eye'; + } +}); - .form-input { - background: var(--chip); - border: 1px solid var(--border); - border-radius: 8px; - padding: 12px 16px; - font-size: 14px; - color: var(--fg); - transition: all 0.2s ease; - font-family: inherit; +// Login form +document.getElementById('loginForm').addEventListener('submit', async function(e) { + e.preventDefault(); + + const username = document.getElementById('username').value.trim(); + const password = document.getElementById('password').value; + const loginButton = document.getElementById('loginButton'); + const buttonText = document.getElementById('buttonText'); + const loadingSpinner = document.getElementById('loadingSpinner'); + const errorMessage = document.getElementById('errorMessage'); + + // Validation + if (!username || !password) { + showError('Пожалуйста, заполните все поля'); + return; + } + + // Show loading state + loginButton.disabled = true; + buttonText.textContent = 'Вход...'; + loadingSpinner.classList.add('show'); + hideError(); + + try { + const response = await fetch('/api/auth/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + username: username, + password: password + }) + }); + + if (response.ok) { + const data = await response.json(); + + // Store token in localStorage + localStorage.setItem('access_token', data.access_token); + + // Redirect to main page + window.location.href = '/'; + } else { + const errorData = await response.json(); + showError(errorData.detail || 'Ошибка входа в систему'); } + } catch (error) { + console.error('Login error:', error); + showError('Ошибка соединения с сервером'); + } finally { + // Reset loading state + loginButton.disabled = false; + buttonText.textContent = 'Войти'; + loadingSpinner.classList.remove('show'); + } +}); - .form-input:focus { - outline: none; - border-color: var(--accent); - box-shadow: 0 0 0 3px rgba(122, 162, 247, 0.1); - } +function showError(message) { + const errorMessage = document.getElementById('errorMessage'); + errorMessage.textContent = message; + errorMessage.classList.add('show'); +} - .form-input::placeholder { - color: var(--muted); - } +function hideError() { + const errorMessage = document.getElementById('errorMessage'); + errorMessage.classList.remove('show'); +} - .password-input-wrapper { - position: relative; - width: 100%; - } +// Auto-focus on username field +document.addEventListener('DOMContentLoaded', function() { + document.getElementById('username').focus(); +}); - .password-toggle { - position: absolute; - right: 8px; /* Ближе к краю для всех устройств */ - top: 50%; - transform: translateY(-50%); - background: none; - border: none; - color: var(--muted); - cursor: pointer; - padding: 6px; - border-radius: 4px; - transition: all 0.2s ease; - z-index: 10; - display: flex; - align-items: center; - justify-content: center; - min-width: 24px; - min-height: 24px; - } - - .password-toggle:hover { - color: var(--fg); - background: var(--chip); - } - - .password-toggle:active { - transform: translateY(-50%) scale(0.95); - } - - .password-input-wrapper .form-input { - padding-right: 40px; /* Место для кнопки */ - width: 100%; /* Поле на всю ширину */ - } - - .login-button { - background: var(--accent); - color: #0b0d12; - border: none; - border-radius: 8px; - padding: 14px 24px; - font-size: 14px; - font-weight: 600; - cursor: pointer; - transition: all 0.2s ease; - font-family: inherit; - margin-top: 8px; - } - - .login-button:hover { - background: #6b8fd8; - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(122, 162, 247, 0.3); - } - - .login-button:disabled { - background: var(--muted); - cursor: not-allowed; - transform: none; - box-shadow: none; - } - - .error-message { - background: rgba(247, 118, 142, 0.1); - border: 1px solid var(--err); - border-radius: 8px; - padding: 12px 16px; - font-size: 14px; +// Handle Enter key in password field +document.getElementById('password').addEventListener('keypress', function(e) { + if (e.key === 'Enter') { + document.getElementById('loginForm').dispatchEvent(new Event('submit')); + } +}); diff --git a/app/templates/error.html b/app/templates/error.html index 4b0de72..b4199cd 100644 --- a/app/templates/error.html +++ b/app/templates/error.html @@ -4,220 +4,7 @@ {{ error_title }} - LogBoard+ - + @@ -277,50 +64,6 @@ - + diff --git a/app/templates/index.html b/app/templates/index.html index 5aad301..fea5b09 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -6,2593 +6,7 @@ LogBoard+ - +
@@ -2948,5695 +362,6 @@ footer{position:fixed;right:10px;bottom:10px;opacity:.6;font-size:11px}
© LogBoard+
- - -
@@ -8677,5 +402,6 @@ function reinitializeElements() {
+ diff --git a/app/templates/login.html b/app/templates/login.html index 6dd0b86..93939c1 100644 --- a/app/templates/login.html +++ b/app/templates/login.html @@ -5,288 +5,7 @@ LogBoard+ - Вход - +
@@ -349,114 +68,6 @@
- - + diff --git a/env.example b/env.example index 59a803c..21e096d 100644 --- a/env.example +++ b/env.example @@ -22,8 +22,11 @@ LOGBOARD_PASS=admin # Директория для сохранения снимков логов (путь внутри контейнера) LOGBOARD_SNAPSHOT_DIR=/app/snapshots +# Директория для статических файлов (CSS, JS, изображения) +LOGBOARD_STATIC_DIR=/app/static + # Путь к HTML шаблону главной страницы -LOGBOARD_INDEX_HTML=./templates/index.html +LOGBOARD_INDEX_HTML=./app/templates/index.html # Временная зона для временных меток в логах (например: Europe/Moscow, UTC) TZ_TS=Europe/Moscow