Files
LogBoard/templates/index.html
Сергей Антропов 1745d4b5d4 Исправлена проблема с парсером в режиме multiview
- Добавлена инициализация счетчиков в openMultiViewWs
- Исправлена обработка входящих сообщений WebSocket
- Добавлена отладочная информация для диагностики
- Парсер теперь работает сразу при переключении в multiview
- Улучшена обработка ошибок и проверки объектов

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

3207 lines
98 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>
<meta name="x-token" content="__TOKEN__"/>
<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-wrap;
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)} .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}
.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}
.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">50</option>
<option value="100">100</option>
<option value="200">200</option>
<option value="300">300</option>
<option value="500" selected>500</option>
<option value="1000">1000</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>
const state = {
token: (document.querySelector('meta[name="x-token"]')?.content)||'',
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('');
// Обновляем современный интерфейс
if (state.current && state.current.id === id && els.logContent) {
els.logContent.innerHTML = obj.logEl.innerHTML;
}
});
// Обновляем мультипросмотр
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('');
// Отладочная информация
console.log(`Multi-view refresh: Container ${containerId}, ${filteredHtml.length} lines, HTML preview:`, filteredHtml.slice(0, 2).join('').substring(0, 200));
// Проверяем, что HTML действительно содержит цветные элементы
const coloredElements = multiViewLog.querySelectorAll('.ok, .warn, .err, .dbg, .ansi-red, .ansi-green, .ansi-yellow, .ansi-blue');
console.log(`Multi-view: Found ${coloredElements.length} colored elements in container ${containerId}`);
}
});
}
}
function escapeHtml(s){ return s.replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m])); }
function classify(line){
const l = line.toLowerCase();
// Отладочная информация для понимания что происходит
if (line.includes('INFO') || line.includes('WARNING') || line.includes('ERROR') || line.includes('DEBUG')) {
console.log(`Classifying line: "${line.substring(0, 100)}..."`);
}
// Проверяем различные форматы уровней логирования (более специфичные сначала)
// DEBUG - ищем точное совпадение уровня логирования
if (/\s- DEBUG -|\s\[debug\]|level=debug|\bdebug\b(?=\s|$)/.test(l)) {
console.log(`Classified as DEBUG: "${line.substring(0, 100)}..."`);
return 'dbg';
}
// ERROR - ищем точное совпадение уровня логирования
if (/\s- ERROR -|\s\[error\]|level=error/.test(l)) {
console.log(`Classified as ERROR: "${line.substring(0, 100)}..."`);
return 'err';
}
// WARNING - ищем точное совпадение уровня логирования
if (/\s- WARNING -|\s\[warn\]|level=warn/.test(l)) {
console.log(`Classified as WARNING: "${line.substring(0, 100)}..."`);
return 'warn';
}
// INFO - ищем точное совпадение уровня логирования
if (/\s- INFO -|\s\[info\]|level=info/.test(l)) {
console.log(`Classified as INFO: "${line.substring(0, 100)}..."`);
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';
// Отладочная информация для неклассифицированных строк
if (line.includes('level=')) {
console.log(`Unclassified line with level: "${line.substring(0, 200)}..."`);
console.log(`Lowercase version: "${l.substring(0, 200)}..."`);
}
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 = ()=> 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}">
<label for="select-${svc.id}" class="container-checkbox-label" title="Выбрать для мультипросмотра"></label>
</div>
`;
item.onclick = (e) => {
// Не переключаем контейнер, если кликнули на ссылку или чекбокс
if (e.target.closest('.container-link') || e.target.closest('.container-select')) {
e.stopPropagation();
return;
}
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 res = await fetch(url);
if (!res.ok){
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 response = await fetch('/api/excluded-containers');
if (!response.ok) {
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 response = await fetch('/api/excluded-containers', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(containers)
});
if (!response.ok) {
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) {
const index = state.selectedContainers.indexOf(containerId);
if (index > -1) {
state.selectedContainers.splice(index, 1);
} else {
state.selectedContainers.push(containerId);
}
updateContainerSelectionUI();
updateMultiViewMode();
}
function updateContainerSelectionUI() {
// Обновляем чекбоксы
document.querySelectorAll('.container-checkbox').forEach(checkbox => {
const containerId = checkbox.getAttribute('data-container-id');
const containerItem = checkbox.closest('.container-item');
if (state.selectedContainers.includes(containerId)) {
checkbox.checked = true;
containerItem.classList.add('selected');
} else {
checkbox.checked = false;
containerItem.classList.remove('selected');
}
});
// Обновляем заголовок
updateLogTitle();
}
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');
setupMultiView();
} else if (state.selectedContainers.length === 1) {
state.multiViewMode = false;
const selectedService = state.services.find(s => s.id === state.selectedContainers[0]);
if (selectedService) {
switchToSingle(selectedService);
}
} 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}`);
}
function setupMultiView() {
console.log('setupMultiView called');
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 <= 2) columns = 2;
else if (state.selectedContainers.length <= 4) columns = 2;
else if (state.selectedContainers.length <= 6) columns = 3;
else columns = 4;
gridContainer.style.gridTemplateColumns = `repeat(${columns}, 1fr)`;
// Создаем панели для каждого выбранного контейнера
state.selectedContainers.forEach(containerId => {
const service = state.services.find(s => s.id === containerId);
if (!service) return;
const panel = createMultiViewPanel(service);
gridContainer.appendChild(panel);
});
if (logContent) {
logContent.appendChild(gridContainer);
}
// Применяем настройки wrap lines
applyWrapSettings();
// Подключаем WebSocket для каждого контейнера
state.selectedContainers.forEach(containerId => {
const service = state.services.find(s => s.id === containerId);
if (service) {
console.log(`Setting up WebSocket for multi-view container: ${service.name} (${containerId})`);
openMultiViewWs(service);
} else {
console.error(`Service not found for container ID: ${containerId}`);
}
});
}
function createMultiViewPanel(service) {
console.log(`Creating multi-view panel for service: ${service.name} (${service.id})`);
const panel = document.createElement('div');
panel.className = 'multi-view-panel';
panel.setAttribute('data-container-id', service.id);
panel.innerHTML = `
<div class="multi-view-header">
<h4 class="multi-view-title">${escapeHtml(service.name)}</h4>
</div>
<div class="multi-view-content">
<div class="multi-view-log" data-container-id="${service.id}">Connecting...</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}`);
} 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;
// Закрываем существующее соединение
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) {
logEl.textContent = 'Connected...';
}
};
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/);
for (let i=0;i<parts.length;i++){
if (parts[i].length===0 && i===parts.length-1) continue;
// 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
state.open[containerId] = {
ws: ws,
serviceName: service.service,
logEl: document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`),
wrapEl: document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`),
counters: {dbg:0, info:0, warn:0, err:0},
pausedBuffer: [],
allLogs: [] // Добавляем буфер для логов
};
}
function clearLogArea() {
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;
if (state.selectedContainers.length === 0) {
logTitle.textContent = 'LogBoard+';
} else if (state.selectedContainers.length === 1) {
const service = state.services.find(s => s.id === state.selectedContainers[0]);
logTitle.textContent = `${service.name} (${service.service || service.name})`;
} else {
logTitle.textContent = `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 res = await fetch(url);
if (!res.ok){
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) 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(state.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 payload = {container_id: id, service: o.serviceName || id, content: text};
const res = await fetch('/api/snapshot', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload)});
if (!res.ok){
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;
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)};
ws.onopen = ()=> setWsState('on');
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/);
for (let i=0;i<parts.length;i++){
if (parts[i].length===0 && i===parts.length-1) continue;
// harvest instance ids if present
const pr = parsePrefixAndStrip(parts[i]);
if (pr){ if (!(pr.id8 in inst.filters)) { inst.filters[pr.id8] = true; updateIdFiltersBar(); } }
handleLine(id, parts[i]);
}
// Обновляем счетчики после обработки всех строк
cdbg.textContent = counters.dbg;
cinfo.textContent = counters.info;
cwarn.textContent = counters.warn;
cerr.textContent = counters.err;
};
// Убираем автоматический refresh - теперь только по кнопке
}
// Глобальная функция для обработки логов
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};
}
const cls = classify(line);
// Обновляем счетчики
if (obj.counters) {
if (cls==='dbg') obj.counters.dbg++;
if (cls==='ok') obj.counters.info++;
if (cls==='warn') obj.counters.warn++;
if (cls==='err') obj.counters.err++;
// Отладочная информация для первых нескольких строк
if (obj.counters.dbg + obj.counters.info + obj.counters.warn + obj.counters.err < 10) {
console.log(`Line: "${line.substring(0, 100)}..." -> Class: ${cls}`);
}
// Отладочная информация о счетчиках
if (obj.counters.dbg + obj.counters.info + obj.counters.warn + obj.counters.err < 5) {
console.log(`Counters: DEBUG=${obj.counters.dbg}, INFO=${obj.counters.info}, WARN=${obj.counters.warn}, ERROR=${obj.counters.err}`);
}
}
const html = `<span class="line ${cls}">${ansiToHtml(line)}</span>\n`;
// Отладочная информация для HTML
if (obj.counters && obj.counters.dbg + obj.counters.info + obj.counters.warn + obj.counters.err < 3) {
console.log(`Generated HTML for class ${cls}:`, html.substring(0, 200));
}
// Сохраняем все логи в буфере (всегда)
if (!obj.allLogs) obj.allLogs = [];
obj.allLogs.push({html: html, line: line, cls: cls});
// Ограничиваем размер буфера
if (obj.allLogs.length > 10000) {
obj.allLogs = obj.allLogs.slice(-5000);
}
// Проверяем фильтры для отображения
const shouldShow = allowedByLevel(cls) && applyFilter(line);
// Добавляем логи в отображение (обычный просмотр)
if (shouldShow && obj.logEl) {
obj.logEl.insertAdjacentHTML('beforeend', html);
if (els.autoscroll && els.autoscroll.checked && obj.wrapEl) {
obj.wrapEl.scrollTop = obj.wrapEl.scrollHeight;
}
// Update modern interface
if (state.current && state.current.id === id && els.logContent) {
els.logContent.innerHTML = obj.logEl.innerHTML;
const logContent = document.querySelector('.log-content');
if (logContent && els.autoscroll && els.autoscroll.checked) {
logContent.scrollTop = logContent.scrollHeight;
}
}
}
// Update multi-view interface
if (state.multiViewMode && state.selectedContainers.includes(id)) {
console.log(`Multi-view processing: container ${id}, shouldShow: ${shouldShow}, multiViewMode: ${state.multiViewMode}, selectedContainers:`, state.selectedContainers);
const multiViewLog = document.querySelector(`.multi-view-log[data-container-id="${id}"]`);
if (multiViewLog) {
if (shouldShow) {
multiViewLog.insertAdjacentHTML('beforeend', html);
if (els.autoscroll && els.autoscroll.checked) {
multiViewLog.scrollTop = multiViewLog.scrollHeight;
}
// Отладочная информация для multi-view
console.log(`Multi-view: Added line with class ${cls} to container ${id}, HTML:`, html.substring(0, 100));
} else {
// Отладочная информация для отфильтрованных строк
console.log(`Multi-view: Filtered out line with class ${cls} from container ${id}, line: "${line.substring(0, 100)}..."`);
}
} else {
// Отладочная информация если элемент не найден
console.log(`Multi-view: Element not found for container ${id}, available elements:`, document.querySelectorAll('.multi-view-log').length);
}
}
}
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;
}
function switchToSingle(svc){
// Legacy functionality
setLayout('tabs');
els.grid.innerHTML='';
const panel = ensurePanel(svc);
panel.querySelector('.log').textContent='';
closeWs(svc.id);
openWs(svc, panel);
state.current = svc;
buildTabs();
for (const p of [...els.grid.children]) if (p!==panel) p.remove();
// Modern interface updates
if (els.logTitle) {
els.logTitle.textContent = `${svc.name} (${svc.service || svc.name})`;
}
if (els.logContent) {
els.logContent.textContent = 'Connecting...';
}
// 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');
}
// Добавляем обработчики для счетчиков после переключения контейнера
addCounterClickHandlers();
}
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) 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(state.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 response = await fetch(`/api/logs/stats/${containerId}`);
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();
}
} catch (error) {
console.error('Error updating counters:', error);
}
}
// Функция для обновления видимости счетчиков
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);
// Обновляем счетчики для всех выбранных контейнеров
for (const containerId of state.selectedContainers) {
await updateCounters(containerId);
}
// Перезапускаем 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);
// Обновляем счетчики
await updateCounters(state.current.id);
// Перезапускаем WebSocket соединение для получения свежих логов
const currentId = state.current.id;
closeWs(currentId);
// Находим обновленный контейнер в списке
const updatedContainer = state.services.find(s => s.id === currentId);
if (updatedContainer) {
// Переключаемся на обновленный контейнер
switchToSingle(updatedContainer);
}
} 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 = '';
}
// Очищаем мультипросмотр
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';
});
};
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) {
// Переключаемся на обновленный контейнер
switchToSingle(updatedContainer);
} else {
// Если контейнер больше не существует, переключаемся на первый доступный
if (state.services.length > 0) {
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', (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);
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', () => {
if (confirm('Вы уверены, что хотите выйти?')) {
// Очищаем localStorage
localStorage.clear();
// Перенаправляем на страницу входа
window.location.href = '/';
}
});
}
});
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;
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...';
}
};
}
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();
};
}
// Обработчики для LogLevels (если элементы существуют)
if (els.lvlDebug) {
els.lvlDebug.onchange = ()=> {
state.levels.debug = els.lvlDebug.checked;
updateCounterVisibility();
refreshAllLogs();
// Обновляем multi-view если он активен
if (state.multiViewMode) {
refreshAllLogs();
}
};
}
if (els.lvlInfo) {
els.lvlInfo.onchange = ()=> {
state.levels.info = els.lvlInfo.checked;
updateCounterVisibility();
refreshAllLogs();
// Обновляем multi-view если он активен
if (state.multiViewMode) {
refreshAllLogs();
}
};
}
if (els.lvlWarn) {
els.lvlWarn.onchange = ()=> {
state.levels.warn = els.lvlWarn.checked;
updateCounterVisibility();
refreshAllLogs();
// Обновляем multi-view если он активен
if (state.multiViewMode) {
refreshAllLogs();
}
};
}
if (els.lvlErr) {
els.lvlErr.onchange = ()=> {
state.levels.err = els.lvlErr.checked;
updateCounterVisibility();
refreshAllLogs();
// Обновляем multi-view если он активен
if (state.multiViewMode) {
refreshAllLogs();
}
};
}
// Hotkeys: [ ] — tabs, M — multi, R — refresh
window.addEventListener('keydown', (e)=>{
if (e.key==='[' || (e.ctrlKey && e.key==='ArrowLeft')){
e.preventDefault();
const idx = state.services.findIndex(s=> s.id===state.current?.id);
if (idx>0) 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) switchToSingle(state.services[idx+1]);
}
if (e.key.toLowerCase()==='m'){
const list = state.services.map(s=> `${s.id}:${s.service}`).join(', ');
const ans = prompt('IDs через запятую:\n'+list);
if (ans) openMulti(ans.split(',').map(x=>x.trim()).filter(Boolean));
}
if (e.key.toLowerCase()==='r' || e.key.toLowerCase()==='к'){
e.preventDefault();
refreshLogsAndCounters();
}
});
// Инициализация
(async function init() {
console.log('Initializing LogBoard+...');
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();
// Инициализируем видимость счетчиков
updateCounterVisibility();
// Добавляем обработчики для счетчиков
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);
}
});
})();
</script>
</body>
</html>