4578 lines
162 KiB
HTML
4578 lines
162 KiB
HTML
<!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;
|
||
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 = {
|
||
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('');
|
||
|
||
// Сразу очищаем пустые строки в legacy панели
|
||
cleanSingleViewEmptyLines(obj.logEl);
|
||
cleanDuplicateLines(obj.logEl);
|
||
|
||
// Обновляем современный интерфейс
|
||
if (state.current && state.current.id === id && els.logContent) {
|
||
els.logContent.innerHTML = obj.logEl.innerHTML;
|
||
|
||
// Очищаем дублированные строки в Single View после обновления
|
||
cleanSingleViewEmptyLines(els.logContent);
|
||
cleanDuplicateLines(els.logContent);
|
||
}
|
||
});
|
||
|
||
// Обновляем мультипросмотр
|
||
if (state.multiViewMode) {
|
||
state.selectedContainers.forEach(containerId => {
|
||
const obj = state.open[containerId];
|
||
if (!obj || !obj.logEl) return;
|
||
|
||
// Получаем все логи из буфера
|
||
const allLogs = obj.allLogs || [];
|
||
const filteredHtml = [];
|
||
|
||
allLogs.forEach(logEntry => {
|
||
// Проверяем уровень логирования
|
||
if (!allowedByLevel(logEntry.cls)) return;
|
||
|
||
// Проверяем фильтр
|
||
if (!applyFilter(logEntry.line)) return;
|
||
|
||
filteredHtml.push(logEntry.html);
|
||
});
|
||
|
||
// Обновляем отображение в мультипросмотре
|
||
const multiViewLog = document.querySelector(`.multi-view-log[data-container-id="${containerId}"]`);
|
||
if (multiViewLog) {
|
||
multiViewLog.innerHTML = filteredHtml.join('');
|
||
|
||
// Сразу очищаем пустые строки в мультипросмотре
|
||
cleanMultiViewEmptyLines(multiViewLog);
|
||
cleanMultiViewDuplicateLines(multiViewLog);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Пересчитываем счетчики в зависимости от режима после обновления логов
|
||
setTimeout(() => {
|
||
if (state.multiViewMode) {
|
||
recalculateMultiViewCounters();
|
||
} else {
|
||
recalculateCounters();
|
||
}
|
||
}, 100);
|
||
}
|
||
function escapeHtml(s){ return s.replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m])); }
|
||
|
||
function classify(line){
|
||
const l = line.toLowerCase();
|
||
|
||
// Проверяем различные форматы уровней логирования (более специфичные сначала)
|
||
|
||
// DEBUG - ищем точное совпадение уровня логирования
|
||
if (/\s- DEBUG -|\s\[debug\]|level=debug|\bdebug\b(?=\s|$)/.test(l)) {
|
||
return 'dbg';
|
||
}
|
||
|
||
// ERROR - ищем точное совпадение уровня логирования
|
||
if (/\s- ERROR -|\s\[error\]|level=error/.test(l)) {
|
||
return 'err';
|
||
}
|
||
|
||
// WARNING - ищем точное совпадение уровня логирования
|
||
if (/\s- WARNING -|\s\[warn\]|level=warn/.test(l)) {
|
||
return 'warn';
|
||
}
|
||
|
||
// INFO - ищем точное совпадение уровня логирования
|
||
if (/\s- INFO -|\s\[info\]|level=info/.test(l)) {
|
||
return 'ok';
|
||
}
|
||
|
||
// Дополнительные проверки для других форматов (только если не найдены точные совпадения)
|
||
if (/\bdebug\b/i.test(l)) return 'dbg';
|
||
if (/\berror\b/i.test(l)) return 'err';
|
||
if (/\bwarning\b/i.test(l)) return 'warn';
|
||
if (/\binfo\b/i.test(l)) return 'ok';
|
||
|
||
return 'other';
|
||
}
|
||
function allowedByLevel(cls){
|
||
if (cls==='dbg') return state.levels.debug;
|
||
if (cls==='err') return state.levels.err;
|
||
if (cls==='warn') return state.levels.warn;
|
||
if (cls==='ok') return state.levels.info;
|
||
if (cls==='other') return 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 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) {
|
||
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 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) 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(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;
|
||
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(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();
|
||
|
||
// Очищаем дублированные строки в 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', () => {
|
||
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;
|
||
|
||
// В 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+...');
|
||
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>
|