logboard/templates/index.html
Сергей Антропов a979dd2838 feat: Добавлена новая система авторизации с JWT токенами
- Удален Basic Auth, заменен на современную JWT авторизацию
- Добавлена страница входа с красивым интерфейсом
- Обновлен фронтенд для работы с JWT токенами
- Добавлены новые зависимости: PyJWT, passlib[bcrypt], jinja2
- Создан тестовый скрипт для проверки авторизации
- Добавлено руководство по миграции
- Обновлена документация и README
- Улучшен дизайн поля ввода пароля на странице входа

Автор: Сергей Антропов
Сайт: https://devops.org.ru
2025-08-17 18:29:06 +03:00

4706 lines
165 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;
}
.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,
.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;
}
.options-btn:hover,
.logout-btn:hover {
background: var(--tab-active);
color: var(--fg);
border-color: var(--accent);
}
.options-btn.active {
background: var(--accent);
color: #0b0d12;
border-color: var(--accent);
}
.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 */
#logRefreshBtn {
background: var(--accent);
color: white;
border: none;
border-radius: 6px;
transition: all 0.2s ease;
padding: 6px 24px; /* Увеличиваем ширину в 2 раза */
font-size: 11px;
font-weight: 500;
display: inline-flex;
align-items: center;
justify-content: center;
height: fit-content;
}
#logRefreshBtn: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: #17a2b8;
color: white;
}
.info-btn:hover {
background: #138496;
}
.warn-btn {
background: #ffc107;
color: #212529;
}
.warn-btn:hover {
background: #e0a800;
}
.error-btn {
background: #dc3545;
color: white;
}
.error-btn:hover {
background: #c82333;
}
.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;
}
.header-title {
font-size: 16px;
font-weight: 600;
color: var(--fg);
margin: 0;
}
.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: 400px;
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;
}
.header-filter:focus {
outline: none;
border-color: var(--accent);
}
.header-filter::placeholder {
color: var(--muted);
}
.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;
}
.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;
}
.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: auto;
padding: 16px;
background: var(--bg);
}
.log {
white-space: pre-wrap;
word-break: break-word;
font-size: 12px;
line-height: 1.5;
margin: 0;
tab-size: 2;
}
/* 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)}
</style>
</head>
<body>
<div class="app-container">
<!-- Sidebar -->
<div class="sidebar">
<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="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>
<!-- Main Content -->
<div class="main-content">
<!-- Header -->
<div class="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="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">
<div class="log-header">
<h3 class="log-title" id="logTitle">Select a container to view logs</h3>
<div class="log-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 id="logRefreshBtn" class="btn btn-small" title="Обновить логи и счетчики">
<i class="fas fa-sync-alt"></i> Refresh
</button>
</div>
</div>
<div class="log-content">
<pre class="log" id="logContent">No container selected</pre>
</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},
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'),
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'),
logTitle: document.getElementById('logTitle'),
logContent: document.getElementById('logContent'),
mobileToggle: document.getElementById('mobileToggle'),
optionsBtn: document.getElementById('optionsBtn'),
logoutBtn: document.getElementById('logoutBtn'),
logRefreshBtn: document.getElementById('logRefreshBtn'),
};
// ----- 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 true; // Всегда показываем неклассифицированные строки
return true;
}
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);
}
});
// Обновляем заголовок
updateLogTitle();
// Если есть сохраненный контейнер, убеждаемся что его чекбокс отмечен
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');
}
}
}
}
// Функция для сохранения выбранного контейнера в 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;
}
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');
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
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;
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>';
}
}
// Обновляем заголовок
if (els.logTitle) {
els.logTitle.textContent = 'LogBoard+';
}
}
console.log(`Multi-view mode updated: multiViewMode = ${state.multiViewMode}`);
}
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 = '';
}
// Создаем сетку для мультипросмотра
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();
// Подключаем 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); // Небольшая задержка для завершения загрузки логов
}
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>
<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}`);
}
console.log(`Multi-view panel created for ${service.name}`);
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},
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 style="display: flex; align-items: center; justify-content: center; height: 100%; color: var(--muted); font-size: 14px;">Выберите контейнер для просмотра логов</div>';
}
const logTitle = document.getElementById('logTitle');
if (logTitle) {
logTitle.textContent = 'LogBoard+';
}
}
function updateLogTitle() {
const logTitle = document.getElementById('logTitle');
if (!logTitle) return;
console.log('updateLogTitle called, selected containers:', state.selectedContainers.length);
if (state.selectedContainers.length === 0) {
logTitle.textContent = 'LogBoard+';
console.log('Log title set to: LogBoard+');
} else if (state.selectedContainers.length === 1) {
const service = state.services.find(s => s.id === state.selectedContainers[0]);
if (service) {
logTitle.textContent = `${service.name} (${service.service || service.name})`;
console.log('Log title set to single container:', service.name);
}
} else {
logTitle.textContent = `Multi-view: ${state.selectedContainers.length} containers`;
console.log('Log title set to multi-view:', state.selectedContainers.length, 'containers');
}
}
function applyWrapSettings() {
const wrapEnabled = els.wrapToggle && els.wrapToggle.checked;
const wrapStyle = wrapEnabled ? 'pre-wrap' : 'pre';
// Применяем к обычному просмотру
document.querySelectorAll('.log').forEach(el => {
el.style.whiteSpace = wrapStyle;
});
// Применяем к мультипросмотру
document.querySelectorAll('.multi-view-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();
if (!state.current && state.services.length) await switchToSingle(state.services[0]);
// Добавляем обработчики для счетчиков после загрузки сервисов
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; }
// Получаем текст логов из современного интерфейса или из legacy
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 token = localStorage.getItem('access_token');
if (!token) {
console.error('No access token found');
window.location.href = '/login';
return;
}
const payload = {container_id: id, service: o.serviceName || id, 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 counters = {dbg:0,info:0,warn:0,err: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;
};
// Убираем автоматический 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);
/**
* Функция для обработки специальных замен в 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};
}
// Фильтруем сообщение "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);
// Обновляем счетчики только для отображаемых логов
// Проверяем фильтры для отображения
const 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++;
}
// Для 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) {
if (shouldShow) {
// Применяем ограничение 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 = '';
}
// Удаляем мультипросмотр из 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];
// Modern interface updates
if (els.logTitle) {
els.logTitle.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();
// Обновляем заголовок
updateLogTitle();
// Обновляем счетчики для нового контейнера
setTimeout(() => {
recalculateCounters();
}, 500); // Небольшая задержка для завершения загрузки логов
await updateCounters(svc.id);
// Добавляем обработчики для счетчиков после переключения контейнера
addCounterClickHandlers();
} 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();
}
// ----- 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 counters = {dbg:0,info:0,warn:0,err: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;
// Обновляем видимость счетчиков
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');
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;
// Обновляем видимость счетчиков
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();
// Обновляем видимость счетчиков
updateCounterVisibility();
// Добавляем обработчики для счетчиков
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};
// Пересчитываем счетчики только для отображаемых логов
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++;
}
});
// Обновляем отображение счетчиков
const cdbg = document.querySelector('.cdbg');
const cinfo = document.querySelector('.cinfo');
const cwarn = document.querySelector('.cwarn');
const cerr = document.querySelector('.cerr');
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;
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;
// Пересчитываем счетчики для каждого контейнера
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};
// Пересчитываем счетчики только для отображаемых логов
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++;
}
});
// Добавляем к общим счетчикам
totalDebug += obj.counters.dbg;
totalInfo += obj.counters.info;
totalWarn += obj.counters.warn;
totalError += obj.counters.err;
}
// Обновляем отображение счетчиков
const cdbg = document.querySelector('.cdbg');
const cinfo = document.querySelector('.cinfo');
const cwarn = document.querySelector('.cwarn');
const cerr = document.querySelector('.cerr');
if (cdbg) cdbg.textContent = totalDebug;
if (cinfo) cinfo.textContent = totalInfo;
if (cwarn) cwarn.textContent = totalWarn;
if (cerr) cerr.textContent = totalError;
console.log(`Multi-view counters recalculated (tail: ${tailLines}):`, { totalDebug, totalInfo, totalWarn, totalError });
}
// Функция для обновления видимости счетчиков
function updateCounterVisibility() {
const debugBtn = document.querySelector('.debug-btn');
const infoBtn = document.querySelector('.info-btn');
const warnBtn = document.querySelector('.warn-btn');
const errorBtn = document.querySelector('.error-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);
}
}
// Функция для обновления логов и счетчиков
async function refreshLogsAndCounters() {
if (state.multiViewMode && state.selectedContainers.length > 0) {
// Обновляем мультипросмотр
console.log('Refreshing multi-view for containers:', state.selectedContainers);
// Пересчитываем счетчики на основе отображаемых логов
recalculateMultiViewCounters();
// Перезапускаем WebSocket соединения для всех выбранных контейнеров
state.selectedContainers.forEach(containerId => {
closeWs(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('Refreshing logs and counters for:', state.current.id);
// Пересчитываем счетчики на основе отображаемых логов
recalculateCounters();
// Перезапускаем 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);
}
}
} 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();
// Если есть текущий контейнер, перезапускаем его WebSocket соединение
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 {
// Если контейнер больше не существует, переключаемся на первый доступный
if (state.services.length > 0) {
await switchToSingle(state.services[0]);
}
}
}
};
// Обработчик для кнопки refresh логов
els.logRefreshBtn.onclick = refreshLogsAndCounters;
// Обработчики для счетчиков
function addCounterClickHandlers() {
const debugBtn = document.querySelector('.debug-btn');
const infoBtn = document.querySelector('.info-btn');
const warnBtn = document.querySelector('.warn-btn');
const errorBtn = document.querySelector('.error-btn');
if (debugBtn) {
debugBtn.onclick = () => {
state.levels.debug = !state.levels.debug;
updateCounterVisibility();
refreshAllLogs();
// Добавляем refresh для обновления логов
if (state.current) {
refreshLogsAndCounters();
}
// Обновляем multi-view если он активен
if (state.multiViewMode) {
refreshAllLogs();
}
};
}
if (infoBtn) {
infoBtn.onclick = () => {
state.levels.info = !state.levels.info;
updateCounterVisibility();
refreshAllLogs();
// Добавляем refresh для обновления логов
if (state.current) {
refreshLogsAndCounters();
}
// Обновляем multi-view если он активен
if (state.multiViewMode) {
refreshAllLogs();
}
};
}
if (warnBtn) {
warnBtn.onclick = () => {
state.levels.warn = !state.levels.warn;
updateCounterVisibility();
refreshAllLogs();
// Добавляем refresh для обновления логов
if (state.current) {
refreshLogsAndCounters();
}
// Обновляем multi-view если он активен
if (state.multiViewMode) {
refreshAllLogs();
}
};
}
if (errorBtn) {
errorBtn.onclick = () => {
state.levels.err = !state.levels.err;
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');
}
});
}
}
// Mobile menu toggle
if (els.mobileToggle) {
els.mobileToggle.onclick = () => {
const sidebar = document.querySelector('.sidebar');
if (sidebar) {
sidebar.classList.toggle('open');
}
};
}
// 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');
}
}
// Обработчик для кнопки выхода
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';
}
}
});
}
});
if (els.snapshotBtn) {
els.snapshotBtn.onclick = ()=>{
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);
}
};
}
// Hotkeys: [ ] — navigation between containers
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]);
}
});
// Инициализация
(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,
logTitle: !!els.logTitle,
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();
// Добавляем обработчики для счетчиков
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);
}
}
});
// Добавляем тестовые функции в глобальную область для отладки
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;
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);
})();
</script>
</body>
</html>