logboard/templates/index.html

6726 lines
237 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

<!doctype html>
<html lang="ru" data-theme="dark">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>LogBoard+</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<style>
/* 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;
--sidebar-width: 280px; --header-height: 60px;
}
: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; overflow: hidden;}
a{color:var(--link)}
/* Modern Layout */
.app-container {
display: flex;
height: 100vh;
overflow: hidden;
}
/* Sidebar */
.sidebar {
width: var(--sidebar-width);
background: var(--panel);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
transition: width 0.3s ease, transform 0.3s ease;
}
.sidebar.collapsed {
width: 60px;
}
.sidebar.collapsed .sidebar-header h1,
.sidebar.collapsed .sidebar-header .subtitle,
.sidebar.collapsed .sidebar-controls,
.sidebar.collapsed .container-list {
display: none;
}
.sidebar.collapsed .sidebar-header {
display: flex;
align-items: center;
justify-content: center;
padding: 10px;
}
.sidebar.collapsed .header-buttons {
flex-direction: column;
gap: 4px;
align-items: center;
justify-content: center;
width: 100%;
}
/* Логотип в свернутом состоянии */
.sidebar.collapsed .sidebar-logo {
display: flex;
align-items: center;
justify-content: center;
height: 40px;
font-size: 18px;
color: var(--accent);
font-weight: bold;
margin-bottom: 10px;
border-bottom: 1px solid var(--border);
}
.sidebar:not(.collapsed) .sidebar-logo {
display: none;
}
/* Сворачивание header вместе с sidebar */
.sidebar.collapsed ~ .main-content .header {
height: 40px;
padding: 0 20px;
display: flex;
align-items: center;
gap: 10px;
}
.sidebar.collapsed ~ .main-content .header .header-title,
.sidebar.collapsed ~ .main-content .header .header-badge,
.sidebar.collapsed ~ .main-content .header .mobile-toggle {
display: none;
}
.sidebar.collapsed ~ .main-content .header .header-filter {
flex: 1;
max-width: none;
margin: 0 8px;
min-width: 150px;
}
.sidebar.collapsed ~ .main-content .header .header-controls { display: flex; align-items: center; gap: 8px; margin-left: auto; }
/* Скрыть кнопки loglevels в компактном header */
.sidebar.collapsed ~ .main-content .header .header-compact-controls { display: none; }
/* Скрыть весь log-header */
.log-header { display: none; }
/* Минимальный padding для log-content в свернутом состоянии */
.sidebar.collapsed ~ .main-content .log-content {
padding: 0;
}
/* Обеспечиваем правильное отображение логов при свернутом sidebar */
.sidebar.collapsed ~ .main-content .log-area {
height: calc(100vh - var(--header-height)) !important;
overflow: hidden !important;
}
.sidebar.collapsed ~ .main-content .log-content {
overflow: visible !important;
height: 100% !important;
padding: 0 !important;
}
.sidebar.collapsed ~ .main-content .single-view-content .log {
height: calc(100vh - var(--header-height)) !important;
overflow: auto !important;
display: block !important;
max-height: none !important;
min-height: 200px !important;
position: relative !important;
}
/* Исправляем скролл для multi-view в свернутом состоянии */
.sidebar.collapsed ~ .main-content .multi-view-content {
height: calc(100vh - var(--header-height) - 60px) !important;
overflow: hidden !important;
}
.sidebar.collapsed ~ .main-content .multi-view-log {
height: 100% !important;
overflow: auto !important;
display: block !important;
max-height: none !important;
min-height: 200px !important;
position: relative !important;
}
/* Обеспечиваем правильное отображение логов при развернутом sidebar */
.sidebar:not(.collapsed) ~ .main-content .single-view-content .log,
.sidebar:not(.collapsed) ~ .main-content .multi-view-log {
height: 100% !important;
overflow: auto !important;
display: block !important;
min-height: 200px !important;
position: relative !important;
}
/* Стили для multi-view-content контейнеров */
.sidebar.collapsed ~ .main-content .multi-view-content {
height: calc(100vh - var(--header-height) - 60px) !important;
overflow: hidden !important;
display: flex !important;
flex-direction: column !important;
}
.sidebar:not(.collapsed) ~ .main-content .multi-view-content {
height: 100% !important;
overflow: hidden !important;
display: flex !important;
flex-direction: column !important;
}
/* Дополнительные стили для multi-view-log в свернутом состоянии */
.sidebar.collapsed ~ .main-content .multi-view-log {
height: 100% !important;
overflow: auto !important;
display: block !important;
max-height: none !important;
min-height: 200px !important;
position: relative !important;
flex: 1 !important;
min-height: 0 !important;
}
/* Более специфичные правила для multi-view логов */
.sidebar.collapsed ~ .main-content .multi-view-content .multi-view-log {
height: 100% !important;
overflow: auto !important;
display: block !important;
max-height: none !important;
min-height: 200px !important;
position: relative !important;
flex: 1 !important;
min-height: 0 !important;
}
.sidebar.collapsed ~ .main-content .multi-view-grid .multi-view-panel .multi-view-content .multi-view-log {
height: 100% !important;
overflow: auto !important;
display: block !important;
max-height: none !important;
min-height: 200px !important;
position: relative !important;
flex: 1 !important;
min-height: 0 !important;
}
/* Multi-view panel для Single View режима */
.multi-view-panel {
display: none;
background: var(--panel);
border-bottom: 1px solid var(--border);
padding: 8px 16px;
font-size: 14px;
font-weight: 500;
color: var(--accent);
}
.sidebar.collapsed ~ .main-content .multi-view-panel {
display: block;
}
/* Дополнительные стили для обеспечения правильного скролла в multi-view */
.multi-view-grid {
display: grid;
gap: 2px;
height: 100%;
padding: 0px;
/* Равная высота строк для нескольких рядов (3+ окон) */
grid-auto-rows: 1fr;
align-items: stretch;
}
.multi-view-panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 2px;
height: 100%;
}
.multi-view-content {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.multi-view-log {
height: 100%;
margin: 0;
padding: 12px;
font-size: 11px;
line-height: 1.4;
white-space: pre;
word-break: break-word;
overflow: auto;
background: var(--bg);
color: var(--fg);
font-family: ui-monospace, Menlo, Consolas, monospace;
flex: 1;
min-height: 0;
}
/* Принудительный сброс стилей для multi-view логов */
.multi-view-log {
height: 100% !important;
overflow: auto !important;
display: block !important;
max-height: none !important;
min-height: 200px !important;
position: relative !important;
flex: 1 !important;
min-height: 0 !important;
}
/* Универсальные стили для всех multi-view логов */
.multi-view-content .multi-view-log {
height: 100% !important;
overflow: auto !important;
display: block !important;
max-height: none !important;
min-height: 200px !important;
position: relative !important;
flex: 1 !important;
min-height: 0 !important;
width: 100% !important;
box-sizing: border-box !important;
}
/* Универсальные стили для всех multi-view логов в свернутом состоянии */
.sidebar.collapsed ~ .main-content .multi-view-content .multi-view-log {
height: 100% !important;
overflow: auto !important;
display: block !important;
max-height: none !important;
min-height: 200px !important;
position: relative !important;
flex: 1 !important;
min-height: 0 !important;
width: 100% !important;
box-sizing: border-box !important;
}
/* Всегда скрываем одиночный блок multi-view панели в Single View */
#multiViewPanel {
display: none !important;
}
/* Приводим Single View лог к виду multi-view-log */
.log-area .log-content .log {
height: 100%;
margin: 0;
padding: 12px;
font-size: 11px;
line-height: 1.4;
white-space: pre; /* по умолчанию как у multi-view; переключается applyWrapSettings() */
word-break: break-word;
overflow: auto;
background: var(--bg);
color: var(--fg);
font-family: ui-monospace, Menlo, Consolas, monospace;
}
.sidebar-toggle {
position: fixed;
top: 50vh;
left: var(--sidebar-width);
transform: translate(-50%, -50%);
width: 30px;
height: 30px;
background: var(--accent);
border: 2px solid var(--panel);
border-radius: 50%;
color: #0b0d12;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
z-index: 1001;
transition: all 0.2s ease;
}
.sidebar.collapsed + .sidebar-toggle {
left: 60px;
}
.sidebar-toggle:hover {
background: var(--ok);
}
.sidebar-header {
padding: 20px;
border-bottom: 1px solid var(--border);
background: var(--panel);
}
.header-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0px;
}
.header-buttons {
display: flex;
gap: 8px;
align-items: center;
}
.sidebar-header h1 {
font-size: 18px;
margin: 0;
color: var(--accent);
font-weight: 600;
}
.sidebar-header .subtitle {
font-size: 12px;
color: var(--muted);
margin: 0;
}
.options-btn,
.help-btn,
.logout-btn {
background: var(--chip);
border: 1px solid var(--border);
color: var(--muted);
padding: 6px 8px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
/* Help button: visible only when sidebar is collapsed */
.sidebar:not(.collapsed) .help-btn { display: none; }
.sidebar.collapsed .help-btn { display: flex; }
/* Компактные контролы в header: по умолчанию скрыты */
.header-compact-controls { display: none; align-items: center; gap: 6px; }
.options-btn:hover,
.help-btn:hover,
.logout-btn:hover {
background: var(--tab-active);
color: var(--fg);
border-color: var(--accent);
}
/* Кнопка options когда меню открыто (неактивное состояние) */
.options-btn:not(.active) {
background: var(--accent);
color: #0b0d12;
border-color: var(--accent);
}
.options-btn.active {
background: var(--chip);
color: var(--muted);
border-color: var(--border);
}
.logout-btn:hover {
background: var(--err);
color: #fff;
border-color: var(--err);
}
.options-btn i,
.logout-btn i {
font-size: 12px;
}
/* Кнопка состояния WebSocket */
.ws-status-btn {
background: var(--chip);
color: var(--muted);
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 12px;
font-size: 11px;
font-weight: 500;
cursor: default;
transition: all 0.3s ease;
font-family: inherit;
min-width: 60px;
text-align: center;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.ws-status-btn.ws-on {
background: #7ea855; /* Темнее на 20% */
color: white;
border-color: #7ea855;
}
.ws-status-btn.ws-off {
background: #f7768e;
color: white;
border-color: #f7768e;
}
.ws-status-btn.ws-err {
background: #e0af68;
color: white;
border-color: #e0af68;
}
/* Sidebar Controls */
.sidebar-controls {
padding: 16px;
border-bottom: 1px solid var(--border);
}
.control-group {
margin-bottom: 16px;
}
.control-group:last-child {
margin-bottom: 0;
}
/* Collapsible sections */
.control-group.collapsible {
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.control-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: var(--chip);
cursor: pointer;
user-select: none;
transition: background-color 0.2s ease;
}
.control-header:hover {
background: var(--tab-active);
}
.control-header label {
display: block;
font-size: 11px;
color: var(--muted);
margin: 0;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.collapse-btn {
background: none;
border: none;
color: var(--muted);
cursor: pointer;
padding: 4px;
border-radius: 4px;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.collapse-btn:hover {
background: var(--border);
color: var(--fg);
}
.collapse-btn i {
font-size: 12px;
transition: transform 0.2s ease;
}
.control-group.collapsed .collapse-btn i {
transform: rotate(-90deg);
}
.control-content {
padding: 16px;
transition: all 0.3s ease;
overflow: hidden;
}
.control-group.collapsed .control-content {
padding: 0 16px;
max-height: 0;
opacity: 0;
}
/* Полностью свернутое состояние - только заголовки */
.control-group.minimized {
margin-bottom: 4px;
}
.control-group.minimized .control-header {
padding: 8px 12px;
font-size: 10px;
}
.control-group.minimized .control-header label {
font-size: 10px;
text-transform: none;
letter-spacing: 0;
}
.control-group.minimized .control-content {
display: none;
}
/* Скрытие всех секций при активной кнопке Options */
.sidebar-controls.hidden {
display: none;
}
.control-group label {
display: block;
font-size: 11px;
color: var(--muted);
margin-bottom: 6px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.control-group select,
.control-group input[type="text"] {
width: 100%;
background: var(--chip);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 12px;
font-size: 12px;
transition: border-color 0.2s ease;
}
.control-group select:focus,
.control-group input[type="text"]:focus {
outline: none;
border-color: var(--accent);
}
/* Checkbox Groups */
.checkbox-group {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.checkbox-group.levels-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px 16px;
width: 100%;
align-items: center;
}
.checkbox-group.options-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px 16px;
width: 100%;
align-items: center;
}
.checkbox-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--muted);
}
.levels-grid .checkbox-item {
min-height: 20px;
justify-content: flex-start;
}
.options-grid .checkbox-item {
min-height: 20px;
justify-content: flex-start;
}
.checkbox-item input[type="checkbox"] {
margin: 0;
}
/* Секция исключенных контейнеров */
.excluded-containers-list {
max-height: 150px;
overflow-y: auto;
margin-bottom: 12px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
}
.excluded-container-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
font-size: 12px;
}
.excluded-container-item:last-child {
border-bottom: none;
}
.excluded-container-item:first-child {
border-top-left-radius: 6px;
border-top-right-radius: 6px;
}
.excluded-container-item:last-child {
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
}
.excluded-container-name {
color: var(--fg);
flex: 1;
word-break: break-word;
}
.remove-excluded-btn {
background: var(--err);
color: white;
border: none;
border-radius: 4px;
padding: 4px 8px;
font-size: 11px;
cursor: pointer;
transition: all 0.2s ease;
margin-left: 8px;
min-width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.remove-excluded-btn:hover {
opacity: 0.8;
transform: scale(1.05);
}
.excluded-containers-controls {
display: flex;
gap: 8px;
align-items: center;
}
.excluded-input {
flex: 1;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
color: var(--fg);
font-size: 12px;
transition: border-color 0.2s ease;
}
.excluded-input:focus {
outline: none;
border-color: var(--accent);
}
.excluded-input::placeholder {
color: var(--muted);
}
/* Buttons */
.btn {
background: var(--chip);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 12px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 6px;
}
.btn:hover {
background: var(--tab-active);
border-color: var(--accent);
}
.btn-primary {
background: var(--accent);
color: #0b0d12;
border-color: var(--accent);
font-weight: 500;
}
.btn-primary:hover {
background: #6b8fd8;
}
.btn-small {
padding: 4px 8px;
font-size: 10px;
min-width: auto;
}
.btn-full-width {
width: 100%;
justify-content: center;
}
/* Стили для кнопки refresh */
.log-refresh-btn {
background: var(--accent);
color: white;
border: none;
border-radius: 6px;
transition: all 0.2s ease;
padding: 6px 24px;
font-size: 11px;
font-weight: 500;
display: inline-flex;
align-items: center;
justify-content: center;
height: fit-content;
}
.log-refresh-btn:hover {
background: var(--accent);
opacity: 0.8;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
/* Стили для счетчиков-кнопок */
.counter-btn {
display: inline-flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 6px 12px;
margin: 0 4px;
border: none;
border-radius: 6px;
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
min-width: 70px;
}
.counter-btn:hover {
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
/* Убираем стиль hidden, так как счетчики больше не скрываются */
.counter-btn.disabled {
opacity: 0.5;
background: var(--muted) !important;
cursor: pointer;
}
.counter-btn.disabled:hover {
opacity: 0.7;
}
.counter-label {
font-size: 10px;
opacity: 0.9;
margin-right: 4px;
}
.counter-value {
font-size: 12px;
font-weight: bold;
}
/* Цвета для разных уровней логов */
.debug-btn {
background: #6c757d; /* Серый */
color: white;
}
.debug-btn:hover {
background: #5a6268; /* Затемненный серый */
}
.info-btn {
background: var(--ok); /* Зеленый цвет */
color: white;
border: 1px solid var(--ok);
}
.info-btn:hover {
background: #7ea855; /* Затемненный зеленый */
}
.warn-btn {
background: #ffc107; /* Оставляем текущий цвет */
color: #212529;
}
.warn-btn:hover {
background: #e0a800; /* Затемненный желтый */
}
.error-btn {
background: #dc3545; /* Оставляем текущий цвет */
color: white;
}
.error-btn:hover {
background: #c82333; /* Затемненный красный */
}
.other-btn {
background: #f8f9fa; /* Самый светлый серый */
color: #212529;
}
.other-btn:hover {
background: #dee2e6; /* Затемненный серый */
}
.btn-group {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.btn-group.actions-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px 16px;
width: 100%;
}
.btn-group.actions-grid .btn-full-width {
grid-column: 1 / -1;
}
/* Main Content */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Header */
.header {
height: var(--header-height);
background: var(--panel);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 20px;
gap: 16px;
flex-wrap: wrap;
}
.header-title {
font-size: 16px;
font-weight: 600;
color: var(--fg);
margin: 0;
display: flex;
align-items: center;
gap: 8px;
}
.header-badge {
color: var(--muted);
padding: 8px 12px;
font-size: 12px;
display: flex;
align-items: center;
gap: 8px;
height: fit-content;
}
.header-badge select {
background: var(--chip);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 12px;
font-size: 12px;
cursor: pointer;
outline: none;
transition: border-color 0.2s ease;
min-width: 120px;
}
.header-badge select:focus {
border-color: var(--accent);
}
.header-badge select option {
background: var(--bg);
color: var(--fg);
}
/* Мультивыбор проектов */
.multi-select-container {
position: relative;
min-width: 120px;
}
.multi-select-display {
background: var(--chip);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 12px;
font-size: 12px;
cursor: pointer;
outline: none;
transition: border-color 0.2s ease;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.multi-select-display:hover {
border-color: var(--accent);
}
.multi-select-display.active {
border-color: var(--accent);
background: var(--tab-active);
}
.multi-select-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.multi-select-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
z-index: 1000;
max-height: 200px;
overflow-y: auto;
margin-top: 4px;
}
.multi-select-option {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
transition: background-color 0.2s ease;
}
.multi-select-option:hover {
background: var(--chip);
}
.multi-select-option input[type="checkbox"] {
margin: 0;
}
.multi-select-option label {
cursor: pointer;
font-size: 12px;
color: var(--fg);
flex: 1;
}
.header-controls {
margin-left: auto;
display: flex;
align-items: center;
gap: 12px;
}
.header-filter {
flex: 1;
min-width: 200px;
max-width: none;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--fg);
font-size: 12px;
transition: border-color 0.2s ease;
margin: 0 16px;
width: 100%;
}
.header-filter:focus {
outline: none;
border-color: var(--accent);
}
.header-filter::placeholder {
color: var(--muted);
}
/* Адаптивность для header-filter */
@media (max-width: 1200px) {
.header-filter {
min-width: 150px;
margin: 0 12px;
}
.sidebar.collapsed ~ .main-content .header .header-filter {
min-width: 120px;
margin: 0 6px;
}
}
@media (max-width: 768px) {
.header-filter {
min-width: 120px;
margin: 0 8px;
padding: 6px 10px;
font-size: 11px;
}
.header {
gap: 12px;
padding: 0 16px;
}
.sidebar.collapsed ~ .main-content .header .header-filter {
min-width: 100px;
margin: 0 4px;
padding: 4px 8px;
font-size: 10px;
}
}
@media (max-width: 480px) {
.header-filter {
min-width: 100px;
margin: 0 4px;
padding: 4px 8px;
font-size: 10px;
}
.header {
gap: 8px;
padding: 0 12px;
}
.header-controls {
gap: 8px;
}
/* Адаптивность для свернутого sidebar */
.sidebar.collapsed ~ .main-content .header .header-filter {
min-width: 100px;
margin: 0 4px;
}
}
.header-project-select {
padding: 6px 12px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--fg);
font-size: 12px;
cursor: pointer;
transition: border-color 0.2s ease;
min-width: 120px;
}
.header-project-select:focus {
outline: none;
border-color: var(--accent);
}
.header-project-select option {
background: var(--bg);
color: var(--fg);
}
/* Theme Toggle */
.theme-toggle {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--muted);
}
.theme-toggle input {
appearance: none;
width: 40px;
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:checked::after {
transform: translateX(20px);
}
.theme-toggle input:checked {
background: var(--accent);
}
/* Container List */
.container-list {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.container-item {
background: var(--chip);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
margin-bottom: 8px;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
}
.container-item:hover {
background: var(--tab-active);
border-color: var(--accent);
}
.container-item.active {
background: var(--tab-active);
border-color: var(--accent);
color: var(--accent);
}
.container-item.active::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: var(--accent);
border-radius: 0 2px 2px 0;
}
.container-name {
font-size: 13px;
font-weight: 500;
margin-bottom: 4px;
display: flex;
align-items: center;
gap: 8px;
}
.container-service {
font-size: 11px;
color: var(--muted);
margin-bottom: 4px;
}
.container-status {
display: flex;
align-items: center;
gap: 8px;
font-size: 10px;
}
.status-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--ok);
}
.status-indicator.running { background: var(--ok); }
.status-indicator.stopped { background: var(--err); }
.status-indicator.paused { background: var(--warn); }
.container-link {
color: var(--accent);
text-decoration: none;
opacity: 0.7;
transition: opacity 0.2s ease;
margin-left: 4px;
}
.container-link:hover {
opacity: 1;
color: var(--accent);
}
/* Чекбоксы для мультивыбора контейнеров */
.container-select {
position: absolute;
bottom: 8px;
right: 8px;
z-index: 10;
}
.container-checkbox {
display: none;
}
.container-checkbox-label {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: 2px solid var(--border);
border-radius: 4px;
background: var(--bg);
cursor: pointer;
transition: all 0.2s ease;
position: relative;
}
.container-checkbox-label:hover {
border-color: var(--accent);
background: var(--chip);
}
.container-checkbox:checked + .container-checkbox-label {
background: var(--accent);
border-color: var(--accent);
}
.container-checkbox:checked + .container-checkbox-label::after {
content: "✓";
color: white;
font-size: 12px;
font-weight: bold;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.container-item.selected {
border-color: var(--accent);
background: var(--tab-active);
}
/* Мультипросмотр */
.multi-view-grid {
display: grid;
gap: 2px;
height: 100%;
padding: 0px;
/* Равная высота строк для нескольких рядов (3+ окон) */
grid-auto-rows: 1fr;
align-items: stretch;
}
.multi-view-panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 2px;
}
.multi-view-header {
padding: 12px 16px;
background: var(--chip);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.multi-view-title {
font-size: 13px;
font-weight: 500;
color: var(--fg);
margin: 0;
flex: 1;
}
.multi-view-content {
flex: 1;
overflow: hidden;
}
/* Single View Panel - аналогично Multi View */
.single-view-panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 2px;
height: 100%;
}
.single-view-header {
padding: 12px 16px;
background: var(--chip);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.single-view-title {
font-size: 13px;
font-weight: 500;
color: var(--fg);
margin: 0;
flex: 1;
}
/* Кнопки уровней логирования для заголовков */
.single-view-levels,
.multi-view-levels {
display: flex;
gap: 4px;
align-items: center;
}
.level-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 4px 6px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--panel);
color: var(--fg);
cursor: pointer;
transition: all 0.2s ease;
font-size: 10px;
min-width: 40px;
position: relative;
}
.level-btn:hover {
background: var(--chip);
border-color: var(--accent);
}
.level-btn.active {
background: var(--accent);
color: #0b0d12;
border-color: var(--accent);
}
.level-btn.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.level-btn.disabled:hover {
background: var(--panel);
border-color: var(--border);
}
.level-label {
font-weight: 500;
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.level-value {
font-weight: 600;
font-size: 11px;
}
/* Цвета для разных уровней */
.level-btn.debug-btn {
border-color: #6c757d; /* Серый */
color: #6c757d;
}
.level-btn.debug-btn:hover,
.level-btn.debug-btn.active {
background: #5a6268; /* Затемненный серый */
color: white;
}
.level-btn.info-btn {
border-color: var(--ok); /* Зеленый цвет */
color: var(--ok);
}
.level-btn.info-btn:hover,
.level-btn.info-btn.active {
background: #7ea855; /* Затемненный зеленый */
color: white;
}
.level-btn.warn-btn {
border-color: var(--warn); /* Оставляем текущий цвет */
color: var(--warn);
}
.level-btn.warn-btn:hover,
.level-btn.warn-btn.active {
background: #e0a800; /* Затемненный желтый */
color: #0b0d12;
}
.level-btn.error-btn {
border-color: var(--err); /* Оставляем текущий цвет */
color: var(--err);
}
.level-btn.error-btn:hover,
.level-btn.error-btn.active {
background: #c82333; /* Затемненный красный */
color: white;
}
.level-btn.other-btn {
border-color: #f8f9fa; /* Самый светлый серый */
color: #f8f9fa;
}
.level-btn.other-btn:hover,
.level-btn.other-btn.active {
background: #dee2e6; /* Затемненный серый */
color: #212529;
}
.single-view-content {
flex: 1;
overflow: hidden;
}
.single-view-content .log {
height: 100%;
margin: 0;
padding: 12px;
font-size: 11px;
line-height: 1.4;
white-space: pre;
word-break: break-word;
overflow: auto;
background: var(--bg);
color: var(--fg);
font-family: ui-monospace, Menlo, Consolas, monospace;
}
.multi-view-log {
height: 100%;
margin: 0;
padding: 12px;
font-size: 11px;
line-height: 1.4;
white-space: pre;
word-break: break-word;
overflow: auto;
background: var(--bg);
color: var(--fg);
font-family: ui-monospace, Menlo, Consolas, monospace;
}
/* Log Area */
.log-area {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.log-header {
padding: 12px 20px;
background: var(--panel);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.log-title {
font-size: 14px;
font-weight: 500;
color: var(--fg);
margin: 0;
}
.log-controls {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
}
.log-content {
flex: 1;
overflow: hidden;
padding: 0;
background: var(--bg);
}
.log {
white-space: pre-wrap;
word-break: break-word;
font-size: 12px;
line-height: 1.5;
margin: 0;
tab-size: 2;
height: 100%;
overflow: auto;
}
/* Responsive */
@media (max-width: 768px) {
.sidebar {
width: 100%;
position: absolute;
z-index: 1000;
height: 100%;
transform: translateX(-100%);
transition: transform 0.3s ease;
}
.sidebar.open {
transform: translateX(0);
}
.main-content {
width: 100%;
}
.mobile-toggle {
display: block;
}
}
.mobile-toggle {
display: none;
background: none;
border: none;
color: var(--fg);
font-size: 18px;
cursor: pointer;
padding: 8px;
}
/* Legacy styles for compatibility */
#tabs{display:none}
main{display:none}
.grid-1{grid-template-columns:1fr}
.grid-2{grid-template-columns:1fr 1fr}
.grid-3{grid-template-columns:1fr 1fr 1fr}
.grid-4{grid-template-columns:1fr 1fr;grid-auto-rows:45vh}
.panel{border:1px solid var(--border);border-radius:10px;background:color-mix(in oklab, var(--panel) 96%, var(--bg));display:flex;flex-direction:column;min-height:0}
.panel .title{padding:6px 10px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:8px;color:var(--muted);font-size:12px}
.badge{padding:2px 8px;border-radius:999px;background:var(--chip);border:1px solid var(--border);margin-left:6px;color:var(--muted)}
.controls .badge{margin-left:0}
.toolbar{display:flex;gap:6px;margin-left:auto}
.counter{font-size:11px;color:var(--muted)}
.logwrap{flex:1;overflow:auto;padding:10px}
.log{white-space:pre-wrap;word-break:break-word;font-size:12px;line-height:1.5;margin:0;tab-size:2}
.line{color:var(--fg); display:block; margin:0; padding:0; line-height:1.4} .ok{color:var(--ok)} .warn{color:var(--warn)} .err{color:var(--err)} .dbg{color:#7dcfff} .ts{color:var(--muted)}
/* Цвета для multi-view логов */
.multi-view-log .line{color:var(--fg) !important; display:block; margin:0; padding:0; line-height:1.4}
.multi-view-log .ok{color:var(--ok) !important}
.multi-view-log .warn{color:var(--warn) !important}
.multi-view-log .err{color:var(--err) !important}
.multi-view-log .dbg{color:#7dcfff !important}
.multi-view-log .ts{color:var(--muted) !important}
/* Дополнительные стили для multi-view логов */
.multi-view-log span.line{color:var(--fg) !important; display:block; margin:0; padding:0; line-height:1.4}
.multi-view-log span.ok{color:var(--ok) !important}
.multi-view-log span.warn{color:var(--warn) !important}
.multi-view-log span.err{color:var(--err) !important}
.multi-view-log span.dbg{color:#7dcfff !important}
.multi-view-log span.ts{color:var(--muted) !important}
/* Стили для ANSI цветов в multi-view */
.multi-view-log .ansi-red{color:#f7768e !important}
.multi-view-log .ansi-green{color:#22c55e !important}
.multi-view-log .ansi-yellow{color:#eab308 !important}
.multi-view-log .ansi-blue{color:#3b82f6 !important}
.multi-view-log .ansi-magenta{color:#a855f7 !important}
.multi-view-log .ansi-cyan{color:#06b6d4 !important}
.multi-view-log .ansi-white{color:var(--fg) !important}
.multi-view-log .ansi-black{color:#79808f !important}
footer{position:fixed;right:10px;bottom:10px;opacity:.6;font-size:11px}
.filterlvl{display:flex;gap:6px;align-items:center}
/* Instance tag */
.inst-tag{display:inline-block;padding:0 6px;margin-right:6px;border-radius:6px;border:1px solid var(--border);opacity:.9}
/* ANSI */
.ansi-black{color:#79808f} .ansi-red{color:#f7768e} .ansi-green{color:#22c55e} .ansi-yellow{color:#eab308}
.ansi-blue{color:#3b82f6} .ansi-magenta{color:#a855f7} .ansi-cyan{color:#06b6d4} .ansi-white{color:var(--fg)}
.ansi-bold{font-weight:bold} .ansi-italic{font-style:italic} .ansi-underline{text-decoration:underline}
/* Theme toggle */
.theme-toggle{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted)}
.theme-toggle input{appearance:none;width:36px;height:20px;border-radius:999px;position:relative;background:var(--chip);border:1px solid var(--border);cursor:pointer}
.theme-toggle input::after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:var(--fg);transition:transform .2s ease}
.theme-toggle input:checked::after{transform:translateX(16px)}
/* Floating copy button */
.copy-fab{
position:fixed; z-index:9999; display:none; padding:6px 10px; border-radius:8px;
background:var(--accent); color:#0b0d12; border:none; box-shadow:0 6px 20px rgba(0,0,0,.25);
font-size:12px;
}
.copy-fab.show{display:block}
.copy-fab:active{transform:translateY(1px)}
/* Модальное окно с горячими клавишами */
.hotkeys-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 10000;
display: none;
align-items: center;
justify-content: center;
}
.hotkeys-modal.show {
display: flex;
}
.hotkeys-modal-content {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 16px 64px rgba(0,0,0,0.4);
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
animation: modalSlideIn 0.3s ease;
}
@keyframes modalSlideIn {
from {
opacity: 0;
transform: scale(0.9) translateY(-20px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.hotkeys-modal-header {
padding: 20px 24px 16px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
.hotkeys-modal-title {
font-size: 18px;
font-weight: 600;
color: var(--accent);
margin: 0;
display: flex;
align-items: center;
gap: 8px;
}
.hotkeys-modal-close {
background: none;
border: none;
color: var(--muted);
cursor: pointer;
padding: 8px;
border-radius: 6px;
transition: all 0.2s ease;
font-size: 16px;
}
.hotkeys-modal-close:hover {
background: var(--chip);
color: var(--fg);
}
.hotkeys-modal-body {
padding: 20px 24px;
}
.hotkeys-section {
margin-bottom: 24px;
}
.hotkeys-section:last-child {
margin-bottom: 0;
}
.hotkeys-section-title {
font-size: 14px;
font-weight: 600;
color: var(--fg);
margin: 0 0 12px 0;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.hotkeys-list {
list-style: none;
padding: 0;
margin: 0;
}
.hotkeys-list li {
display: flex;
align-items: center;
gap: 12px;
margin: 8px 0;
font-size: 13px;
color: var(--fg);
}
.hotkeys-list kbd {
background: var(--chip);
border: 1px solid var(--border);
border-radius: 4px;
padding: 4px 8px;
font-size: 11px;
font-family: monospace;
color: var(--accent);
min-width: 20px;
text-align: center;
}
/* Hotkeys notification */
.hotkeys-notification {
position: fixed;
top: 20px;
right: 20px;
z-index: 10000;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
max-width: 350px;
animation: slideInRight 0.3s ease;
}
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.notification-content {
padding: 16px;
position: relative;
}
.notification-content h4 {
margin: 0 0 12px 0;
color: var(--accent);
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
}
.notification-content ul {
margin: 0;
padding: 0;
list-style: none;
}
.notification-content li {
margin: 8px 0;
font-size: 12px;
color: var(--fg);
display: flex;
align-items: center;
gap: 8px;
}
.notification-content kbd {
background: var(--chip);
border: 1px solid var(--border);
border-radius: 4px;
padding: 2px 6px;
font-size: 10px;
font-family: monospace;
color: var(--accent);
}
.notification-close {
position: absolute;
top: 8px;
right: 8px;
background: none;
border: none;
color: var(--muted);
cursor: pointer;
padding: 4px;
border-radius: 4px;
transition: all 0.2s ease;
}
.notification-close:hover {
background: var(--chip);
color: var(--fg);
}
</style>
</head>
<body>
<div class="app-container">
<!-- Sidebar -->
<div class="sidebar" id="sidebar">
<!-- Логотип для свернутого состояния -->
<div class="sidebar-logo">
<i class="fas fa-terminal"></i>
</div>
<div class="sidebar-header">
<div class="header-top">
<h1><i class="fas fa-terminal"></i> LogBoard+</h1>
<div class="header-buttons">
<button class="options-btn" id="optionsBtn" title="Показать/скрыть настройки">
<i class="fas fa-cog"></i>
</button>
<button class="help-btn" id="helpBtn" title="Горячие клавиши">
<i class="fas fa-question-circle"></i>
</button>
<button class="logout-btn" id="logoutBtn" title="Выйти">
<i class="fas fa-sign-out-alt"></i>
</button>
</div>
</div>
</div>
<div class="sidebar-controls">
<div class="control-group collapsible" data-section="tail">
<div class="control-header">
<label>Tail Lines</label>
<button class="collapse-btn" data-target="tail">
<i class="fas fa-chevron-down"></i>
</button>
</div>
<div class="control-content" id="tail-content">
<select id="tail">
<option value="50" selected>50</option>
<option value="100">100</option>
<option value="200">200</option>
</select>
</div>
</div>
<div class="control-group collapsible" data-section="options">
<div class="control-header">
<label>Options</label>
<button class="collapse-btn" data-target="options">
<i class="fas fa-chevron-down"></i>
</button>
</div>
<div class="control-content" id="options-content">
<div class="checkbox-group options-grid">
<div class="checkbox-item">
<input type="checkbox" id="autoscroll" checked>
<label for="autoscroll">Auto-scroll</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="wrap" checked>
<label for="wrap">Wrap text</label>
</div>
</div>
</div>
</div>
<div class="control-group collapsible" data-section="actions">
<div class="control-header">
<label>Actions</label>
<button class="collapse-btn" data-target="actions">
<i class="fas fa-chevron-down"></i>
</button>
</div>
<div class="control-content" id="actions-content">
<div class="btn-group actions-grid">
<button id="refresh" class="btn"><i class="fas fa-sync-alt"></i> Refresh</button>
<button id="clear" class="btn"><i class="fas fa-trash"></i> Clear</button>
<button id="snapshot" class="btn btn-full-width"><i class="fas fa-download"></i> Download logs</button>
</div>
</div>
</div>
<div class="control-group collapsible" data-section="excluded">
<div class="control-header">
<label>Excluded</label>
<button class="collapse-btn" data-target="excluded">
<i class="fas fa-chevron-down"></i>
</button>
</div>
<div class="control-content" id="excluded-content">
<div class="excluded-containers-list" id="excludedContainersList">
<!-- Список будет загружен динамически -->
</div>
<div class="excluded-containers-controls">
<input type="text" id="newExcludedContainer" placeholder="Имя контейнера" class="excluded-input">
<button id="addExcludedContainer" class="btn btn-small">Добавить</button>
</div>
</div>
</div>
</div>
<!-- Container List -->
<div class="container-list" id="containerList">
<div class="container-item placeholder">
<div class="container-name">
<i class="fas fa-info-circle"></i>
Loading containers...
</div>
</div>
</div>
</div>
<!-- Кнопка сворачивания sidebar -->
<button class="sidebar-toggle" id="sidebarToggle" title="Свернуть/развернуть панель (Ctrl+B / Ctrl+И)">
<i class="fas fa-chevron-left"></i>
</button>
<!-- Main Content -->
<div class="main-content">
<!-- Header -->
<div class="header" id="header">
<button class="mobile-toggle" id="mobileToggle">
<i class="fas fa-bars"></i>
</button>
<h2 class="header-title">
Logs
</h2>
<span class="header-badge" id="projectBadge">
<div class="multi-select-container">
<div class="multi-select-display" id="projectSelectDisplay">
<span class="multi-select-text">All Projects</span>
<i class="fas fa-chevron-down"></i>
</div>
<div class="multi-select-dropdown" id="projectSelectDropdown" style="display: none;">
<div class="multi-select-option" data-value="all">
<input type="checkbox" id="project-all" checked>
<label for="project-all">All Projects</label>
</div>
</div>
</div>
</span>
<input id="filter" type="text" placeholder="Filter logs (regex)…" class="header-filter"/>
<div class="header-controls">
<div class="header-compact-controls">
<button class="counter-btn debug-btn" title="DEBUG">
<span class="counter-label">DEBUG</span>
<span class="counter-value cdbg">0</span>
</button>
<button class="counter-btn info-btn" title="INFO">
<span class="counter-label">INFO</span>
<span class="counter-value cinfo">0</span>
</button>
<button class="counter-btn warn-btn" title="WARN">
<span class="counter-label">WARN</span>
<span class="counter-value cwarn">0</span>
</button>
<button class="counter-btn error-btn" title="ERROR">
<span class="counter-label">ERROR</span>
<span class="counter-value cerr">0</span>
</button>
<button class="counter-btn other-btn" title="OTHER">
<span class="counter-label">OTHER</span>
<span class="counter-value cother">0</span>
</button>
</div>
<button class="btn btn-small log-refresh-btn" title="Обновить логи и счетчики">
<i class="fas fa-sync-alt"></i> Refresh
</button>
<div class="theme-toggle">
<span>Theme</span>
<input id="themeSwitch" type="checkbox" />
</div>
<button id="wsstate" class="ws-status-btn">ws: off</button>
</div>
</div>
<!-- Log Area -->
<div class="log-area">
<!-- Multi-view panel для Single View режима -->
<div class="multi-view-panel" id="multiViewPanel">
<span id="multiViewPanelTitle">No container selected</span>
</div>
<div class="log-content">
<div class="single-view-panel" id="singleViewPanel">
<div class="single-view-header">
<h4 class="single-view-title" id="singleViewTitle">No container selected</h4>
<div class="single-view-levels">
<button class="level-btn debug-btn" data-level="debug" title="DEBUG">
<span class="level-label">DEBUG</span>
<span class="level-value" data-container="single">0</span>
</button>
<button class="level-btn info-btn" data-level="info" title="INFO">
<span class="level-label">INFO</span>
<span class="level-value" data-container="single">0</span>
</button>
<button class="level-btn warn-btn" data-level="warn" title="WARN">
<span class="level-label">WARN</span>
<span class="level-value" data-container="single">0</span>
</button>
<button class="level-btn error-btn" data-level="err" title="ERROR">
<span class="level-label">ERROR</span>
<span class="level-value" data-container="single">0</span>
</button>
<button class="level-btn other-btn" data-level="other" title="OTHER">
<span class="level-label">OTHER</span>
<span class="level-value" data-container="single">0</span>
</button>
</div>
</div>
<div class="single-view-content">
<pre class="log" id="logContent">No container selected</pre>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Legacy elements for compatibility -->
<div id="tabs" style="display: none;"></div>
<div id="idFilters" style="display: none;"></div>
<main id="grid" style="display: none;"></main>
<button id="copyFab" class="copy-fab" type="button">копировать</button>
<footer style="display: none;">© LogBoard+</footer>
<script>
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'),
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){
els.wsstate.textContent = 'ws: ' + s;
// Удаляем все классы состояний
els.wsstate.classList.remove('ws-on', 'ws-off', 'ws-err');
// Добавляем соответствующий класс
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');
}
}
// Функция для обновления всех логов при изменении фильтров
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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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<id8.length;i++){ h = (h*31 + id8.charCodeAt(i))>>>0; }
const color = inst.palette[h % inst.palette.length];
inst.colors[id8] = color;
return color;
}
function updateIdFiltersBar(){
const bar = document.getElementById('idFilters');
bar.innerHTML = '';
const ids = Object.keys(inst.filters);
if (!ids.length){ bar.style.display='none'; return; }
bar.style.display='flex';
ids.forEach(id8=>{
const wrap = document.createElement('label');
wrap.style.display='inline-flex'; wrap.style.alignItems='center'; wrap.style.gap='6px';
const cb = document.createElement('input'); cb.type='checkbox'; cb.checked = inst.filters[id8] !== false;
cb.onchange = ()=> inst.filters[id8] = cb.checked;
const chip = document.createElement('span');
chip.className='inst-tag';
chip.style.borderColor = idColor(id8);
chip.style.color = idColor(id8);
chip.textContent = id8;
wrap.appendChild(cb); wrap.appendChild(chip);
bar.appendChild(wrap);
});
}
function shouldShowInstance(id8){
if (!Object.keys(inst.filters).length) return true;
const val = inst.filters[id8];
return val !== false;
}
function parsePrefixAndStrip(line){
// Accept "[id]" or "[id service]" prefixes from fan/fan_group
const m = line.match(/^\[([0-9a-f]{8})(?:\s+[^\]]+)?\]\s(.*)$/i);
if (!m) return null;
return {id8: m[1], rest: m[2]};
}
function ansiToHtml(text){
const ESC = '\u001b[';
const parts = text.split(ESC);
if (parts.length === 1) return escapeHtml(text);
let html = escapeHtml(parts[0]);
let classes = [];
for (let i=1;i<parts.length;i++){
const seg = parts[i];
const m = seg.match(/^([0-9;]+)m(.*)$/s);
if(!m){ html += escapeHtml(seg); continue; }
const codes = m[1].split(';').map(Number);
let rest = m[2];
for(const c of codes){
if (c===0) classes = [];
else if (c===1) classes.push('ansi-bold');
else if (c===3) classes.push('ansi-italic');
else if (c===4) classes.push('ansi-underline');
else if (c>=30 && c<=37){
classes = classes.filter(x=>!x.startsWith('ansi-'));
const map = {30:'black',31:'red',32:'green',33:'yellow',34:'blue',35:'magenta',36:'cyan',37:'white'};
classes.push('ansi-'+map[c]);
}
}
if (classes.length) html += `<span class="${classes.join(' ')}">` + escapeHtml(rest) + `</span>`;
else html += escapeHtml(rest);
}
return html;
}
function panelTemplate(svc){
const div = document.createElement('div'); div.className='panel'; div.dataset.cid = svc.id;
div.innerHTML = `
<div class="title">
<span>${escapeHtml((svc.project?`[${svc.project}] `:'')+(svc.service||svc.name))}</span>
<span class="counter">dbg:<b class="cdbg">0</b> info:<b class="cinfo">0</b> warn:<b class="cwarn">0</b> err:<b class="cerr">0</b></span>
<div class="toolbar">
<button class="primary t-reconnect">reconnect</button>
<button class="t-snapshot">snapshot</button>
<button class="t-close">close</button>
</div>
</div>
<div class="logwrap"><pre class="log"></pre></div>`;
return div;
}
function buildTabs(){
// Legacy tabs (hidden)
els.tabs.innerHTML='';
state.services.forEach(svc=>{
const b = document.createElement('button');
b.className='tab' + ((state.current && svc.id===state.current.id) ? ' active':'');
b.textContent = (svc.project?(`[${svc.project}] `):'') + (svc.service||svc.name);
b.title = `${svc.name}${svc.image}${svc.status}`;
b.onclick = async ()=> await switchToSingle(svc);
els.tabs.appendChild(b);
});
// Modern container list
els.containerList.innerHTML = '';
state.services.forEach(svc => {
const item = document.createElement('div');
item.className = 'container-item';
if (state.current && svc.id === state.current.id) {
item.classList.add('active');
}
item.setAttribute('data-cid', svc.id);
const statusClass = svc.status === 'running' ? 'running' :
svc.status === 'stopped' ? 'stopped' : 'paused';
item.innerHTML = `
<div class="container-name">
<i class="fas fa-cube"></i>
${escapeHtml(svc.name)}
</div>
<div class="container-service">
${escapeHtml(svc.service || svc.name)}
${svc.project ? `${escapeHtml(svc.project)}` : ''}
</div>
<div class="container-status">
<span class="status-indicator ${statusClass}"></span>
${escapeHtml(svc.status)}
${svc.url ? `<a href="${svc.url}" target="_blank" class="container-link" title="Открыть сайт"><i class="fas fa-external-link-alt"></i></a>` : ''}
</div>
<div class="container-select">
<input type="checkbox" id="select-${svc.id}" class="container-checkbox" data-container-id="${svc.id}" ${state.selectedContainers.includes(svc.id) ? 'checked' : ''}>
<label for="select-${svc.id}" class="container-checkbox-label" title="Выбрать для мультипросмотра"></label>
</div>
`;
// Устанавливаем состояние selected для контейнера
if (state.selectedContainers.includes(svc.id)) {
item.classList.add('selected');
}
item.onclick = async (e) => {
// Не переключаем контейнер, если кликнули на ссылку или чекбокс
if (e.target.closest('.container-link') || e.target.closest('.container-select')) {
e.stopPropagation();
return;
}
await switchToSingle(svc);
};
els.containerList.appendChild(item);
});
}
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 = `
<input type="checkbox" id="project-all" checked>
<label for="project-all">All Projects</label>
`;
dropdown.appendChild(allOption);
// Добавляем проекты
console.log('Adding projects to multi-select:', projects);
projects.forEach(project => {
const option = document.createElement('div');
option.className = 'multi-select-option';
option.setAttribute('data-value', project);
option.innerHTML = `
<input type="checkbox" id="project-${project}">
<label for="project-${project}">${escapeHtml(project)}</label>
`;
dropdown.appendChild(option);
});
// Восстанавливаем сохраненные выбранные проекты
const savedProjects = JSON.parse(localStorage.getItem('lb_selected_projects') || '["all"]');
updateMultiSelect(savedProjects);
console.log('Multi-select updated, current selection:', savedProjects);
} else {
console.error('Multi-select elements not found!');
}
} catch (error) {
console.error('Error fetching projects:', error);
}
}
// Функция для обновления мультивыбора проектов
function updateMultiSelect(selectedProjects) {
const dropdown = document.getElementById('projectSelectDropdown');
const displayText = document.querySelector('.multi-select-text');
if (!dropdown || !displayText) return;
// Фильтруем выбранные проекты, оставляя только те, которые есть в dropdown
const availableProjects = [];
dropdown.querySelectorAll('.multi-select-option').forEach(option => {
const value = option.getAttribute('data-value');
availableProjects.push(value);
});
const filteredProjects = selectedProjects.filter(project =>
project === 'all' || availableProjects.includes(project)
);
// Если все выбранные проекты исчезли, выбираем "All Projects"
if (filteredProjects.length === 0 || (filteredProjects.length === 1 && filteredProjects[0] === 'all')) {
filteredProjects.length = 0;
filteredProjects.push('all');
}
// Обновляем чекбоксы
dropdown.querySelectorAll('.multi-select-option').forEach(option => {
const value = option.getAttribute('data-value');
const checkbox = option.querySelector('input[type="checkbox"]');
if (checkbox) {
checkbox.checked = filteredProjects.includes(value);
}
});
// Обновляем текст отображения
if (filteredProjects.includes('all') || filteredProjects.length === 0) {
displayText.textContent = 'All Projects';
} else if (filteredProjects.length === 1) {
displayText.textContent = filteredProjects[0];
} else {
displayText.textContent = `${filteredProjects.length} Projects`;
}
// Сохраняем в localStorage
localStorage.setItem('lb_selected_projects', JSON.stringify(filteredProjects));
}
// Функция для получения выбранных проектов
function getSelectedProjects() {
const dropdown = document.getElementById('projectSelectDropdown');
if (!dropdown) return ['all'];
const selectedProjects = [];
dropdown.querySelectorAll('.multi-select-option input[type="checkbox"]:checked').forEach(checkbox => {
const option = checkbox.closest('.multi-select-option');
const value = option.getAttribute('data-value');
selectedProjects.push(value);
});
return selectedProjects.length > 0 ? selectedProjects : ['all'];
}
// Функции для работы с исключенными контейнерами
async function loadExcludedContainers() {
try {
const token = localStorage.getItem('access_token');
if (!token) {
console.error('No access token found');
return [];
}
const response = await fetch('/api/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 = '<div class="excluded-container-item"><span class="excluded-container-name">Нет исключенных контейнеров</span></div>';
return;
}
containers.forEach(container => {
const item = document.createElement('div');
item.className = 'excluded-container-item';
item.innerHTML = `
<span class="excluded-container-name">${escapeHtml(container)}</span>
<button class="remove-excluded-btn" onclick="removeExcludedContainer('${escapeHtml(container)}')">×</button>
`;
list.appendChild(item);
});
}
async function addExcludedContainer() {
const input = document.getElementById('newExcludedContainer');
const containerName = input.value.trim();
if (!containerName) {
alert('Введите имя контейнера');
return;
}
const currentContainers = await loadExcludedContainers();
if (currentContainers.includes(containerName)) {
alert('Контейнер уже в списке исключенных');
return;
}
currentContainers.push(containerName);
const success = await saveExcludedContainers(currentContainers);
if (success) {
renderExcludedContainers(currentContainers);
input.value = '';
// Обновляем список проектов и контейнеров
await fetchProjects();
await fetchServices();
} else {
alert('Ошибка сохранения');
}
}
async function removeExcludedContainer(containerName) {
const currentContainers = await loadExcludedContainers();
const updatedContainers = currentContainers.filter(name => name !== containerName);
const success = await saveExcludedContainers(updatedContainers);
if (success) {
renderExcludedContainers(updatedContainers);
// Обновляем список проектов и контейнеров
await fetchProjects();
await fetchServices();
} else {
alert('Ошибка удаления');
}
}
// Функции для мультивыбора контейнеров
function toggleContainerSelection(containerId) {
console.log('toggleContainerSelection called for:', containerId);
console.log('Current state.selectedContainers before:', [...state.selectedContainers]);
const index = state.selectedContainers.indexOf(containerId);
if (index > -1) {
state.selectedContainers.splice(index, 1);
console.log('Removed container from selection:', containerId);
} else {
state.selectedContainers.push(containerId);
console.log('Added container to selection:', containerId);
}
console.log('Current selected containers after:', state.selectedContainers);
updateContainerSelectionUI();
updateMultiViewMode();
}
function updateContainerSelectionUI() {
console.log('updateContainerSelectionUI called, selected containers:', state.selectedContainers);
// Обновляем чекбоксы
const checkboxes = document.querySelectorAll('.container-checkbox');
console.log('Found checkboxes:', checkboxes.length);
checkboxes.forEach(checkbox => {
const containerId = checkbox.getAttribute('data-container-id');
const containerItem = checkbox.closest('.container-item');
console.log('Processing checkbox for container:', containerId, 'checked:', checkbox.checked, 'should be:', state.selectedContainers.includes(containerId));
if (state.selectedContainers.includes(containerId)) {
checkbox.checked = true;
containerItem.classList.add('selected');
console.log('Container selected:', containerId);
} else {
checkbox.checked = false;
containerItem.classList.remove('selected');
console.log('Container deselected:', containerId);
}
});
// Обновляем 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();
}
// Функция для сохранения выбранного контейнера в localStorage
function saveSelectedContainer(containerId) {
if (containerId) {
localStorage.setItem('lb_selected_container', containerId);
console.log('Saved selected container to localStorage:', containerId);
} else {
localStorage.removeItem('lb_selected_container');
console.log('Removed selected container from localStorage');
}
}
// Функция для восстановления выбранного контейнера из localStorage
function getSelectedContainerFromStorage() {
const containerId = localStorage.getItem('lb_selected_container');
console.log('Retrieved selected container from localStorage:', containerId);
return containerId;
}
// Функция для сохранения режима просмотра в localStorage
function saveViewMode(multiViewMode, selectedContainers) {
const viewModeData = {
multiViewMode: multiViewMode,
selectedContainers: selectedContainers || []
};
localStorage.setItem('lb_view_mode', JSON.stringify(viewModeData));
console.log('Saved view mode to localStorage:', viewModeData);
}
// Функция для восстановления режима просмотра из localStorage
function getViewModeFromStorage() {
const viewModeData = localStorage.getItem('lb_view_mode');
if (viewModeData) {
try {
const data = JSON.parse(viewModeData);
console.log('Retrieved view mode from localStorage:', data);
return data;
} catch (error) {
console.error('Error parsing view mode from localStorage:', error);
return null;
}
}
return null;
}
// Функция для сохранения состояния кнопок loglevels в localStorage
function saveLogLevelsState() {
const levelsData = {
globalLevels: state.levels,
containerLevels: state.containerLevels
};
localStorage.setItem('lb_log_levels', JSON.stringify(levelsData));
console.log('Saved log levels state to localStorage:', levelsData);
}
// Функция для восстановления состояния кнопок loglevels из localStorage
function getLogLevelsStateFromStorage() {
const levelsData = localStorage.getItem('lb_log_levels');
if (levelsData) {
try {
const data = JSON.parse(levelsData);
console.log('Retrieved log levels state from localStorage:', data);
return data;
} catch (error) {
console.error('Error parsing log levels state from localStorage:', error);
return null;
}
}
return null;
}
async function updateMultiViewMode() {
console.log(`updateMultiViewMode called: selectedContainers.length = ${state.selectedContainers.length}, containers:`, state.selectedContainers);
if (state.selectedContainers.length > 1) {
state.multiViewMode = true;
state.current = null; // Сбрасываем текущий контейнер
console.log('Setting up multi-view mode');
// Сохраняем режим просмотра в localStorage
saveViewMode(true, state.selectedContainers);
await setupMultiView();
} else if (state.selectedContainers.length === 1) {
// Переключаемся в single view для одного контейнера
console.log('Switching from multi-view to single view');
state.multiViewMode = false;
const selectedService = state.services.find(s => s.id === state.selectedContainers[0]);
if (selectedService) {
console.log('Switching to single view for:', selectedService.name);
console.log('updateMultiViewMode: About to call switchToSingle - VERSION 2');
// Сохраняем режим просмотра в localStorage
saveViewMode(false, [selectedService.id]);
// Сохраняем выбранный контейнер в localStorage
saveSelectedContainer(selectedService.id);
// Обновляем страницу для полного сброса состояния
console.log('Refreshing page to switch to single view');
window.location.reload();
return; // Прерываем выполнение, так как страница перезагрузится
}
} else {
// Когда снимаем все галочки, переключаемся в single view
state.multiViewMode = false;
state.current = null;
// Сохраняем режим просмотра в localStorage
saveViewMode(false, []);
clearLogArea();
// Очищаем область логов и показываем пустое состояние
const logArea = document.querySelector('.log-area');
if (logArea) {
const logContent = logArea.querySelector('.log-content');
if (logContent) {
logContent.innerHTML = '<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: var(--muted); font-size: 14px;">Выберите контейнер для просмотра логов</div>';
}
}
}
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();
// Принудительно обновляем стили логов для 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 = `
<div class="multi-view-header">
<h4 class="multi-view-title">${escapeHtml(service.name)}</h4>
<div class="multi-view-levels">
<button class="level-btn debug-btn" data-level="debug" data-container-id="${service.id}" title="DEBUG">
<span class="level-label">DEBUG</span>
<span class="level-value" data-container="${service.id}">0</span>
</button>
<button class="level-btn info-btn" data-level="info" data-container-id="${service.id}" title="INFO">
<span class="level-label">INFO</span>
<span class="level-value" data-container="${service.id}">0</span>
</button>
<button class="level-btn warn-btn" data-level="warn" data-container-id="${service.id}" title="WARN">
<span class="level-label">WARN</span>
<span class="level-value" data-container="${service.id}">0</span>
</button>
<button class="level-btn error-btn" data-level="err" data-container-id="${service.id}" title="ERROR">
<span class="level-label">ERROR</span>
<span class="level-value" data-container="${service.id}">0</span>
</button>
<button class="level-btn other-btn" data-level="other" data-container-id="${service.id}" title="OTHER">
<span class="level-label">OTHER</span>
<span class="level-value" data-container="${service.id}">0</span>
</button>
</div>
</div>
<div class="multi-view-content">
<div class="multi-view-log" data-container-id="${service.id}"></div>
</div>
`;
// Проверяем, что элемент создался правильно
const logElement = panel.querySelector(`.multi-view-log[data-container-id="${service.id}"]`);
if (logElement) {
console.log(`Multi-view log element created successfully for ${service.name}`);
// Очищаем пустые строки после создания панели
cleanMultiViewEmptyLines(logElement);
// Очищаем дублированные строки после создания панели
cleanMultiViewDuplicateLines(logElement);
} else {
console.error(`Failed to create multi-view log element for ${service.name}`);
}
// Инициализируем состояние кнопок уровней логирования для этого контейнера
setTimeout(() => {
const levelBtns = panel.querySelectorAll('.level-btn');
levelBtns.forEach(btn => {
const level = btn.getAttribute('data-level');
const containerId = btn.getAttribute('data-container-id');
// Инициализируем настройки контейнера, если их нет
if (containerId && (!state.containerLevels || !state.containerLevels[containerId])) {
if (!state.containerLevels) {
state.containerLevels = {};
}
state.containerLevels[containerId] = {debug: true, info: true, warn: true, err: true, other: true};
}
// Используем настройки контейнера
const isActive = state.containerLevels && state.containerLevels[containerId] ?
state.containerLevels[containerId][level] : true;
btn.classList.toggle('active', isActive);
btn.classList.toggle('disabled', !isActive);
});
}, 100);
console.log(`Multi-view panel created for ${service.name}`);
// Применяем стили к новой панели
setTimeout(() => {
updateLogStyles();
}, 200);
return panel;
}
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)}...`);
const parts = (event.data||'').split(/\r?\n/);
// Проверяем на дублирование в исходных данных
if (event.data.includes('FoundINFO:')) {
console.log('🚨 WebSocket: ОБНАРУЖЕНЫ данные с FoundINFO:');
console.log('🚨 WebSocket: Полные данные:', event.data);
}
// Проверяем на дублирование строк и убираем дубликаты
const lines = event.data.split(/\r?\n/).filter(line => line.trim().length > 0);
const uniqueLines = [...new Set(lines)];
if (lines.length !== uniqueLines.length) {
console.log('🚨 WebSocket: ОБНАРУЖЕНО ДУБЛИРОВАНИЕ строк!');
console.log('🚨 WebSocket: Всего строк:', lines.length);
console.log('🚨 WebSocket: Уникальных строк:', uniqueLines.length);
console.log('🚨 WebSocket: Дублированные строки:', lines.filter((line, index) => lines.indexOf(line) !== index));
// Используем только уникальные строки
const uniqueParts = uniqueLines.map(line => line.trim()).filter(line => line.length > 0);
for (let i=0;i<uniqueParts.length;i++){
// Проверяем каждую часть на FoundINFO:
if (uniqueParts[i].includes('FoundINFO:')) {
console.log('🚨 WebSocket: Часть с FoundINFO:', uniqueParts[i]);
}
// harvest instance ids if present
const pr = parsePrefixAndStrip(uniqueParts[i]);
if (pr){ if (!(pr.id8 in inst.filters)) { inst.filters[pr.id8] = true; updateIdFiltersBar(); } }
handleLine(containerId, uniqueParts[i]);
}
} else {
// Если дублирования нет, обрабатываем как обычно
for (let i=0;i<parts.length;i++){
if (parts[i].length===0 && i===parts.length-1) continue;
// Проверяем каждую часть на FoundINFO:
if (parts[i].includes('FoundINFO:')) {
console.log('🚨 WebSocket: Часть с FoundINFO:', parts[i]);
}
// harvest instance ids if present
const pr = parsePrefixAndStrip(parts[i]);
if (pr){ if (!(pr.id8 in inst.filters)) { inst.filters[pr.id8] = true; updateIdFiltersBar(); } }
handleLine(containerId, parts[i]);
}
}
};
ws.onclose = () => {
console.log(`Multi-view WebSocket closed for ${service.name}`);
};
ws.onerror = (error) => {
console.error(`Multi-view WebSocket error for ${service.name}:`, error);
};
// Сохраняем соединение с полным набором полей как в openWs
const logEl = document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`);
console.log(`openMultiViewWs: Found log element for ${service.name}:`, !!logEl);
state.open[containerId] = {
ws: ws,
serviceName: service.service,
logEl: logEl,
wrapEl: logEl,
counters: {dbg:0, info:0, warn:0, err:0, other:0},
pausedBuffer: [],
allLogs: [] // Добавляем буфер для логов
};
console.log(`openMultiViewWs: WebSocket setup completed for ${service.name} (${containerId})`);
}
function clearLogArea() {
console.log('clearLogArea called');
// Очищаем мультипросмотр если он был активен
if (state.multiViewMode) {
console.log('Clearing multi-view grid');
const multiViewGrid = document.getElementById('multiViewGrid');
if (multiViewGrid) {
multiViewGrid.remove();
}
}
const logContent = document.querySelector('.log-content');
if (logContent) {
logContent.innerHTML = `
<div class="single-view-panel" id="singleViewPanel">
<div class="single-view-header">
<h4 class="single-view-title" id="singleViewTitle">No container selected</h4>
</div>
<div class="single-view-content">
<pre class="log" id="logContent">No container selected</pre>
</div>
</div>
`;
}
const logTitle = document.getElementById('logTitle');
if (logTitle) {
logTitle.textContent = 'LogBoard+';
}
// Обновляем видимость кнопок LogLevels
updateLogLevelsVisibility();
}
function applyWrapSettings() {
const wrapEnabled = els.wrapToggle && els.wrapToggle.checked;
const wrapStyle = wrapEnabled ? 'pre-wrap' : 'pre';
// Применяем к обычному просмотру (только к логам в основном контенте)
document.querySelectorAll('.main-content .log').forEach(el => {
el.style.whiteSpace = wrapStyle;
});
// Применяем к мультипросмотру
document.querySelectorAll('.multi-view-content .multi-view-log').forEach(el => {
el.style.whiteSpace = wrapStyle;
});
// Применяем к single-view
document.querySelectorAll('.single-view-content .log').forEach(el => {
el.style.whiteSpace = wrapStyle;
});
}
async function fetchServices(){
try {
console.log('Fetching services...');
const url = new URL(location.origin + '/api/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 = ()=> {
setWsState('on');
// Очищаем сообщение "Connecting..." когда соединение установлено
if (state.current && state.current.id === id && els.logContent) {
els.logContent.innerHTML = '';
}
// Также очищаем legacy элемент лога
if (obj.logEl) {
obj.logEl.innerHTML = '';
}
};
ws.onclose = ()=> setWsState(Object.keys(state.open).length? 'on':'off');
ws.onerror = ()=> setWsState('err');
ws.onmessage = (ev)=>{
console.log(`Received WebSocket message: ${ev.data.substring(0, 100)}...`);
const parts = (ev.data||'').split(/\r?\n/);
console.log(`openWs: Processing ${parts.length} lines for container ${id}`);
// Проверяем на дублирование в исходных данных для Single View
if (ev.data.includes('FoundINFO:')) {
console.log('🚨 Single View WebSocket: ОБНАРУЖЕНЫ данные с FoundINFO:');
console.log('🚨 Single View WebSocket: Полные данные:', ev.data);
}
// Проверяем на дублирование строк и убираем дубликаты
const lines = ev.data.split(/\r?\n/).filter(line => line.trim().length > 0);
const uniqueLines = [...new Set(lines)];
if (lines.length !== uniqueLines.length) {
console.log('🚨 Single View WebSocket: ОБНАРУЖЕНО ДУБЛИРОВАНИЕ строк!');
console.log('🚨 Single View WebSocket: Всего строк:', lines.length);
console.log('🚨 Single View WebSocket: Уникальных строк:', uniqueLines.length);
console.log('🚨 Single View WebSocket: Дублированные строки:', lines.filter((line, index) => lines.indexOf(line) !== index));
// Используем только уникальные строки
const uniqueParts = uniqueLines.map(line => line.trim()).filter(line => line.length > 0);
for (let i=0;i<uniqueParts.length;i++){
// Проверяем каждую часть на FoundINFO:
if (uniqueParts[i].includes('FoundINFO:')) {
console.log('🚨 Single View WebSocket: Часть с FoundINFO:', uniqueParts[i]);
}
// harvest instance ids if present
const pr = parsePrefixAndStrip(uniqueParts[i]);
if (pr){ if (!(pr.id8 in inst.filters)) { inst.filters[pr.id8] = true; updateIdFiltersBar(); } }
console.log(`openWs: Calling handleLine for container ${id}, line: "${uniqueParts[i].substring(0, 50)}..."`);
handleLine(id, uniqueParts[i]);
}
} else {
// Если дублирования нет, обрабатываем как обычно
for (let i=0;i<parts.length;i++){
if (parts[i].length===0 && i===parts.length-1) continue;
// Проверяем каждую часть на FoundINFO:
if (parts[i].includes('FoundINFO:')) {
console.log('🚨 Single View WebSocket: Часть с FoundINFO:', parts[i]);
}
// harvest instance ids if present
const pr = parsePrefixAndStrip(parts[i]);
if (pr){ if (!(pr.id8 in inst.filters)) { inst.filters[pr.id8] = true; updateIdFiltersBar(); } }
console.log(`openWs: Calling handleLine for container ${id}, line: "${parts[i].substring(0, 50)}..."`);
handleLine(id, parts[i]);
}
}
// Обновляем счетчики после обработки всех строк
cdbg.textContent = counters.dbg;
cinfo.textContent = counters.info;
cwarn.textContent = counters.warn;
cerr.textContent = counters.err;
if (cother) cother.textContent = counters.other;
};
// Убираем автоматический refresh - теперь только по кнопке
}
/**
* Функция для обработки переноса строк в multi view
* Если символов больше 5, то перенос строк работает
* Если меньше 5, то переноса строк нет
* @param {string} text - исходный текст
* @returns {string} - обработанный текст с правильными переносами
*/
function processMultiViewLineBreaks(text) {
// Если символов меньше или равно 5, возвращаем без переносов
if (text.length <= 5) {
return text;
}
// Если символов больше 5, добавляем перенос строки в конце
return text + '\n';
}
/**
* Функция для радикальной очистки пустых строк в multi view
* Удаляет все пустые строки и лишние переносы строк
* @param {HTMLElement} multiViewLog - элемент лога multi view
*/
function cleanMultiViewEmptyLines(multiViewLog) {
if (!multiViewLog) return;
let removedCount = 0;
// Удаляем все пустые строки (элементы .line без текста)
const lines = Array.from(multiViewLog.querySelectorAll('.line'));
lines.forEach(line => {
const textContent = line.textContent || line.innerText || '';
if (textContent.trim() === '') {
line.remove();
removedCount++;
}
});
// Удаляем все текстовые узлы, которые содержат только пробелы и переносы строк
const walker = document.createTreeWalker(
multiViewLog,
NodeFilter.SHOW_TEXT,
null,
false
);
const textNodesToRemove = [];
let node;
while (node = walker.nextNode()) {
const content = node.textContent;
// Удаляем все узлы, которые содержат только пробелы, переносы строк или табуляцию
if (content.trim() === '') {
textNodesToRemove.push(node);
}
}
textNodesToRemove.forEach(node => node.remove());
// Удаляем все пустые текстовые узлы между элементами .line
const allNodes = Array.from(multiViewLog.childNodes);
for (let i = allNodes.length - 1; i >= 0; i--) {
const node = allNodes[i];
if (node.nodeType === Node.TEXT_NODE && node.textContent.trim() === '') {
node.remove();
}
}
if (removedCount > 0) {
console.log(`cleanMultiViewEmptyLines: Удалено ${removedCount} пустых строк`);
}
}
/**
* Функция для очистки дублированных строк в multi view
* Удаляет последовательные дублированные строки
* @param {HTMLElement} multiViewLog - элемент лога multi view
*/
function cleanMultiViewDuplicateLines(multiViewLog) {
if (!multiViewLog) return;
const lines = Array.from(multiViewLog.querySelectorAll('.line'));
let removedCount = 0;
// Проходим по строкам с конца, чтобы не нарушить индексы
for (let i = lines.length - 1; i > 0; i--) {
const currentLine = lines[i];
const previousLine = lines[i - 1];
if (currentLine && previousLine) {
const currentText = currentLine.textContent || currentLine.innerText || '';
const previousText = previousLine.textContent || previousLine.innerText || '';
// Удаляем дублированные строки (включая пустые)
if (currentText.trim() === previousText.trim()) {
console.log(`cleanMultiViewDuplicateLines: Удаляем дублированную строку: ${currentText.substring(0, 50)}...`);
currentLine.remove();
removedCount++;
}
}
}
// После удаления дубликатов очищаем лишние пустые строки
cleanMultiViewEmptyLines(multiViewLog);
if (removedCount > 0) {
console.log(`cleanMultiViewDuplicateLines: Удалено ${removedCount} дублированных строк`);
}
}
/**
* Универсальная функция для очистки дублированных строк
* Работает как для Single View, так и для MultiView
* @param {HTMLElement} logElement - элемент лога (любого типа)
*/
function cleanDuplicateLines(logElement) {
if (!logElement) return;
const lines = Array.from(logElement.querySelectorAll('.line'));
let removedCount = 0;
// Проходим по строкам с конца, чтобы не нарушить индексы
for (let i = lines.length - 1; i > 0; i--) {
const currentLine = lines[i];
const previousLine = lines[i - 1];
if (currentLine && previousLine) {
const currentText = currentLine.textContent || currentLine.innerText || '';
const previousText = previousLine.textContent || previousLine.innerText || '';
// Удаляем дублированные строки (включая пустые)
if (currentText.trim() === previousText.trim()) {
console.log(`cleanDuplicateLines: Удаляем дублированную строку: ${currentText.substring(0, 50)}...`);
currentLine.remove();
removedCount++;
}
}
}
// После удаления дубликатов очищаем лишние пустые строки
if (logElement.classList.contains('multi-view-log')) {
cleanMultiViewEmptyLines(logElement);
} else {
cleanSingleViewEmptyLines(logElement);
}
if (removedCount > 0) {
console.log(`cleanDuplicateLines: Удалено ${removedCount} дублированных строк`);
}
}
/**
* Функция для радикальной очистки пустых строк в Single View
* Удаляет все пустые строки и лишние переносы строк
* @param {HTMLElement} logElement - элемент лога Single View
*/
function cleanSingleViewEmptyLines(logElement) {
if (!logElement) return;
let removedCount = 0;
// Удаляем все пустые строки (элементы .line без текста)
const lines = Array.from(logElement.querySelectorAll('.line'));
lines.forEach(line => {
const textContent = line.textContent || line.innerText || '';
if (textContent.trim() === '') {
line.remove();
removedCount++;
}
});
// Удаляем все текстовые узлы, которые содержат только пробелы и переносы строк
const walker = document.createTreeWalker(
logElement,
NodeFilter.SHOW_TEXT,
null,
false
);
const textNodesToRemove = [];
let node;
while (node = walker.nextNode()) {
const content = node.textContent;
// Удаляем все узлы, которые содержат только пробелы, переносы строк или табуляцию
if (content.trim() === '') {
textNodesToRemove.push(node);
}
}
textNodesToRemove.forEach(node => node.remove());
// Удаляем все пустые текстовые узлы между элементами .line
const allNodes = Array.from(logElement.childNodes);
for (let i = allNodes.length - 1; i >= 0; i--) {
const node = allNodes[i];
if (node.nodeType === Node.TEXT_NODE && node.textContent.trim() === '') {
node.remove();
}
}
if (removedCount > 0) {
console.log(`cleanSingleViewEmptyLines: Удалено ${removedCount} пустых строк`);
}
}
/**
* Функция для нормализации пробелов в логах
* Заменяет множественные пробелы на один пробел
* @param {string} text - исходный текст
* @returns {string} - текст с нормализованными пробелами
*/
function normalizeSpaces(text) {
if (!text) return text;
// Заменяем множественные пробелы на один пробел
// Используем регулярное выражение для замены 2+ пробелов на один
return text.replace(/\s{2,}/g, ' ');
}
/**
* Функция для периодической очистки пустых строк
* Вызывается автоматически каждые 2 секунды для поддержания чистоты логов
*/
function periodicCleanup() {
// Очищаем пустые строки в Single View
if (!state.multiViewMode && els.logContent) {
cleanSingleViewEmptyLines(els.logContent);
cleanDuplicateLines(els.logContent);
}
// Очищаем пустые строки в мультипросмотре
if (state.multiViewMode) {
state.selectedContainers.forEach(containerId => {
const multiViewLog = document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`);
if (multiViewLog) {
cleanMultiViewEmptyLines(multiViewLog);
cleanMultiViewDuplicateLines(multiViewLog);
}
});
}
// Очищаем пустые строки в legacy панелях
Object.values(state.open).forEach(obj => {
if (obj.logEl) {
cleanSingleViewEmptyLines(obj.logEl);
cleanDuplicateLines(obj.logEl);
}
});
}
// Запускаем периодическую очистку каждые 2 секунды
setInterval(periodicCleanup, 2000);
// Запускаем периодическую проверку стилей multi-view логов каждые 5 секунд
setInterval(() => {
if (state.multiViewMode) {
const multiViewLogs = document.querySelectorAll('.multi-view-content .multi-view-log');
if (multiViewLogs.length > 0) {
console.log('Periodic check: Force fixing multi-view styles');
forceFixMultiViewStyles();
// Дополнительно исправляем все контейнеры
console.log('Periodic check: Fixing all containers');
if (window.fixAllContainers) {
window.fixAllContainers();
}
}
}
}, 5000);
/**
* Функция для обработки специальных замен в MultiView логах
* Выполняет специфичные замены для улучшения читаемости логов
* @param {string} text - исходный текст
* @returns {string} - текст с примененными заменами
*/
function processMultiViewSpecialReplacements(text) {
if (!text) return text;
let processedText = text;
// Добавляем отладочную информацию для проверки
if (text.includes('FoundINFO:')) {
console.log('🔍 processMultiViewSpecialReplacements: Найдена строка с FoundINFO:', text);
}
// Проверяем на дублирование строк в исходном тексте
const lines = processedText.split('\n');
const uniqueLines = [...new Set(lines)];
if (lines.length !== uniqueLines.length) {
console.log('🚨 processMultiViewSpecialReplacements: Обнаружено дублирование в исходном тексте!');
console.log('🚨 Исходные строки:', lines.length, 'Уникальные строки:', uniqueLines.length);
// Убираем дублированные строки
processedText = uniqueLines.join('\n');
}
// Заменяем случаи, где INFO: прилипает к предыдущему тексту
// Ищем паттерн: любой текст + INFO: (но не в начале строки)
// Используем более точное регулярное выражение для поиска
processedText = processedText.replace(/([A-Za-z0-9\s]+)INFO:/g, '$1\nINFO:');
// Убираем лишние переносы строк в начале, если они есть
processedText = processedText.replace(/^\n+/, '');
// Проверяем результат
if (text.includes('FoundINFO:') && processedText !== text) {
console.log('✅ processMultiViewSpecialReplacements: Замена выполнена:', processedText);
} else if (text.includes('FoundINFO:') && processedText === text) {
console.log('❌ processMultiViewSpecialReplacements: Замена НЕ выполнена для:', text);
}
return processedText;
}
/**
* Функция для обработки специальных замен в Single View логах
* Не добавляет лишние переносы строк
* @param {string} text - исходный текст
* @returns {string} - текст с примененными заменами
*/
function processSingleViewSpecialReplacements(text) {
if (!text) return text;
let processedText = text;
// Добавляем отладочную информацию для проверки
if (text.includes('FoundINFO:')) {
console.log('🔍 processSingleViewSpecialReplacements: Найдена строка с FoundINFO:', text);
}
// Проверяем на дублирование строк в исходном тексте
const lines = processedText.split('\n');
const uniqueLines = [...new Set(lines)];
if (lines.length !== uniqueLines.length) {
console.log('🚨 processSingleViewSpecialReplacements: Обнаружено дублирование в исходном тексте!');
console.log('🚨 Исходные строки:', lines.length, 'Уникальные строки:', uniqueLines.length);
// Убираем дублированные строки
processedText = uniqueLines.join('\n');
}
// Для Single View НЕ добавляем переносы строк, только убираем дубликаты
// Убираем лишние переносы строк в начале, если они есть
processedText = processedText.replace(/^\n+/, '');
// Проверяем результат
if (text.includes('FoundINFO:') && processedText !== text) {
console.log('✅ processSingleViewSpecialReplacements: Замена выполнена:', processedText);
} else if (text.includes('FoundINFO:') && processedText === text) {
console.log('❌ processSingleViewSpecialReplacements: Замена НЕ выполнена для:', text);
}
return processedText;
}
// Тестовая функция для проверки работы processMultiViewLineBreaks
function testMultiViewLineBreaks() {
console.log('=== Тест функции processMultiViewLineBreaks ===');
console.log('Тест 1 (1 символ):', JSON.stringify(processMultiViewLineBreaks('a')));
console.log('Тест 2 (3 символа):', JSON.stringify(processMultiViewLineBreaks('abc')));
console.log('Тест 3 (5 символов):', JSON.stringify(processMultiViewLineBreaks('abcde')));
console.log('Тест 4 (6 символов):', JSON.stringify(processMultiViewLineBreaks('abcdef')));
console.log('Тест 5 (с переносами):', JSON.stringify(processMultiViewLineBreaks('a\nb\nc')));
console.log('Тест 6 (длинная строка):', JSON.stringify(processMultiViewLineBreaks('Это длинная строка с текстом')));
console.log('=== Конец теста ===');
}
// Функция для тестирования исправлений дублирования
function testDuplicateRemoval() {
console.log('=== Тест исправлений дублирования ===');
// Создаем тестовый элемент
const testElement = document.createElement('div');
testElement.innerHTML = `
<span class="line">Первая строка</span>
<span class="line">Вторая строка</span>
<span class="line">Вторая строка</span>
<span class="line">Третья строка</span>
<span class="line">Третья строка</span>
<span class="line">Четвертая строка</span>
`;
console.log('До очистки дубликатов:', testElement.querySelectorAll('.line').length, 'строк');
console.log('HTML до очистки:', testElement.innerHTML);
cleanMultiViewDuplicateLines(testElement);
console.log('После очистки дубликатов:', testElement.querySelectorAll('.line').length, 'строк');
console.log('HTML после очистки:', testElement.innerHTML);
console.log('=== Конец теста дублирования ===');
}
// Функция для тестирования Single View дублирования
function testSingleViewDuplicateRemoval() {
console.log('=== Тест Single View дублирования ===');
// Создаем тестовый элемент для Single View
const testElement = document.createElement('div');
testElement.innerHTML = `
<span class="line">INFO: 172.22.0.1:59030 - "GET /api/logs/stats/b816b3f326b9 HTTP/1.1" 200 OK</span>
<span class="line">INFO: 172.22.0.1:61392 - "GET / HTTP/1.1" 401 Unauthorized</span>
<span class="line">INFO: 172.22.0.1:61392 - "GET / HTTP/1.1" 401 Unauthorized</span>
<span class="line">INFO: 172.22.0.1:61392 - "GET / HTTP/1.1" 200 OK</span>
<span class="line">INFO: 172.22.0.1:61392 - "GET / HTTP/1.1" 200 OK</span>
<span class="line">INFO: 172.22.0.1:61392 - "GET /api/projects HTTP/1.1" 200 OK</span>
`;
console.log('До очистки Single View дубликатов:', testElement.querySelectorAll('.line').length, 'строк');
console.log('HTML до очистки:', testElement.innerHTML);
cleanDuplicateLines(testElement);
console.log('После очистки Single View дубликатов:', testElement.querySelectorAll('.line').length, 'строк');
console.log('HTML после очистки:', testElement.innerHTML);
console.log('=== Конец теста Single View дублирования ===');
}
// Функция для тестирования очистки пустых строк в Single View
function testSingleViewEmptyLinesRemoval() {
console.log('=== Тест очистки пустых строк в Single View ===');
// Создаем тестовый элемент с пустыми строками
const testElement = document.createElement('div');
testElement.innerHTML = `
<span class="line">INFO: 172.22.0.1:59030 - "GET /api/logs/stats/b816b3f326b9 HTTP/1.1" 200 OK</span>
<span class="line"> </span>
<span class="line"></span>
<span class="line">INFO: 172.22.0.1:61392 - "GET / HTTP/1.1" 401 Unauthorized</span>
<span class="line"> </span>
<span class="line">INFO: 172.22.0.1:61392 - "GET / HTTP/1.1" 200 OK</span>
<span class="line"></span>
<span class="line">INFO: 172.22.0.1:61392 - "GET /api/projects HTTP/1.1" 200 OK</span>
`;
console.log('До очистки пустых строк:', testElement.querySelectorAll('.line').length, 'строк');
console.log('HTML до очистки:', testElement.innerHTML);
cleanSingleViewEmptyLines(testElement);
console.log('После очистки пустых строк:', testElement.querySelectorAll('.line').length, 'строк');
console.log('HTML после очистки:', testElement.innerHTML);
console.log('=== Конец теста очистки пустых строк ===');
}
// Функция для тестирования правильного отображения переносов строк
function testSingleViewLineBreaks() {
console.log('=== Тест правильного отображения переносов строк в Single View ===');
// Создаем тестовый элемент с правильными переносами строк
const testElement = document.createElement('div');
testElement.innerHTML = `
<span class="line">INFO: 172.22.0.1:59030 - "GET /api/logs/stats/b816b3f326b9 HTTP/1.1" 200 OK</span>\n
<span class="line">INFO: 172.22.0.1:61392 - "GET / HTTP/1.1" 401 Unauthorized</span>\n
<span class="line">INFO: 172.22.0.1:61392 - "GET / HTTP/1.1" 200 OK</span>\n
<span class="line">INFO: 172.22.0.1:61392 - "GET /api/projects HTTP/1.1" 200 OK</span>\n
`;
console.log('Тестовый элемент с переносами строк:');
console.log('Количество строк:', testElement.querySelectorAll('.line').length);
console.log('HTML:', testElement.innerHTML);
console.log('Текстовое содержимое:', testElement.textContent);
// Проверяем, что переносы строк присутствуют
const textContent = testElement.textContent;
const lineBreaks = (textContent.match(/\n/g) || []).length;
console.log('Количество переносов строк в тексте:', lineBreaks);
console.log('=== Конец теста переносов строк ===');
}
// Тестовая функция для проверки работы cleanMultiViewEmptyLines
function testCleanMultiViewEmptyLines() {
console.log('=== Тест функции cleanMultiViewEmptyLines ===');
// Создаем тестовый элемент
const testElement = document.createElement('div');
testElement.innerHTML = `
<span class="line">Первая строка лога</span>
<span class="line"> </span>
<span class="line"></span>
<span class="line">Вторая строка лога</span>
<span class="line"> </span>
<span class="line">Третья строка лога</span>
`;
console.log('До очистки:', testElement.innerHTML);
cleanMultiViewEmptyLines(testElement);
console.log('После очистки:', testElement.innerHTML);
console.log('=== Конец теста ===');
}
// Тестовая функция для проверки работы normalizeSpaces
function testNormalizeSpaces() {
console.log('=== Тест функции normalizeSpaces ===');
console.log('Тест 1 (обычная строка):', JSON.stringify(normalizeSpaces('Hello World')));
console.log('Тест 2 (двойные пробелы):', JSON.stringify(normalizeSpaces('Hello World')));
console.log('Тест 3 (множественные пробелы):', JSON.stringify(normalizeSpaces('Hello World')));
console.log('Тест 4 (пробелы в начале и конце):', JSON.stringify(normalizeSpaces(' Hello World ')));
console.log('Тест 5 (табуляция и пробелы):', JSON.stringify(normalizeSpaces('Hello\t\tWorld')));
console.log('Тест 6 (смешанные пробелы):', JSON.stringify(normalizeSpaces('Hello \t World')));
console.log('Тест 7 (пустая строка):', JSON.stringify(normalizeSpaces('')));
console.log('Тест 8 (null):', JSON.stringify(normalizeSpaces(null)));
console.log('=== Конец теста ===');
}
// Тестовая функция для проверки работы processMultiViewSpecialReplacements
function testMultiViewSpecialReplacements() {
console.log('=== Тест функции processMultiViewSpecialReplacements ===');
console.log('Тест 1 (обычная строка):', JSON.stringify(processMultiViewSpecialReplacements('Hello World')));
console.log('Тест 2 (200 OKINFO:):', JSON.stringify(processMultiViewSpecialReplacements('200 OKINFO: Some message')));
console.log('Тест 3 (404 Not FoundINFO:):', JSON.stringify(processMultiViewSpecialReplacements('404 Not FoundINFO: Some message')));
console.log('Тест 4 (FoundINFO:):', JSON.stringify(processMultiViewSpecialReplacements('FoundINFO: Some message')));
console.log('Тест 5 (Found INFO:):', JSON.stringify(processMultiViewSpecialReplacements('Found INFO: Some message')));
console.log('Тест 6 (500 Internal Server ErrorINFO:):', JSON.stringify(processMultiViewSpecialReplacements('500 Internal Server ErrorINFO: Some message')));
console.log('Тест 7 (GET /api/usersINFO:):', JSON.stringify(processMultiViewSpecialReplacements('GET /api/usersINFO: Some message')));
console.log('Тест 8 (POST /api/loginINFO:):', JSON.stringify(processMultiViewSpecialReplacements('POST /api/loginINFO: Some message')));
console.log('Тест 9 (INFO: в начале):', JSON.stringify(processMultiViewSpecialReplacements('INFO: Some message')));
console.log('Тест 10 (несколько INFO:):', JSON.stringify(processMultiViewSpecialReplacements('200 OKINFO: First INFO: Second')));
console.log('Тест 11 (пустая строка):', JSON.stringify(processMultiViewSpecialReplacements('')));
console.log('Тест 12 (null):', JSON.stringify(processMultiViewSpecialReplacements(null)));
console.log('=== Конец теста ===');
}
// Комплексная тестовая функция для проверки полного процесса обработки MultiView
function testFullMultiViewProcessing() {
console.log('=== Тест полного процесса обработки MultiView ===');
const testCases = [
'200 OKINFO: Some message',
'404 Not FoundINFO: Another message',
'500 Internal Server ErrorINFO: Third message',
'FoundINFO: First FoundINFO: Second',
'GET /api/usersINFO: API call',
'POST /api/loginINFO: Login attempt',
'Short',
'Long message with 200 OKINFO: inside'
];
testCases.forEach((testCase, index) => {
console.log(`\nТест ${index + 1}: "${testCase}"`);
// 1. Нормализация пробелов
const normalized = normalizeSpaces(testCase);
console.log(' 1. Нормализация пробелов:', JSON.stringify(normalized));
// 2. Специальные замены
const specialProcessed = processMultiViewSpecialReplacements(normalized);
console.log(' 2. Специальные замены:', JSON.stringify(specialProcessed));
// 3. Обработка переноса строк
const finalProcessed = processMultiViewLineBreaks(specialProcessed);
console.log(' 3. Перенос строк:', JSON.stringify(finalProcessed));
console.log(' Результат:', finalProcessed);
});
console.log('\n=== Конец комплексного теста ===');
}
// Быстрая функция для тестирования замены INFO:
function quickTestINFO() {
console.log('=== Быстрый тест замены INFO: ===');
const testStrings = [
'INFO: 127.0.0.1:53352 - "GET /health HTTP/1.1" 200 OKINFO: 127.0.0.1:53352 - "GET /health HTTP/1.1" 200 OK',
'INFO: 127.0.0.1:48988 - "GET /health HTTP/1.1" 200 OKINFO: 127.0.0.1:48988 - "GET /health HTTP/1.1" 200 OK',
'INFO: 127.0.0.1:44606 - "GET /health HTTP/1.1" 200 OKINFO: 127.0.0.1:44606 - "GET /health HTTP/1.1" 200 OK',
'200 OKINFO:',
'404 Not FoundINFO:',
'500 Internal Server ErrorINFO:',
'FoundINFO:',
'INFO:'
];
testStrings.forEach((str, index) => {
const result = processMultiViewSpecialReplacements(str);
console.log(`Тест ${index + 1}: "${str}" -> "${result}"`);
});
console.log('=== Конец быстрого теста ===');
}
// Функция для тестирования регулярного выражения
function testRegex() {
console.log('=== Тест регулярного выражения ===');
const testString = 'INFO: 172.22.0.10:33860 - "POST /api/v1/auth/verify HTTP/1.1" 404 Not FoundINFO: 172.22.0.10:33860 - "POST /api/v1/auth/verify HTTP/1.1" 404 Not Found';
console.log('Исходная строка:', testString);
// Тестируем наше регулярное выражение
const result = testString.replace(/([A-Za-z0-9\s]+)INFO:/g, '$1\nINFO:');
console.log('Результат замены:', result);
// Проверяем, есть ли совпадения
const matches = testString.match(/([A-Za-z0-9\s]+)INFO:/g);
console.log('Найденные совпадения:', matches);
console.log('=== Конец теста регулярного выражения ===');
}
// Функция для проверки HTML в MultiView на наличие FoundINFO:
function checkMultiViewHTML() {
console.log('=== Проверка HTML в MultiView ===');
const multiViewLogs = document.querySelectorAll('.multi-view-log');
console.log('Найдено MultiView логов:', multiViewLogs.length);
multiViewLogs.forEach((log, index) => {
const containerId = log.getAttribute('data-container-id');
console.log(`MultiView ${index + 1} (${containerId}):`);
// Проверяем весь HTML
const html = log.innerHTML;
if (html.includes('FoundINFO:')) {
console.log('🚨 НАЙДЕНО FoundINFO: в HTML!');
console.log('HTML:', html);
} else {
console.log('✅ FoundINFO: не найдено в HTML');
}
// Проверяем текстовое содержимое
const textContent = log.textContent;
if (textContent.includes('FoundINFO:')) {
console.log('🚨 НАЙДЕНО FoundINFO: в тексте!');
console.log('Текст:', textContent);
} else {
console.log('✅ FoundINFO: не найдено в тексте');
}
});
console.log('=== Конец проверки HTML ===');
}
// Глобальная функция для обработки логов
function handleLine(id, line){
const obj = state.open[id];
if (!obj) {
console.error(`handleLine: Object not found for container ${id}, available containers:`, Object.keys(state.open));
return;
}
// Отладочная информация для первых нескольких строк
if (!obj.counters) {
console.error(`handleLine: Counters not initialized for container ${id}`);
obj.counters = {dbg:0, info:0, warn:0, err:0, other:0};
}
// Фильтруем сообщение "Connected to container" для всех режимов
// Это сообщение отправляется сервером при установке WebSocket соединения
if (line.includes('Connected to container:')) {
console.log(`handleLine: Фильтруем сообщение "Connected to container" для контейнера ${id}`);
return; // Пропускаем это сообщение во всех режимах
}
// Нормализуем пробелы в строке лога
const normalizedLine = normalizeSpaces(line);
const cls = classify(normalizedLine);
// Обновляем счетчики только для отображаемых логов
// Проверяем фильтры для отображения в зависимости от режима
let shouldShow;
if (state.multiViewMode && state.selectedContainers.includes(id)) {
// Для multi-view используем настройки конкретного контейнера
shouldShow = allowedByContainerLevel(cls, id) && applyFilter(normalizedLine);
} else {
// Для single-view используем глобальные настройки
shouldShow = allowedByLevel(cls) && applyFilter(normalizedLine);
}
// Обновляем счетчики только если строка будет отображаться
if (obj.counters && shouldShow) {
if (cls==='dbg') obj.counters.dbg++;
if (cls==='ok') obj.counters.info++;
if (cls==='warn') obj.counters.warn++;
if (cls==='err') obj.counters.err++;
if (cls==='other') obj.counters.other++;
}
// Обновляем счетчики в кнопках заголовков
updateHeaderCounters(id, obj.counters);
// Для Single View НЕ добавляем перенос строки после каждой строки лога
const html = `<span class="line ${cls}">${ansiToHtml(normalizedLine)}</span>`;
// Сохраняем все логи в буфере (всегда)
if (!obj.allLogs) obj.allLogs = [];
// Для Single View сохраняем обработанную строку, для MultiView - оригинальную
const processedLine = !state.multiViewMode ? processSingleViewSpecialReplacements(normalizedLine) : normalizedLine;
const processedHtml = `<span class="line ${cls}">${ansiToHtml(processedLine)}</span>`;
obj.allLogs.push({html: processedHtml, line: processedLine, cls: cls});
// Ограничиваем размер буфера
if (obj.allLogs.length > 10000) {
obj.allLogs = obj.allLogs.slice(-5000);
}
// Добавляем логи в отображение (обычный просмотр) - только если НЕ в multi-view режиме
if (shouldShow && obj.logEl && !state.multiViewMode) {
// Обрабатываем строку для Single View (без лишних переносов строк)
const singleViewProcessedLine = processSingleViewSpecialReplacements(normalizedLine);
// Проверяем на дублирование в Single View логах
const existingLines = Array.from(obj.logEl.querySelectorAll('.line'));
const lastLine = existingLines[existingLines.length - 1];
if (lastLine && lastLine.textContent === singleViewProcessedLine) {
console.log(`handleLine: Пропускаем дублированную строку для Single View контейнера ${id}:`, singleViewProcessedLine.substring(0, 50));
return; // Пропускаем дублированную строку
}
// Создаем HTML с обработанной строкой для Single View (без переноса строки)
const singleViewHtml = `<span class="line ${cls}">${ansiToHtml(singleViewProcessedLine)}</span>`;
obj.logEl.insertAdjacentHTML('beforeend', singleViewHtml);
// Очищаем лишние пустые строки в Single View
cleanSingleViewEmptyLines(obj.logEl);
if (els.autoscroll && els.autoscroll.checked && obj.wrapEl) {
obj.wrapEl.scrollTop = obj.wrapEl.scrollHeight;
}
// Update modern interface
console.log(`handleLine: Checking modern interface update - state.current:`, state.current, `id:`, id, `els.logContent:`, !!els.logContent);
if (state.current && state.current.id === id && els.logContent) {
console.log(`handleLine: Updating modern interface for container ${id} with html:`, singleViewHtml.substring(0, 100));
// Добавляем новую строку напрямую в современный интерфейс
els.logContent.insertAdjacentHTML('beforeend', singleViewHtml);
// Очищаем лишние пустые строки в современном интерфейсе
cleanSingleViewEmptyLines(els.logContent);
console.log(`handleLine: Modern interface updated, logContent children count:`, els.logContent.children.length);
if (els.autoscroll && els.autoscroll.checked) {
els.logContent.scrollTop = els.logContent.scrollHeight;
}
} else {
console.log(`handleLine: Modern interface update skipped - state.current:`, state.current?.id, `id:`, id, `logContent exists:`, !!els.logContent);
}
}
// Update multi-view interface
if (state.multiViewMode && state.selectedContainers.includes(id)) {
const multiViewLog = document.querySelector(`.multi-view-log[data-container-id="${id}"]`);
if (multiViewLog) {
// Проверяем фильтры для конкретного контейнера
const shouldShowInMultiView = allowedByContainerLevel(cls, id) && applyFilter(normalizedLine);
if (shouldShowInMultiView) {
// Применяем ограничение tail lines в multi view
const tailLines = parseInt(els.tail.value) || 50;
// Порядок обработки строк для MultiView:
// 1. Нормализация пробелов (уже выполнена выше)
// 2. Специальные замены (например, "FoundINFO:" -> "Found\nINFO:")
// 3. Обработка переноса строк
const specialProcessedLine = processMultiViewSpecialReplacements(normalizedLine);
// Обрабатываем перенос строк для multi view
// Если символов больше 5, то перенос строк работает
// Если меньше 5, то переноса строк нет
const processedLine = processMultiViewLineBreaks(specialProcessedLine);
// Проверяем на дублирование в multi-view логах
const existingLines = Array.from(multiViewLog.querySelectorAll('.line'));
const lastLine = existingLines[existingLines.length - 1];
if (lastLine && lastLine.textContent === processedLine) {
console.log(`handleLine: Пропускаем дублированную строку для контейнера ${id}:`, processedLine.substring(0, 50));
return; // Пропускаем дублированную строку
}
const multiViewHtml = `<span class="line ${cls}">${ansiToHtml(processedLine)}</span>`;
// Добавляем новую строку
multiViewLog.insertAdjacentHTML('beforeend', multiViewHtml);
// Очищаем пустые строки в multi view
cleanMultiViewEmptyLines(multiViewLog);
// Очищаем дублированные строки в multi view
cleanMultiViewDuplicateLines(multiViewLog);
// Ограничиваем количество отображаемых строк
const logLines = Array.from(multiViewLog.querySelectorAll('.line'));
if (logLines.length > tailLines) {
// Удаляем лишние строки с начала
const linesToRemove = logLines.length - tailLines;
console.log(`handleLine: Trimming ${linesToRemove} lines from container ${id} (tail: ${tailLines})`);
// Удаляем первые N строк
logLines.slice(0, linesToRemove).forEach(line => {
line.remove();
});
}
if (els.autoscroll && els.autoscroll.checked) {
multiViewLog.scrollTop = multiViewLog.scrollHeight;
}
console.log(`handleLine: Updated multi-view for container ${id}, log element found: true, tail lines: ${tailLines}`);
}
} else {
console.error(`handleLine: Multi-view log element not found for container ${id}`);
}
// Обновляем счетчики в multi view периодически (каждые 10 строк)
if (!state.multiViewCounterUpdateTimer) {
state.multiViewCounterUpdateTimer = setTimeout(() => {
updateMultiViewCounters();
// Периодически очищаем дублированные строки во всех multi-view логах
state.selectedContainers.forEach(containerId => {
const multiViewLog = document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`);
if (multiViewLog) {
cleanMultiViewDuplicateLines(multiViewLog);
}
});
state.multiViewCounterUpdateTimer = null;
}, 1000); // Обновляем каждую секунду
}
}
}
function ensurePanel(svc){
let panel = els.grid.querySelector(`.panel[data-cid="${svc.id}"]`);
if (!panel){
panel = panelTemplate(svc);
els.grid.appendChild(panel);
panel.querySelector('.t-reconnect').onclick = ()=>{
const id = svc.id;
const o = state.open[id];
if (o){ o.logEl.textContent=''; closeWs(id); }
openWs(svc, panel);
};
panel.querySelector('.t-close').onclick = ()=>{
closeWs(svc.id);
panel.remove();
if (!Object.keys(state.open).length) setWsState('off');
};
panel.querySelector('.t-snapshot').onclick = ()=> sendSnapshot(svc.id);
}
return panel;
}
async function switchToSingle(svc){
console.log('switchToSingle: ENTRY POINT - function called - VERSION 2');
console.log('switchToSingle: svc parameter:', svc);
try {
console.log('switchToSingle called for:', svc.name);
console.log('switchToSingle: Starting function execution');
console.log('switchToSingle: svc name:', svc.name, 'id:', svc.id);
console.log('switchToSingle: state.current:', state.current?.name, 'multiViewMode:', state.multiViewMode);
// Всегда очищаем мультипросмотр при переключении в single view
console.log('Clearing multi-view mode');
state.multiViewMode = false;
// Закрываем WebSocket соединения для мультипросмотра
console.log('switchToSingle: Closing WebSocket connections for multi-view');
state.selectedContainers.forEach(containerId => {
closeWs(containerId);
});
// Очищаем область логов
console.log('switchToSingle: Clearing log content');
if (els.logContent) {
els.logContent.innerHTML = '';
}
// Воссоздаем single-view-panel если его нет
const logContent = document.querySelector('.log-content');
const singleViewPanel = document.getElementById('singleViewPanel');
if (logContent && !singleViewPanel) {
console.log('switchToSingle: Recreating single-view-panel');
logContent.innerHTML = `
<div class="single-view-panel" id="singleViewPanel">
<div class="single-view-header">
<h4 class="single-view-title" id="singleViewTitle">${svc.name} (${svc.service || svc.name})</h4>
<div class="single-view-levels">
<button class="level-btn debug-btn" data-level="debug" title="DEBUG">
<span class="level-label">DEBUG</span>
<span class="level-value" data-container="single">0</span>
</button>
<button class="level-btn info-btn" data-level="info" title="INFO">
<span class="level-label">INFO</span>
<span class="level-value" data-container="single">0</span>
</button>
<button class="level-btn warn-btn" data-level="warn" title="WARN">
<span class="level-label">WARN</span>
<span class="level-value" data-container="single">0</span>
</button>
<button class="level-btn error-btn" data-level="err" title="ERROR">
<span class="level-label">ERROR</span>
<span class="level-value" data-container="single">0</span>
</button>
<button class="level-btn other-btn" data-level="other" title="OTHER">
<span class="level-label">OTHER</span>
<span class="level-value" data-container="single">0</span>
</button>
</div>
</div>
<div class="single-view-content">
<pre class="log" id="logContent">Connecting...</pre>
</div>
</div>
`;
// Обновляем ссылки на элементы
els.singleViewPanel = document.getElementById('singleViewPanel');
els.singleViewTitle = document.getElementById('singleViewTitle');
els.logContent = document.getElementById('logContent');
}
// Удаляем мультипросмотр из DOM
const multiViewGrid = document.getElementById('multiViewGrid');
if (multiViewGrid) {
console.log('Removing multi-view grid from DOM');
multiViewGrid.remove();
} else {
console.log('Multi-view grid not found in DOM');
}
// Legacy functionality (скрытая)
console.log('switchToSingle: Setting up legacy functionality');
setLayout('tabs');
els.grid.innerHTML='';
const panel = ensurePanel(svc);
panel.querySelector('.log').textContent='';
closeWs(svc.id);
console.log('switchToSingle: Calling openWs for:', svc.name, 'id:', svc.id);
openWs(svc, panel);
state.current = svc;
console.log('switchToSingle: Set state.current to:', svc.name, 'id:', svc.id);
console.log('switchToSingle: state.current after setting:', state.current);
buildTabs();
for (const p of [...els.grid.children]) if (p!==panel) p.remove();
// Обновляем состояние выбранных контейнеров для корректного отображения заголовка
state.selectedContainers = [svc.id];
// Сохраняем режим просмотра в localStorage
saveViewMode(false, [svc.id]);
// Сохраняем состояние кнопок loglevels в localStorage
saveLogLevelsState();
if (els.multiViewPanelTitle) {
els.multiViewPanelTitle.textContent = `${svc.name} (${svc.service || svc.name})`;
}
if (els.singleViewTitle) {
els.singleViewTitle.textContent = `${svc.name} (${svc.service || svc.name})`;
}
if (els.logContent) {
els.logContent.innerHTML = '<span class="line info">Connecting...</span>';
}
// Обновляем logEl для современного интерфейса
const obj = state.open[svc.id];
if (obj && els.logContent) {
obj.logEl = els.logContent;
obj.wrapEl = els.logContent.parentElement;
console.log('switchToSingle: Updated obj.logEl and obj.wrapEl for modern interface');
// Если у нас уже есть логи в буфере, отображаем их
if (obj.allLogs && obj.allLogs.length > 0) {
console.log(`switchToSingle: Restoring ${obj.allLogs.length} buffered log lines`);
els.logContent.innerHTML = '';
obj.allLogs.forEach(logEntry => {
if (allowedByLevel(logEntry.cls) && applyFilter(logEntry.line)) {
els.logContent.insertAdjacentHTML('beforeend', logEntry.html);
}
});
// Очищаем лишние пустые строки после восстановления логов
cleanSingleViewEmptyLines(els.logContent);
cleanDuplicateLines(els.logContent);
if (els.autoscroll && els.autoscroll.checked) {
els.logContent.scrollTop = els.logContent.scrollHeight;
}
}
}
// Update active state in container list
document.querySelectorAll('.container-item').forEach(item => {
item.classList.remove('active');
});
const activeItem = document.querySelector(`.container-item[data-cid="${svc.id}"]`);
if (activeItem) {
activeItem.classList.add('active');
}
// Обновляем состояние чекбоксов после переключения контейнера
updateContainerSelectionUI();
// Обновляем счетчики для нового контейнера
setTimeout(() => {
recalculateCounters();
// Применяем настройки wrap text после переключения контейнера
applyWrapSettings();
}, 500); // Небольшая задержка для завершения загрузки логов
await updateCounters(svc.id);
// Добавляем обработчики для счетчиков после переключения контейнера
addCounterClickHandlers();
// Обновляем состояние кнопок уровней логирования
setTimeout(() => {
initializeLevelButtons();
}, 100);
} catch (error) {
console.error('switchToSingle: Error occurred:', error);
console.error('switchToSingle: Error stack:', error.stack);
}
}
async function openMulti(ids){
els.grid.innerHTML='';
const chosen = state.services.filter(s=> ids.includes(s.id));
const n = chosen.length;
if (n<=1){ if (n===1) await switchToSingle(chosen[0]); return; }
setLayout(n>=4 ? 'grid4' : n===3 ? 'grid3' : 'grid2');
for (const svc of chosen){
const panel = ensurePanel(svc);
panel.querySelector('.log').textContent='';
closeWs(svc.id);
openWs(svc, panel);
}
// Добавляем обработчики для счетчиков после открытия мульти-контейнеров
addCounterClickHandlers();
// Применяем настройки wrap text после открытия мульти-контейнеров
applyWrapSettings();
}
// ----- Copy on selection -----
function getSelectionText(){
const sel = window.getSelection();
return sel && sel.rangeCount ? sel.toString() : "";
}
function showCopyFabNearSelection(){
const sel = window.getSelection();
if (!sel || sel.rangeCount===0) return hideCopyFab();
const text = sel.toString();
if (!text.trim()) return hideCopyFab();
// Only show if selection inside a .log or .logwrap
const range = sel.getRangeAt(0);
const common = range.commonAncestorContainer;
const el = common.nodeType===1 ? common : common.parentElement;
if (!el || !el.closest('.logwrap')) return hideCopyFab();
const rect = range.getBoundingClientRect();
const top = rect.bottom + 8 + window.scrollY;
const left = rect.right + 8 + window.scrollX;
els.copyFab.style.top = top + 'px';
els.copyFab.style.left = left + 'px';
els.copyFab.classList.add('show');
}
function hideCopyFab(){
els.copyFab.classList.remove('show');
}
document.addEventListener('selectionchange', ()=>{
// throttle-ish using requestAnimationFrame
window.requestAnimationFrame(showCopyFabNearSelection);
});
document.addEventListener('scroll', hideCopyFab, true);
els.copyFab.addEventListener('click', async ()=>{
const text = getSelectionText();
if (!text) return;
try {
await navigator.clipboard.writeText(text);
const old = els.copyFab.textContent;
els.copyFab.textContent = 'скопировано';
setTimeout(()=> els.copyFab.textContent = old, 1000);
hideCopyFab();
window.getSelection()?.removeAllRanges();
} catch(e){
alert('не удалось скопировать: ' + e);
}
});
function fanGroupUrl(servicesCsv, project){
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const tail = els.tail.value || '500';
const token = encodeURIComponent(localStorage.getItem('access_token') || '');
const pj = project?`&project=${encodeURIComponent(project)}`:'';
return `${proto}://${location.host}/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 = ()=> setWsState('on');
ws.onclose = ()=> setWsState(Object.keys(state.open).length? 'on':'off');
ws.onerror = ()=> setWsState('err');
ws.onmessage = (ev)=>{
const parts = (ev.data||'').split(/\r?\n/);
for (let i=0;i<parts.length;i++){
if (parts[i].length===0 && i===parts.length-1) continue;
const pr = parsePrefixAndStrip(parts[i]);
if (pr){ if (!(pr.id8 in inst.filters)) { inst.filters[pr.id8] = true; updateIdFiltersBar(); } }
handleLine(id, parts[i]);
}
cdbg.textContent = counters.dbg;
cinfo.textContent = counters.info;
cwarn.textContent = counters.warn;
cerr.textContent = counters.err;
if (cother) cother.textContent = counters.other;
// Обновляем видимость счетчиков
updateCounterVisibility();
// Добавляем обработчики для счетчиков
addCounterClickHandlers();
};
// Show filter bar and clear previous filters
inst.filters = {};
updateIdFiltersBar();
}
if (els.groupBtn && els.groupBtn.onclick !== null) {
els.groupBtn.onclick = ()=>{
const list = state.services.map(s=> `${s.service}`).filter((v,i,a)=> a.indexOf(v)===i).join(', ');
const ans = prompt('Введите имена сервисов через запятую:\n'+list);
if (ans){
const services = ans.split(',').map(x=>x.trim()).filter(Boolean);
if (services.length) openFanGroup(services);
}
};
}
// Функция для обновления счетчиков через Ajax
async function updateCounters(containerId) {
try {
const token = localStorage.getItem('access_token');
if (!token) {
console.error('No access token found');
return;
}
const response = await fetch(`/api/logs/stats/${containerId}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
const stats = await response.json();
const cdbg = document.querySelector('.cdbg');
const cinfo = document.querySelector('.cinfo');
const cwarn = document.querySelector('.cwarn');
const cerr = document.querySelector('.cerr');
const cother = document.querySelector('.cother');
if (cdbg) cdbg.textContent = stats.debug || 0;
if (cinfo) cinfo.textContent = stats.info || 0;
if (cwarn) cwarn.textContent = stats.warn || 0;
if (cerr) cerr.textContent = stats.error || 0;
if (cother) cother.textContent = stats.other || 0;
// Обновляем видимость счетчиков
updateCounterVisibility();
// Добавляем обработчики для счетчиков
addCounterClickHandlers();
// Очищаем дублированные строки в Single View режиме
if (!state.multiViewMode && state.current && state.current.id === containerId) {
const logContent = document.querySelector('.log-content');
if (logContent) {
cleanDuplicateLines(logContent);
cleanSingleViewEmptyLines(logContent);
}
// Также очищаем в legacy панели
const obj = state.open[containerId];
if (obj && obj.logEl) {
cleanDuplicateLines(obj.logEl);
cleanSingleViewEmptyLines(obj.logEl);
}
}
}
} catch (error) {
console.error('Error updating counters:', error);
}
}
// Функция для обновления счетчиков в multi view (суммирует статистику всех контейнеров)
// Эта функция теперь использует пересчет на основе отображаемых логов
async function updateMultiViewCounters() {
if (!state.multiViewMode || state.selectedContainers.length === 0) {
return;
}
try {
console.log('Updating multi-view counters for containers:', state.selectedContainers);
// Используем новую функцию пересчета счетчиков
recalculateMultiViewCounters();
// Добавляем обработчики для счетчиков
addCounterClickHandlers();
} catch (error) {
console.error('Error updating multi-view counters:', error);
}
}
// Функция для пересчета счетчиков на основе отображаемых логов (Single View)
function recalculateCounters() {
if (!state.current) return;
const containerId = state.current.id;
const obj = state.open[containerId];
if (!obj || !obj.allLogs) return;
// Получаем значение Tail Lines
const tailLines = parseInt(els.tail.value) || 50;
// Берем только последние N логов в соответствии с Tail Lines
const visibleLogs = obj.allLogs.slice(-tailLines);
// Сбрасываем счетчики
obj.counters = {dbg: 0, info: 0, warn: 0, err: 0, other: 0};
// Пересчитываем счетчики только для отображаемых логов
visibleLogs.forEach(logEntry => {
const shouldShow = allowedByLevel(logEntry.cls) && applyFilter(logEntry.line);
if (shouldShow) {
if (logEntry.cls === 'dbg') obj.counters.dbg++;
if (logEntry.cls === 'ok') obj.counters.info++;
if (logEntry.cls === 'warn') obj.counters.warn++;
if (logEntry.cls === 'err') obj.counters.err++;
if (logEntry.cls === 'other') obj.counters.other++;
}
});
// Обновляем отображение счетчиков
const cdbg = document.querySelector('.cdbg');
const cinfo = document.querySelector('.cinfo');
const cwarn = document.querySelector('.cwarn');
const cerr = document.querySelector('.cerr');
const cother = document.querySelector('.cother');
if (cdbg) cdbg.textContent = obj.counters.dbg;
if (cinfo) cinfo.textContent = obj.counters.info;
if (cwarn) cwarn.textContent = obj.counters.warn;
if (cerr) cerr.textContent = obj.counters.err;
if (cother) cother.textContent = obj.counters.other;
// Обновляем счетчики в кнопках заголовка single-view
updateHeaderCounters(containerId, obj.counters);
console.log(`Counters recalculated for container ${containerId} (tail: ${tailLines}):`, obj.counters);
}
// Функция для пересчета счетчиков в MultiView на основе отображаемых логов
function recalculateMultiViewCounters() {
if (!state.multiViewMode || state.selectedContainers.length === 0) {
return;
}
console.log('Recalculating multi-view counters for containers:', state.selectedContainers);
// Получаем значение Tail Lines
const tailLines = parseInt(els.tail.value) || 50;
// Суммируем статистику всех выбранных контейнеров
let totalDebug = 0;
let totalInfo = 0;
let totalWarn = 0;
let totalError = 0;
let totalOther = 0;
// Пересчитываем счетчики для каждого контейнера
for (const containerId of state.selectedContainers) {
const obj = state.open[containerId];
if (!obj || !obj.allLogs) continue;
// Берем только последние N логов в соответствии с Tail Lines
const visibleLogs = obj.allLogs.slice(-tailLines);
// Сбрасываем счетчики для этого контейнера
obj.counters = {dbg: 0, info: 0, warn: 0, err: 0, other: 0};
// Пересчитываем счетчики только для отображаемых логов
visibleLogs.forEach(logEntry => {
const shouldShow = allowedByContainerLevel(logEntry.cls, containerId) && applyFilter(logEntry.line);
if (shouldShow) {
if (logEntry.cls === 'dbg') obj.counters.dbg++;
if (logEntry.cls === 'ok') obj.counters.info++;
if (logEntry.cls === 'warn') obj.counters.warn++;
if (logEntry.cls === 'err') obj.counters.err++;
if (logEntry.cls === 'other') obj.counters.other++;
}
});
// Обновляем счетчики в кнопках заголовка для этого контейнера
updateHeaderCounters(containerId, obj.counters);
// Добавляем к общим счетчикам
totalDebug += obj.counters.dbg;
totalInfo += obj.counters.info;
totalWarn += obj.counters.warn;
totalError += obj.counters.err;
totalOther += obj.counters.other;
}
// Обновляем отображение счетчиков
const cdbg = document.querySelector('.cdbg');
const cinfo = document.querySelector('.cinfo');
const cwarn = document.querySelector('.cwarn');
const cerr = document.querySelector('.cerr');
const cother = document.querySelector('.cother');
if (cdbg) cdbg.textContent = totalDebug;
if (cinfo) cinfo.textContent = totalInfo;
if (cwarn) cwarn.textContent = totalWarn;
if (cerr) cerr.textContent = totalError;
if (cother) cother.textContent = totalOther;
console.log(`Multi-view counters recalculated (tail: ${tailLines}):`, { totalDebug, totalInfo, totalWarn, totalError, totalOther });
}
// Функция для обновления видимости счетчиков
function updateCounterVisibility() {
// Обновляем старые кнопки счетчиков (только для legacy интерфейса)
const debugBtn = document.querySelector('.debug-btn');
const infoBtn = document.querySelector('.info-btn');
const warnBtn = document.querySelector('.warn-btn');
const errorBtn = document.querySelector('.error-btn');
const otherBtn = document.querySelector('.other-btn');
if (debugBtn) {
debugBtn.classList.toggle('disabled', !state.levels.debug);
}
if (infoBtn) {
infoBtn.classList.toggle('disabled', !state.levels.info);
}
if (warnBtn) {
warnBtn.classList.toggle('disabled', !state.levels.warn);
}
if (errorBtn) {
errorBtn.classList.toggle('disabled', !state.levels.err);
}
if (otherBtn) {
otherBtn.classList.toggle('disabled', !state.levels.other);
}
}
// Функция для управления видимостью кнопок LogLevels
function updateLogLevelsVisibility() {
const singleViewLevels = document.querySelector('.single-view-levels');
const multiViewLevels = document.querySelectorAll('.multi-view-levels');
// Проверяем, есть ли выбранные контейнеры
const hasSelectedContainers = state.selectedContainers.length > 0 || state.current;
// Управляем видимостью кнопок в single view
if (singleViewLevels) {
if (hasSelectedContainers) {
singleViewLevels.style.display = 'flex';
} else {
singleViewLevels.style.display = 'none';
}
}
// Управляем видимостью кнопок в multi view
multiViewLevels.forEach(levelsContainer => {
if (hasSelectedContainers) {
levelsContainer.style.display = 'flex';
} else {
levelsContainer.style.display = 'none';
}
});
}
// Функция для обновления логов и счетчиков
async function refreshLogsAndCounters() {
if (state.multiViewMode && state.selectedContainers.length > 0) {
// Обновляем мультипросмотр
console.log('Refreshing multi-view for containers:', state.selectedContainers);
// Очищаем логи в мультипросмотре перед обновлением
state.selectedContainers.forEach(containerId => {
const multiViewLog = document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`);
if (multiViewLog) {
multiViewLog.textContent = 'Refreshing...';
}
// Очищаем буфер логов для мультипросмотра
const obj = state.open[containerId];
if (obj && obj.allLogs) {
obj.allLogs = [];
}
});
// Перезапускаем WebSocket соединения для всех выбранных контейнеров
state.selectedContainers.forEach(containerId => {
closeWs(containerId);
const service = state.services.find(s => s.id === containerId);
if (service) {
openMultiViewWs(service);
}
});
// Пересчитываем счетчики на основе отображаемых логов
setTimeout(() => {
recalculateMultiViewCounters();
// Применяем настройки wrap text после обновления
applyWrapSettings();
}, 1000); // Небольшая задержка для завершения переподключения
} else if (state.current) {
// Обычный режим просмотра
console.log('Refreshing logs and counters for:', state.current.id);
// Очищаем логи перед обновлением
if (els.logContent) {
els.logContent.textContent = 'Refreshing...';
}
// Перезапускаем WebSocket соединение для получения свежих логов
const currentId = state.current.id;
closeWs(currentId);
// Находим обновленный контейнер в списке
const updatedContainer = state.services.find(s => s.id === currentId);
if (updatedContainer) {
// Переключаемся на обновленный контейнер
await switchToSingle(updatedContainer);
// Очищаем лишние пустые строки после переключения
if (els.logContent) {
cleanSingleViewEmptyLines(els.logContent);
cleanDuplicateLines(els.logContent);
}
}
// Пересчитываем счетчики на основе отображаемых логов
setTimeout(() => {
recalculateCounters();
// Применяем настройки wrap text после обновления
applyWrapSettings();
}, 1000); // Небольшая задержка для завершения переподключения
} else {
console.log('No container selected');
}
}
// Controls
els.clearBtn.onclick = ()=> {
// Очищаем обычный просмотр
Object.values(state.open).forEach(o => {
if (o.logEl) o.logEl.textContent = '';
if (o.allLogs) o.allLogs = []; // Очищаем буфер логов
});
// Очищаем современный интерфейс
if (els.logContent) {
els.logContent.textContent = '';
// Очищаем лишние пустые строки после очистки
cleanSingleViewEmptyLines(els.logContent);
cleanDuplicateLines(els.logContent);
}
// Очищаем мультипросмотр
if (state.multiViewMode) {
state.selectedContainers.forEach(containerId => {
const multiViewLog = document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`);
if (multiViewLog) {
multiViewLog.textContent = '';
}
// Очищаем буфер логов для мультипросмотра
const obj = state.open[containerId];
if (obj && obj.allLogs) {
obj.allLogs = [];
}
});
}
// Сбрасываем счетчики
document.querySelectorAll('.cdbg, .cinfo, .cwarn, .cerr').forEach(el => {
el.textContent = '0';
});
// Сбрасываем счетчики в объектах состояния
Object.values(state.open).forEach(obj => {
if (obj.counters) {
obj.counters = {dbg: 0, info: 0, warn: 0, err: 0};
}
});
};
els.refreshBtn.onclick = async () => {
console.log('Refreshing services...');
await fetchServices();
if (state.multiViewMode && state.selectedContainers.length > 0) {
// В Multi View режиме обновляем все выбранные контейнеры
console.log('Refreshing Multi View mode with containers:', state.selectedContainers);
// Закрываем все текущие соединения
state.selectedContainers.forEach(containerId => {
closeWs(containerId);
});
// Перезапускаем соединения для всех выбранных контейнеров
state.selectedContainers.forEach(containerId => {
const service = state.services.find(s => s.id === containerId);
if (service) {
openMultiViewWs(service);
}
});
// Очищаем логи в мультипросмотре
state.selectedContainers.forEach(containerId => {
const multiViewLog = document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`);
if (multiViewLog) {
multiViewLog.textContent = 'Refreshing...';
}
});
} else if (state.current) {
// Обычный режим просмотра
console.log('Reconnecting to current container:', state.current.id);
const currentId = state.current.id;
// Закрываем текущее соединение
closeWs(currentId);
// Находим обновленный контейнер в списке
const updatedContainer = state.services.find(s => s.id === currentId);
if (updatedContainer) {
// Переключаемся на обновленный контейнер
await switchToSingle(updatedContainer);
} else {
// Если контейнер больше не существует, не открываем автоматически первый доступный
// Пользователь сам выберет нужный контейнер
console.log('Container no longer exists, not auto-opening first available container');
}
}
};
// Обработчик для кнопок refresh логов (в log-header и в header)
document.querySelectorAll('.log-refresh-btn').forEach(btn=>{
btn.addEventListener('click', refreshLogsAndCounters);
});
// Обработчики для счетчиков
function addCounterClickHandlers() {
// Назначаем обработчики на все дубликаты кнопок (и в log-header, и в header-compact-controls)
const debugButtons = document.querySelectorAll('.debug-btn');
const infoButtons = document.querySelectorAll('.info-btn');
const warnButtons = document.querySelectorAll('.warn-btn');
const errorButtons = document.querySelectorAll('.error-btn');
const otherButtons = document.querySelectorAll('.other-btn');
debugButtons.forEach(debugBtn => debugBtn.onclick = () => {
state.levels.debug = !state.levels.debug;
updateCounterVisibility();
refreshAllLogs();
// Добавляем refresh для обновления логов
if (state.current) {
refreshLogsAndCounters();
}
// Обновляем multi-view если он активен
if (state.multiViewMode) {
refreshAllLogs();
}
});
infoButtons.forEach(infoBtn => infoBtn.onclick = () => {
state.levels.info = !state.levels.info;
updateCounterVisibility();
refreshAllLogs();
// Добавляем refresh для обновления логов
if (state.current) {
refreshLogsAndCounters();
}
// Обновляем multi-view если он активен
if (state.multiViewMode) {
refreshAllLogs();
}
});
warnButtons.forEach(warnBtn => warnBtn.onclick = () => {
state.levels.warn = !state.levels.warn;
updateCounterVisibility();
refreshAllLogs();
// Добавляем refresh для обновления логов
if (state.current) {
refreshLogsAndCounters();
}
// Обновляем multi-view если он активен
if (state.multiViewMode) {
refreshAllLogs();
}
});
errorButtons.forEach(errorBtn => errorBtn.onclick = () => {
state.levels.err = !state.levels.err;
updateCounterVisibility();
refreshAllLogs();
// Добавляем refresh для обновления логов
if (state.current) {
refreshLogsAndCounters();
}
// Обновляем multi-view если он активен
if (state.multiViewMode) {
refreshAllLogs();
}
});
otherButtons.forEach(otherBtn => otherBtn.onclick = () => {
state.levels.other = !state.levels.other;
updateCounterVisibility();
refreshAllLogs();
// Добавляем refresh для обновления логов
if (state.current) {
refreshLogsAndCounters();
}
// Обновляем multi-view если он активен
if (state.multiViewMode) {
refreshAllLogs();
}
});
}
// Функция для добавления обработчиков мультивыбора проектов
function addMultiSelectHandlers() {
const display = document.getElementById('projectSelectDisplay');
const dropdown = document.getElementById('projectSelectDropdown');
console.log('Adding multi-select handlers, elements found:', {display: !!display, dropdown: !!dropdown});
if (display && dropdown) {
// Обработчик клика по дисплею
display.addEventListener('click', (e) => {
e.stopPropagation();
const isOpen = dropdown.style.display !== 'none';
if (isOpen) {
dropdown.style.display = 'none';
display.classList.remove('active');
} else {
dropdown.style.display = 'block';
display.classList.add('active');
}
});
// Обработчики кликов по опциям
dropdown.addEventListener('click', async (e) => {
e.stopPropagation();
const option = e.target.closest('.multi-select-option');
if (!option) return;
const checkbox = option.querySelector('input[type="checkbox"]');
if (!checkbox) return;
const value = option.getAttribute('data-value');
// Специальная логика для "All Projects"
if (value === 'all') {
if (checkbox.checked) {
// Если "All Projects" выбран, снимаем все остальные
dropdown.querySelectorAll('.multi-select-option input[type="checkbox"]').forEach(cb => {
if (cb !== checkbox) cb.checked = false;
});
}
} else {
// Если выбран конкретный проект, снимаем "All Projects"
const allCheckbox = dropdown.querySelector('#project-all');
if (allCheckbox) allCheckbox.checked = false;
}
// Обновляем отображение и загружаем сервисы
const selectedProjects = getSelectedProjects();
updateMultiSelect(selectedProjects);
await fetchServices();
});
// Закрытие dropdown при клике вне его
document.addEventListener('click', (e) => {
if (!display.contains(e.target) && !dropdown.contains(e.target)) {
dropdown.style.display = 'none';
display.classList.remove('active');
}
});
}
}
// Функция для показа уведомления о горячих клавишах
function showHotkeysNotification() {
// Создаем уведомление
const notification = document.createElement('div');
notification.className = 'hotkeys-notification';
notification.innerHTML = `
<div class="notification-content">
<h4><i class="fas fa-keyboard"></i> Горячие клавиши</h4>
<ul>
<li><kbd>[</kbd> <kbd>]</kbd> - Навигация между контейнерами</li>
<li><kbd>Ctrl</kbd> + <kbd>R</kbd> или <kbd>Ctrl</kbd> + <kbd>K</kbd> - Обновить логи</li>
<li><kbd>Ctrl</kbd> + <kbd>B</kbd> - Свернуть/развернуть панель</li>
<li>Кнопка <i class="fas fa-chevron-left"></i> - управление панелью</li>
</ul>
<button class="notification-close" onclick="this.parentElement.parentElement.remove()">
<i class="fas fa-times"></i>
</button>
</div>
`;
document.body.appendChild(notification);
// Автоматически скрываем через 8 секунд
setTimeout(() => {
if (notification.parentElement) {
notification.remove();
}
}, 8000);
}
// Функция для сворачивания/разворачивания sidebar и header
function toggleSidebar() {
if (els.sidebar) {
const isCollapsed = els.sidebar.classList.contains('collapsed');
if (isCollapsed) {
// Разворачиваем sidebar
els.sidebar.classList.remove('collapsed');
els.sidebarToggle.innerHTML = '<i class="fas fa-chevron-left"></i>';
els.sidebarToggle.title = 'Свернуть панель (Ctrl+B / Ctrl+И)';
localStorage.setItem('lb_sidebar_collapsed', 'false');
} else {
// Сворачиваем sidebar
els.sidebar.classList.add('collapsed');
els.sidebarToggle.innerHTML = '<i class="fas fa-chevron-right"></i>';
els.sidebarToggle.title = 'Развернуть панель (Ctrl+B / Ctrl+И)';
localStorage.setItem('lb_sidebar_collapsed', 'true');
}
// Принудительно обновляем стили логов после переключения sidebar
setTimeout(() => {
updateLogStyles();
// Дополнительная проверка для multi-view логов
if (state.multiViewMode) {
console.log('Sidebar toggle: Force fixing multi-view styles');
forceFixMultiViewStyles();
}
}, 100);
}
}
// Функция для принудительного исправления стилей multi-view логов
function forceFixMultiViewStyles() {
const multiViewLogs = document.querySelectorAll('.multi-view-content .multi-view-log');
console.log(`Force fixing styles for ${multiViewLogs.length} multi-view logs`);
multiViewLogs.forEach((log, index) => {
const containerId = log.getAttribute('data-container-id');
console.log(`Force fixing multi-view log ${index + 1} for container: ${containerId}`);
// Универсальное исправление для всех контейнеров
log.style.setProperty('height', '100%', 'important');
log.style.setProperty('overflow', 'auto', 'important');
log.style.setProperty('max-height', 'none', 'important');
log.style.setProperty('display', 'block', 'important');
log.style.setProperty('min-height', '200px', 'important');
log.style.setProperty('position', 'relative', 'important');
log.style.setProperty('flex', '1', 'important');
log.style.setProperty('min-height', '0', 'important');
log.style.setProperty('width', '100%', 'important');
log.style.setProperty('box-sizing', 'border-box', 'important');
// Принудительно вызываем пересчет layout
log.style.setProperty('transform', 'translateZ(0)', 'important');
// Устанавливаем универсальные inline стили для всех контейнеров
const currentStyle = log.getAttribute('style') || '';
const newStyle = currentStyle + '; height: 100% !important; overflow: auto !important; flex: 1 !important; min-height: 0 !important; width: 100% !important; box-sizing: border-box !important; display: block !important; position: relative !important;';
log.setAttribute('style', newStyle);
// Проверяем и исправляем родительские элементы для всех контейнеров
const parentContent = log.closest('.multi-view-content');
if (parentContent) {
parentContent.style.setProperty('display', 'flex', 'important');
parentContent.style.setProperty('flex-direction', 'column', 'important');
parentContent.style.setProperty('overflow', 'hidden', 'important');
parentContent.style.setProperty('height', '100%', 'important');
}
const parentPanel = log.closest('.multi-view-panel');
if (parentPanel) {
parentPanel.style.setProperty('display', 'flex', 'important');
parentPanel.style.setProperty('flex-direction', 'column', 'important');
parentPanel.style.setProperty('overflow', 'hidden', 'important');
parentPanel.style.setProperty('height', '100%', 'important');
}
});
// Также исправляем стили для multi-view-content контейнеров
const multiViewContents = document.querySelectorAll('.multi-view-content');
multiViewContents.forEach(content => {
content.style.setProperty('display', 'flex', 'important');
content.style.setProperty('flex-direction', 'column', 'important');
content.style.setProperty('overflow', 'hidden', 'important');
content.style.setProperty('height', '100%', 'important');
});
// Универсальное исправление для всех контейнеров
multiViewLogs.forEach(log => {
console.log(`Universal fix for container:`, log.getAttribute('data-container-id'));
// Принудительно устанавливаем все стили заново для всех контейнеров
log.style.cssText = 'height: 100% !important; overflow: auto !important; max-height: none !important; display: block !important; min-height: 200px !important; position: relative !important; flex: 1 !important; min-height: 0 !important; width: 100% !important; box-sizing: border-box !important;';
// Принудительно вызываем пересчет layout
log.style.setProperty('transform', 'translateZ(0)', 'important');
});
// Применяем настройки wrap text после исправления стилей
applyWrapSettings();
}
// Функция для обновления стилей логов
function updateLogStyles() {
const isCollapsed = els.sidebar && els.sidebar.classList.contains('collapsed');
// Обновляем стили для single-view логов
const singleViewLogs = document.querySelectorAll('.single-view-content .log');
singleViewLogs.forEach(log => {
if (isCollapsed) {
log.style.height = 'calc(100vh - var(--header-height))';
log.style.overflow = 'auto';
} else {
log.style.height = '100%';
log.style.overflow = 'auto';
}
});
// Обновляем стили для multi-view логов (более агрессивно)
const multiViewLogs = document.querySelectorAll('.multi-view-content .multi-view-log');
console.log(`Found ${multiViewLogs.length} multi-view logs to update`);
multiViewLogs.forEach((log, index) => {
const containerId = log.getAttribute('data-container-id');
console.log(`Updating multi-view log ${index + 1}/${multiViewLogs.length} for container: ${containerId}`);
// Принудительно устанавливаем правильные стили независимо от состояния sidebar
log.style.setProperty('height', '100%', 'important');
log.style.setProperty('overflow', 'auto', 'important');
log.style.setProperty('max-height', 'none', 'important');
log.style.setProperty('display', 'block', 'important');
log.style.setProperty('min-height', '200px', 'important');
log.style.setProperty('position', 'relative', 'important');
log.style.setProperty('flex', '1', 'important');
log.style.setProperty('min-height', '0', 'important');
// Принудительно вызываем пересчет layout
log.style.setProperty('transform', 'translateZ(0)', 'important');
});
// Также обновляем стили для multi-view-content контейнеров
const multiViewContents = document.querySelectorAll('.multi-view-content');
multiViewContents.forEach(content => {
if (isCollapsed) {
// В свернутом состоянии multi-view-content должен иметь правильную высоту
content.style.setProperty('height', 'calc(100vh - var(--header-height) - 60px)', 'important');
} else {
content.style.setProperty('height', '100%', 'important');
}
content.style.setProperty('overflow', 'hidden', 'important');
content.style.setProperty('display', 'flex', 'important');
content.style.setProperty('flex-direction', 'column', 'important');
});
// Применяем настройки wrap text
applyWrapSettings();
console.log('Log styles updated, sidebar collapsed:', isCollapsed, 'multi-view logs found:', multiViewLogs.length);
// Принудительно исправляем стили multi-view логов
forceFixMultiViewStyles();
// Дополнительная проверка через 500ms для multi view логов
if (multiViewLogs.length > 0) {
setTimeout(() => {
console.log('Performing delayed update for multi-view logs...');
forceFixMultiViewStyles();
}, 500);
}
}
// Mobile menu toggle
if (els.mobileToggle) {
els.mobileToggle.onclick = () => {
const sidebar = document.querySelector('.sidebar');
if (sidebar) {
sidebar.classList.toggle('open');
}
};
}
// Функция для показа/скрытия модального окна с горячими клавишами
function toggleHotkeysModal() {
if (els.hotkeysModal) {
const isVisible = els.hotkeysModal.classList.contains('show');
if (isVisible) {
els.hotkeysModal.classList.remove('show');
} else {
els.hotkeysModal.classList.add('show');
}
}
}
// Sidebar toggle button
if (els.sidebarToggle) {
els.sidebarToggle.onclick = toggleSidebar;
}
// Help button
if (els.helpBtn) {
els.helpBtn.onclick = toggleHotkeysModal;
}
// 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) {
// Показываем настройки
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 {
// Очищаем 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.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+R/Ctrl+K — refresh logs
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.ctrlKey && e.key==='ArrowLeft')){
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.ctrlKey && e.key==='ArrowRight')){
e.preventDefault();
const idx = state.services.findIndex(s=> s.id===state.current?.id);
if (idx>=0 && idx<state.services.length-1) await switchToSingle(state.services[idx+1]);
}
// Обновление логов по Ctrl+R или Ctrl+K
if ((e.ctrlKey && e.key==='r') || (e.ctrlKey && e.key==='k')){
e.preventDefault();
console.log('Hotkey refresh triggered:', e.key);
await refreshLogsAndCounters();
}
// Сворачивание/разворачивание sidebar по Ctrl+B / Ctrl+И (русская раскладка)
if (e.ctrlKey && (e.key==='b' || e.key==='и' || e.code==='KeyB')){
e.preventDefault();
toggleSidebar();
}
});
// Инициализация
(async function init() {
console.log('Initializing LogBoard+...');
// Проверяем авторизацию
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();
// Проверяем, есть ли сохраненный контейнер в localStorage
const savedContainerId = getSelectedContainerFromStorage();
if (savedContainerId) {
console.log('Found saved container, switching to it:', savedContainerId);
const savedService = state.services.find(s => s.id === savedContainerId);
if (savedService) {
// Добавляем контейнер в выбранные
state.selectedContainers = [savedContainerId];
// Переключаемся на сохраненный контейнер
await switchToSingle(savedService);
// Очищаем сохраненный контейнер из localStorage
saveSelectedContainer(null);
} else {
console.log('Saved container not found in services, clearing localStorage');
saveSelectedContainer(null);
}
}
// Инициализируем видимость счетчиков
updateCounterVisibility();
// Обновляем состояние чекбоксов после загрузки сервисов
updateContainerSelectionUI();
// Восстанавливаем состояние sidebar
const sidebarCollapsed = localStorage.getItem('lb_sidebar_collapsed');
if (sidebarCollapsed === 'true' && els.sidebar && els.sidebarToggle) {
els.sidebar.classList.add('collapsed');
els.sidebarToggle.innerHTML = '<i class="fas fa-chevron-right"></i>';
els.sidebarToggle.title = 'Развернуть панель (Ctrl+B / Ctrl+И)';
}
// Показываем подсказку о горячих клавишах при первом запуске
const hotkeysShown = localStorage.getItem('lb_hotkeys_shown');
if (!hotkeysShown) {
setTimeout(() => {
showHotkeysNotification();
localStorage.setItem('lb_hotkeys_shown', 'true');
}, 2000);
}
// Добавляем обработчики для счетчиков
addCounterClickHandlers();
// Добавляем обработчик для выпадающего списка проектов в заголовке
addMultiSelectHandlers();
// Загружаем и отображаем исключенные контейнеры
loadExcludedContainers().then(containers => {
renderExcludedContainers(containers);
});
// Добавляем обработчики для исключенных контейнеров
const addExcludedBtn = document.getElementById('addExcludedContainer');
const newExcludedInput = document.getElementById('newExcludedContainer');
if (addExcludedBtn) {
addExcludedBtn.onclick = addExcludedContainer;
}
if (newExcludedInput) {
newExcludedInput.onkeypress = (e) => {
if (e.key === 'Enter') {
addExcludedContainer();
}
};
}
// Добавляем обработчики для чекбоксов контейнеров
document.addEventListener('change', (e) => {
if (e.target.classList.contains('container-checkbox')) {
const containerId = e.target.getAttribute('data-container-id');
toggleContainerSelection(containerId);
}
// Обработчик изменения tail lines
if (e.target.id === 'tail') {
console.log('Tail lines changed to:', e.target.value);
if (state.multiViewMode) {
// В multi view применяем новое ограничение к уже отображаемым логам
const tailLines = parseInt(e.target.value) || 50;
console.log(`Applying tail lines limit ${tailLines} to ${state.selectedContainers.length} containers:`, state.selectedContainers);
// Проверяем все элементы multi-view-log на странице
const allMultiViewLogs = document.querySelectorAll('.multi-view-log');
console.log(`Found ${allMultiViewLogs.length} multi-view-log elements on page:`, Array.from(allMultiViewLogs).map(el => el.getAttribute('data-container-id')));
state.selectedContainers.forEach(containerId => {
console.log(`Processing container ${containerId}...`);
// Ищем элемент несколькими способами
let multiViewLog = document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`);
if (!multiViewLog) {
console.warn(`Container ${containerId} not found with data-container-id, trying alternative search...`);
// Попробуем найти по другому селектору
multiViewLog = document.querySelector(`[data-container-id="${containerId}"]`);
}
if (multiViewLog) {
console.log(`Found multi-view-log for container ${containerId}, current lines:`, multiViewLog.querySelectorAll('.line').length);
// Получаем все строки логов
const logLines = Array.from(multiViewLog.querySelectorAll('.line'));
console.log(`Container ${containerId}: ${logLines.length} log lines found`);
if (logLines.length > tailLines) {
// Удаляем лишние строки с начала
const linesToRemove = logLines.length - tailLines;
console.log(`Removing ${linesToRemove} lines from container ${containerId}`);
// Удаляем первые N строк
logLines.slice(0, linesToRemove).forEach(line => {
line.remove();
});
const remainingLines = multiViewLog.querySelectorAll('.line').length;
console.log(`Container ${containerId} now has ${remainingLines} lines after trimming`);
} else {
console.log(`Container ${containerId} has ${logLines.length} lines, no trimming needed (limit: ${tailLines})`);
}
} else {
console.error(`Multi-view log element not found for container ${containerId}`);
console.error(`Available multi-view-log elements:`, Array.from(document.querySelectorAll('.multi-view-log')).map(el => ({
containerId: el.getAttribute('data-container-id'),
className: el.className,
parent: el.parentElement?.className
})));
}
});
}
}
});
// Добавляем обработчики кликов на label чекбоксов
document.addEventListener('click', (e) => {
if (e.target.classList.contains('container-checkbox-label')) {
e.preventDefault();
e.stopPropagation();
const label = e.target;
const checkbox = label.previousElementSibling;
if (checkbox && checkbox.classList.contains('container-checkbox')) {
checkbox.checked = !checkbox.checked;
const containerId = checkbox.getAttribute('data-container-id');
toggleContainerSelection(containerId);
}
}
});
// Обработчики для кнопок уровней логирования в заголовках
document.addEventListener('click', (e) => {
if (e.target.closest('.level-btn')) {
const levelBtn = e.target.closest('.level-btn');
const level = levelBtn.getAttribute('data-level');
const containerId = levelBtn.getAttribute('data-container-id');
console.log(`Клик по кнопке уровня логирования: level=${level}, containerId=${containerId}, multiViewMode=${state.multiViewMode}`);
// Переключаем состояние кнопки
const isActive = levelBtn.classList.contains('active');
levelBtn.classList.toggle('active');
// Обновляем состояние уровней логирования
if (containerId) {
// Для multi-view: конкретный контейнер
if (!state.containerLevels) {
state.containerLevels = {};
}
if (!state.containerLevels[containerId]) {
state.containerLevels[containerId] = {debug: true, info: true, warn: true, err: true, other: true};
}
state.containerLevels[containerId][level] = !isActive;
// Сохраняем состояние кнопок loglevels в localStorage
saveLogLevelsState();
// Обновляем видимость логов только для этого контейнера
updateContainerLogVisibility(containerId);
// Пересчитываем счетчики только для этого контейнера
setTimeout(() => {
updateContainerCounters(containerId);
}, 100);
// Обновляем видимость логов для всех контейнеров в multi-view
// чтобы убедиться, что изменения применились только к нужному контейнеру
state.selectedContainers.forEach(id => {
if (id !== containerId) {
updateContainerLogVisibility(id);
}
});
} else {
// Для single-view: глобальные настройки
state.levels[level] = !isActive;
// Сохраняем состояние кнопок loglevels в localStorage
saveLogLevelsState();
// Обновляем видимость логов только для текущего контейнера
if (state.current) {
updateLogVisibility(els.logContent);
}
// Пересчитываем счетчики только для текущего контейнера
setTimeout(() => {
recalculateCounters();
}, 100);
}
}
});
// Добавляем тестовые функции в глобальную область для отладки
window.testDuplicateRemoval = testDuplicateRemoval;
window.testSingleViewDuplicateRemoval = testSingleViewDuplicateRemoval;
window.testSingleViewEmptyLinesRemoval = testSingleViewEmptyLinesRemoval;
window.testSingleViewLineBreaks = testSingleViewLineBreaks;
window.testMultiViewLineBreaks = testMultiViewLineBreaks;
window.testMultiViewSpecialReplacements = testMultiViewSpecialReplacements;
window.testFullMultiViewProcessing = testFullMultiViewProcessing;
window.quickTestINFO = quickTestINFO;
window.testRegex = testRegex;
window.checkMultiViewHTML = checkMultiViewHTML;
window.cleanMultiViewDuplicateLines = cleanMultiViewDuplicateLines;
window.cleanDuplicateLines = cleanDuplicateLines;
window.cleanSingleViewEmptyLines = cleanSingleViewEmptyLines;
// Добавляем функции для исправления стилей в глобальную область
window.forceFixMultiViewStyles = forceFixMultiViewStyles;
window.updateLogStyles = updateLogStyles;
// Универсальная функция для исправления всех контейнеров
window.fixAllContainers = function() {
console.log('Fixing all multi-view containers');
const allLogs = document.querySelectorAll('.multi-view-content .multi-view-log');
allLogs.forEach(log => {
const containerId = log.getAttribute('data-container-id');
console.log(`Fixing container:`, containerId);
// Принудительно устанавливаем все стили заново
log.style.cssText = 'height: 100% !important; overflow: auto !important; max-height: none !important; display: block !important; min-height: 200px !important; position: relative !important; flex: 1 !important; min-height: 0 !important; width: 100% !important; box-sizing: border-box !important;';
// Принудительно вызываем пересчет layout
log.style.setProperty('transform', 'translateZ(0)', 'important');
// Проверяем родительские элементы
const parentContent = log.closest('.multi-view-content');
if (parentContent) {
parentContent.style.cssText = 'display: flex !important; flex-direction: column !important; overflow: hidden !important; height: 100% !important;';
}
const parentPanel = log.closest('.multi-view-panel');
if (parentPanel) {
parentPanel.style.cssText = 'display: flex !important; flex-direction: column !important; overflow: hidden !important; height: 100% !important;';
}
});
// Применяем настройки wrap text после исправления всех контейнеров
applyWrapSettings();
};
// Оставляем старую функцию для обратной совместимости
window.fixProblematicContainers = function() {
console.log('fixProblematicContainers is deprecated, use fixAllContainers instead');
window.fixAllContainers();
};
console.log('LogBoard+ инициализирован с исправлениями дублирования строк и правильными переносами строк в Single View и MultiView режимах');
console.log('Для тестирования используйте: testDuplicateRemoval(), testSingleViewDuplicateRemoval(), testSingleViewEmptyLinesRemoval() или testSingleViewLineBreaks()');
// Запускаем первоначальную очистку пустых строк
setTimeout(() => {
if (!state.multiViewMode && els.logContent) {
cleanSingleViewEmptyLines(els.logContent);
cleanDuplicateLines(els.logContent);
}
}, 1000);
// Инициализируем видимость кнопок LogLevels
updateLogLevelsVisibility();
})();
</script>
<!-- Модальное окно с горячими клавишами -->
<div class="hotkeys-modal" id="hotkeysModal">
<div class="hotkeys-modal-content">
<div class="hotkeys-modal-header">
<h3 class="hotkeys-modal-title">
<i class="fas fa-keyboard"></i>
Горячие клавиши
</h3>
<button class="hotkeys-modal-close" id="hotkeysModalClose">
<i class="fas fa-times"></i>
</button>
</div>
<div class="hotkeys-modal-body">
<div class="hotkeys-section">
<h4 class="hotkeys-section-title">Навигация</h4>
<ul class="hotkeys-list">
<li><kbd>[</kbd> <kbd>]</kbd> <span>Переход между контейнерами</span></li>
<li><kbd>Ctrl</kbd> + <kbd></kbd> <span>Предыдущий контейнер</span></li>
<li><kbd>Ctrl</kbd> + <kbd></kbd> <span>Следующий контейнер</span></li>
</ul>
</div>
<div class="hotkeys-section">
<h4 class="hotkeys-section-title">Обновление</h4>
<ul class="hotkeys-list">
<li><kbd>Ctrl</kbd> + <kbd>R</kbd> <span>Обновить логи</span></li>
<li><kbd>Ctrl</kbd> + <kbd>K</kbd> <span>Обновить логи (альтернатива)</span></li>
</ul>
</div>
<div class="hotkeys-section">
<h4 class="hotkeys-section-title">Интерфейс</h4>
<ul class="hotkeys-list">
<li><kbd>Ctrl</kbd> + <kbd>B</kbd> <span>Свернуть/развернуть панель</span></li>
<li><kbd>Ctrl</kbd> + <kbd>И</kbd> <span>Свернуть/развернуть панель (русская раскладка)</span></li>
</ul>
</div>
</div>
</div>
</div>
</body>
</html>