2183 lines
64 KiB
HTML
2183 lines
64 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;
|
||
}
|
||
|
||
/* 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);
|
||
}
|
||
|
||
.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); }
|
||
|
||
/* Log Area */
|
||
.log-area {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.log-header {
|
||
padding: 12px 20px;
|
||
background: var(--panel);
|
||
border-bottom: 1px solid var(--border);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
}
|
||
|
||
.log-title {
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: var(--fg);
|
||
margin: 0;
|
||
}
|
||
|
||
.log-controls {
|
||
margin-left: auto;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.log-content {
|
||
flex: 1;
|
||
overflow: auto;
|
||
padding: 16px;
|
||
background: var(--bg);
|
||
}
|
||
|
||
.log {
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
font-size: 12px;
|
||
line-height: 1.5;
|
||
margin: 0;
|
||
tab-size: 2;
|
||
}
|
||
|
||
/* Responsive */
|
||
@media (max-width: 768px) {
|
||
.sidebar {
|
||
width: 100%;
|
||
position: absolute;
|
||
z-index: 1000;
|
||
height: 100%;
|
||
transform: translateX(-100%);
|
||
transition: transform 0.3s ease;
|
||
}
|
||
|
||
.sidebar.open {
|
||
transform: translateX(0);
|
||
}
|
||
|
||
.main-content {
|
||
width: 100%;
|
||
}
|
||
|
||
.mobile-toggle {
|
||
display: block;
|
||
}
|
||
}
|
||
|
||
.mobile-toggle {
|
||
display: none;
|
||
background: none;
|
||
border: none;
|
||
color: var(--fg);
|
||
font-size: 18px;
|
||
cursor: pointer;
|
||
padding: 8px;
|
||
}
|
||
|
||
/* Legacy styles for compatibility */
|
||
#tabs{display:none}
|
||
main{display:none}
|
||
.grid-1{grid-template-columns:1fr}
|
||
.grid-2{grid-template-columns:1fr 1fr}
|
||
.grid-3{grid-template-columns:1fr 1fr 1fr}
|
||
.grid-4{grid-template-columns:1fr 1fr;grid-auto-rows:45vh}
|
||
.panel{border:1px solid var(--border);border-radius:10px;background:color-mix(in oklab, var(--panel) 96%, var(--bg));display:flex;flex-direction:column;min-height:0}
|
||
.panel .title{padding:6px 10px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:8px;color:var(--muted);font-size:12px}
|
||
.badge{padding:2px 8px;border-radius:999px;background:var(--chip);border:1px solid var(--border);margin-left:6px;color:var(--muted)}
|
||
.controls .badge{margin-left:0}
|
||
.toolbar{display:flex;gap:6px;margin-left:auto}
|
||
.counter{font-size:11px;color:var(--muted)}
|
||
.logwrap{flex:1;overflow:auto;padding:10px}
|
||
.log{white-space:pre-wrap;word-break:break-word;font-size:12px;line-height:1.5;margin:0;tab-size:2}
|
||
.line{color:var(--fg)} .ok{color:var(--ok)} .warn{color:var(--warn)} .err{color:var(--err)} .dbg{color:#7dcfff} .ts{color:var(--muted)}
|
||
footer{position:fixed;right:10px;bottom:10px;opacity:.6;font-size:11px}
|
||
.filterlvl{display:flex;gap:6px;align-items:center}
|
||
/* Instance tag */
|
||
.inst-tag{display:inline-block;padding:0 6px;margin-right:6px;border-radius:6px;border:1px solid var(--border);opacity:.9}
|
||
/* ANSI */
|
||
.ansi-black{color:#79808f} .ansi-red{color:#f7768e} .ansi-green{color:#22c55e} .ansi-yellow{color:#eab308}
|
||
.ansi-blue{color:#3b82f6} .ansi-magenta{color:#a855f7} .ansi-cyan{color:#06b6d4} .ansi-white{color:var(--fg)}
|
||
.ansi-bold{font-weight:bold} .ansi-italic{font-style:italic} .ansi-underline{text-decoration:underline}
|
||
|
||
/* Theme toggle */
|
||
.theme-toggle{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted)}
|
||
.theme-toggle input{appearance:none;width:36px;height:20px;border-radius:999px;position:relative;background:var(--chip);border:1px solid var(--border);cursor:pointer}
|
||
.theme-toggle input::after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:var(--fg);transition:transform .2s ease}
|
||
.theme-toggle input:checked::after{transform:translateX(16px)}
|
||
|
||
/* Floating copy button */
|
||
.copy-fab{
|
||
position:fixed; z-index:9999; display:none; padding:6px 10px; border-radius:8px;
|
||
background:var(--accent); color:#0b0d12; border:none; box-shadow:0 6px 20px rgba(0,0,0,.25);
|
||
font-size:12px;
|
||
}
|
||
.copy-fab.show{display:block}
|
||
.copy-fab:active{transform:translateY(1px)}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="app-container">
|
||
<!-- Sidebar -->
|
||
<div class="sidebar">
|
||
<div class="sidebar-header">
|
||
<div class="header-top">
|
||
<h1><i class="fas fa-terminal"></i> LogBoard+</h1>
|
||
<div class="header-buttons">
|
||
<button class="options-btn" id="optionsBtn" title="Показать/скрыть настройки">
|
||
<i class="fas fa-cog"></i>
|
||
</button>
|
||
<button class="logout-btn" id="logoutBtn" title="Выйти">
|
||
<i class="fas fa-sign-out-alt"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<div class="sidebar-controls">
|
||
|
||
|
||
|
||
|
||
<div class="control-group collapsible" data-section="tail">
|
||
<div class="control-header">
|
||
<label>Tail Lines</label>
|
||
<button class="collapse-btn" data-target="tail">
|
||
<i class="fas fa-chevron-down"></i>
|
||
</button>
|
||
</div>
|
||
<div class="control-content" id="tail-content">
|
||
<select id="tail">
|
||
<option value="50">50</option>
|
||
<option value="100">100</option>
|
||
<option value="200">200</option>
|
||
<option value="300">300</option>
|
||
<option value="500" selected>500</option>
|
||
<option value="1000">1000</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="control-group collapsible" data-section="options">
|
||
<div class="control-header">
|
||
<label>Options</label>
|
||
<button class="collapse-btn" data-target="options">
|
||
<i class="fas fa-chevron-down"></i>
|
||
</button>
|
||
</div>
|
||
<div class="control-content" id="options-content">
|
||
<div class="checkbox-group options-grid">
|
||
<div class="checkbox-item">
|
||
<input type="checkbox" id="autoscroll" checked>
|
||
<label for="autoscroll">Auto-scroll</label>
|
||
</div>
|
||
<div class="checkbox-item">
|
||
<input type="checkbox" id="wrap" checked>
|
||
<label for="wrap">Wrap text</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
|
||
<div class="control-group collapsible" data-section="actions">
|
||
<div class="control-header">
|
||
<label>Actions</label>
|
||
<button class="collapse-btn" data-target="actions">
|
||
<i class="fas fa-chevron-down"></i>
|
||
</button>
|
||
</div>
|
||
<div class="control-content" id="actions-content">
|
||
<div class="btn-group actions-grid">
|
||
<button id="refresh" class="btn"><i class="fas fa-sync-alt"></i> Refresh</button>
|
||
<button id="clear" class="btn"><i class="fas fa-trash"></i> Clear</button>
|
||
<button id="snapshot" class="btn btn-full-width"><i class="fas fa-download"></i> Download logs</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 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">
|
||
<select id="projectSelectHeader">
|
||
<option value="all">All Projects</option>
|
||
</select>
|
||
</span>
|
||
<input id="filter" type="text" placeholder="Filter logs (regex)…" class="header-filter"/>
|
||
<div class="header-controls">
|
||
<div class="theme-toggle">
|
||
<span>Theme</span>
|
||
<input id="themeSwitch" type="checkbox" />
|
||
</div>
|
||
<button id="wsstate" class="ws-status-btn">ws: off</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Log Area -->
|
||
<div class="log-area">
|
||
<div class="log-header">
|
||
<h3 class="log-title" id="logTitle">Select a container to view logs</h3>
|
||
<div class="log-controls">
|
||
<button class="counter-btn debug-btn" title="DEBUG">
|
||
<span class="counter-label">DEBUG</span>
|
||
<span class="counter-value cdbg">0</span>
|
||
</button>
|
||
<button class="counter-btn info-btn" title="INFO">
|
||
<span class="counter-label">INFO</span>
|
||
<span class="counter-value cinfo">0</span>
|
||
</button>
|
||
<button class="counter-btn warn-btn" title="WARN">
|
||
<span class="counter-label">WARN</span>
|
||
<span class="counter-value cwarn">0</span>
|
||
</button>
|
||
<button class="counter-btn error-btn" title="ERROR">
|
||
<span class="counter-label">ERROR</span>
|
||
<span class="counter-value cerr">0</span>
|
||
</button>
|
||
<button id="logRefreshBtn" class="btn btn-small" title="Обновить логи и счетчики">
|
||
<i class="fas fa-sync-alt"></i> Refresh
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="log-content">
|
||
<pre class="log" id="logContent">No container selected</pre>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Legacy elements for compatibility -->
|
||
<div id="tabs" style="display: none;"></div>
|
||
<div id="idFilters" style="display: none;"></div>
|
||
<main id="grid" style="display: none;"></main>
|
||
<button id="copyFab" class="copy-fab" type="button">копировать</button>
|
||
<footer style="display: none;">© LogBoard+</footer>
|
||
|
||
<script>
|
||
const state = {
|
||
token: (document.querySelector('meta[name="x-token"]')?.content)||'',
|
||
services: [],
|
||
current: null,
|
||
open: {}, // id -> {ws, logEl, wrapEl, counters, pausedBuffer:[], serviceName}
|
||
layout: 'tabs', // 'tabs' | 'grid2' | 'grid3' | 'grid4'
|
||
filter: null,
|
||
levels: {debug:true, info:true, warn:true, err:true},
|
||
};
|
||
|
||
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'),
|
||
projectSelect: document.getElementById('projectSelect'),
|
||
clearBtn: document.getElementById('clear'),
|
||
refreshBtn: document.getElementById('refresh'),
|
||
snapshotBtn: document.getElementById('snapshot'),
|
||
lvlDebug: document.getElementById('lvlDebug'),
|
||
lvlInfo: document.getElementById('lvlInfo'),
|
||
lvlWarn: document.getElementById('lvlWarn'),
|
||
lvlErr: document.getElementById('lvlErr'),
|
||
layoutBadge: document.getElementById('layoutBadge') || { textContent: '' },
|
||
aggregate: document.getElementById('aggregate') || { checked: false },
|
||
themeSwitch: document.getElementById('themeSwitch'),
|
||
copyFab: document.getElementById('copyFab'),
|
||
groupBtn: document.getElementById('groupBtn') || { onclick: null },
|
||
|
||
// New modern elements
|
||
containerList: document.getElementById('containerList'),
|
||
logTitle: document.getElementById('logTitle'),
|
||
logContent: document.getElementById('logContent'),
|
||
mobileToggle: document.getElementById('mobileToggle'),
|
||
optionsBtn: document.getElementById('optionsBtn'),
|
||
logoutBtn: document.getElementById('logoutBtn'),
|
||
logRefreshBtn: document.getElementById('logRefreshBtn'),
|
||
};
|
||
|
||
// ----- Theme toggle -----
|
||
(function initTheme(){
|
||
const saved = localStorage.lb_theme || 'dark';
|
||
document.documentElement.setAttribute('data-theme', saved);
|
||
els.themeSwitch.checked = (saved==='light');
|
||
els.themeSwitch.addEventListener('change', ()=>{
|
||
const t = els.themeSwitch.checked ? 'light' : 'dark';
|
||
document.documentElement.setAttribute('data-theme', t);
|
||
localStorage.lb_theme = t;
|
||
});
|
||
})();
|
||
|
||
function setWsState(s){
|
||
els.wsstate.textContent = 'ws: ' + s;
|
||
|
||
// Удаляем все классы состояний
|
||
els.wsstate.classList.remove('ws-on', 'ws-off', 'ws-err');
|
||
|
||
// Добавляем соответствующий класс
|
||
if (s === 'on') {
|
||
els.wsstate.classList.add('ws-on');
|
||
} else if (s === 'off') {
|
||
els.wsstate.classList.add('ws-off');
|
||
} else if (s === 'err') {
|
||
els.wsstate.classList.add('ws-err');
|
||
}
|
||
}
|
||
|
||
// Функция для обновления всех логов при изменении фильтров
|
||
function refreshAllLogs() {
|
||
Object.keys(state.open).forEach(id => {
|
||
const obj = state.open[id];
|
||
if (!obj || !obj.logEl) return;
|
||
|
||
// Получаем все логи из буфера
|
||
const allLogs = obj.allLogs || [];
|
||
const filteredHtml = [];
|
||
|
||
allLogs.forEach(logEntry => {
|
||
// Проверяем уровень логирования
|
||
if (!allowedByLevel(logEntry.cls)) return;
|
||
|
||
// Проверяем фильтр
|
||
if (!applyFilter(logEntry.line)) return;
|
||
|
||
filteredHtml.push(logEntry.html);
|
||
});
|
||
|
||
// Обновляем отображение
|
||
obj.logEl.innerHTML = filteredHtml.join('');
|
||
|
||
// Обновляем современный интерфейс
|
||
if (state.current && state.current.id === id && els.logContent) {
|
||
els.logContent.innerHTML = obj.logEl.innerHTML;
|
||
}
|
||
});
|
||
}
|
||
function escapeHtml(s){ return s.replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m])); }
|
||
|
||
function classify(line){
|
||
const l = line.toLowerCase();
|
||
|
||
// Проверяем различные форматы уровней логирования
|
||
if (/\bdebug\b|level=debug| \[debug\]/.test(l)) return 'dbg';
|
||
if (/\berr(or)?\b|level=error| \[error\]/.test(l)) return 'err';
|
||
if (/\bwarn(ing)?\b|level=warn| \[warn\]/.test(l)) return 'warn';
|
||
if (/\b(info|started|listening|ready|up)\b|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';
|
||
|
||
// Отладочная информация для неклассифицированных строк
|
||
if (line.includes('level=')) {
|
||
console.log(`Unclassified line with level: "${line.substring(0, 200)}..."`);
|
||
console.log(`Lowercase version: "${l.substring(0, 200)}..."`);
|
||
}
|
||
|
||
return 'other';
|
||
}
|
||
function allowedByLevel(cls){
|
||
if (cls==='dbg') return state.levels.debug;
|
||
if (cls==='err') return state.levels.err;
|
||
if (cls==='warn') return state.levels.warn;
|
||
if (cls==='ok') return state.levels.info;
|
||
if (cls==='other') return true; // Всегда показываем неклассифицированные строки
|
||
return true;
|
||
}
|
||
function applyFilter(line){
|
||
if(!state.filter) return true;
|
||
try{
|
||
// Экранируем специальные символы regex для безопасного поиска
|
||
const escapedFilter = state.filter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
return new RegExp(escapedFilter, 'i').test(line);
|
||
}catch(e){
|
||
console.error('Filter error:', e);
|
||
return true;
|
||
}
|
||
}
|
||
|
||
// ANSI → HTML (SGR: 0/1/3/4, 30-37)
|
||
|
||
// ----- Instance color & filters -----
|
||
const inst = { colors: {}, filters: {}, palette: [
|
||
'#7aa2f7','#9ece6a','#e0af68','#f7768e','#bb9af7','#7dcfff','#c0caf5','#f6bd60',
|
||
'#84cc16','#06b6d4','#fb923c','#ef4444','#22c55e','#a855f7'
|
||
]};
|
||
|
||
function idColor(id8){
|
||
if (inst.colors[id8]) return inst.colors[id8];
|
||
// simple hash to pick from palette
|
||
let h = 0; for (let i=0;i<id8.length;i++){ h = (h*31 + id8.charCodeAt(i))>>>0; }
|
||
const color = inst.palette[h % inst.palette.length];
|
||
inst.colors[id8] = color;
|
||
return color;
|
||
}
|
||
|
||
function updateIdFiltersBar(){
|
||
const bar = document.getElementById('idFilters');
|
||
bar.innerHTML = '';
|
||
const ids = Object.keys(inst.filters);
|
||
if (!ids.length){ bar.style.display='none'; return; }
|
||
bar.style.display='flex';
|
||
ids.forEach(id8=>{
|
||
const wrap = document.createElement('label');
|
||
wrap.style.display='inline-flex'; wrap.style.alignItems='center'; wrap.style.gap='6px';
|
||
const cb = document.createElement('input'); cb.type='checkbox'; cb.checked = inst.filters[id8] !== false;
|
||
cb.onchange = ()=> inst.filters[id8] = cb.checked;
|
||
const chip = document.createElement('span');
|
||
chip.className='inst-tag';
|
||
chip.style.borderColor = idColor(id8);
|
||
chip.style.color = idColor(id8);
|
||
chip.textContent = id8;
|
||
wrap.appendChild(cb); wrap.appendChild(chip);
|
||
bar.appendChild(wrap);
|
||
});
|
||
}
|
||
|
||
function shouldShowInstance(id8){
|
||
if (!Object.keys(inst.filters).length) return true;
|
||
const val = inst.filters[id8];
|
||
return val !== false;
|
||
}
|
||
|
||
function parsePrefixAndStrip(line){
|
||
// Accept "[id]" or "[id service]" prefixes from fan/fan_group
|
||
const m = line.match(/^\[([0-9a-f]{8})(?:\s+[^\]]+)?\]\s(.*)$/i);
|
||
if (!m) return null;
|
||
return {id8: m[1], rest: m[2]};
|
||
}
|
||
|
||
function ansiToHtml(text){
|
||
const ESC = '\u001b[';
|
||
const parts = text.split(ESC);
|
||
if (parts.length === 1) return escapeHtml(text);
|
||
let html = escapeHtml(parts[0]);
|
||
let classes = [];
|
||
for (let i=1;i<parts.length;i++){
|
||
const seg = parts[i];
|
||
const m = seg.match(/^([0-9;]+)m(.*)$/s);
|
||
if(!m){ html += escapeHtml(seg); continue; }
|
||
const codes = m[1].split(';').map(Number);
|
||
let rest = m[2];
|
||
for(const c of codes){
|
||
if (c===0) classes = [];
|
||
else if (c===1) classes.push('ansi-bold');
|
||
else if (c===3) classes.push('ansi-italic');
|
||
else if (c===4) classes.push('ansi-underline');
|
||
else if (c>=30 && c<=37){
|
||
classes = classes.filter(x=>!x.startsWith('ansi-'));
|
||
const map = {30:'black',31:'red',32:'green',33:'yellow',34:'blue',35:'magenta',36:'cyan',37:'white'};
|
||
classes.push('ansi-'+map[c]);
|
||
}
|
||
}
|
||
if (classes.length) html += `<span class="${classes.join(' ')}">` + escapeHtml(rest) + `</span>`;
|
||
else html += escapeHtml(rest);
|
||
}
|
||
return html;
|
||
}
|
||
|
||
function panelTemplate(svc){
|
||
const div = document.createElement('div'); div.className='panel'; div.dataset.cid = svc.id;
|
||
div.innerHTML = `
|
||
<div class="title">
|
||
<span>${escapeHtml((svc.project?`[${svc.project}] `:'')+(svc.service||svc.name))}</span>
|
||
<span class="counter">dbg:<b class="cdbg">0</b> info:<b class="cinfo">0</b> warn:<b class="cwarn">0</b> err:<b class="cerr">0</b></span>
|
||
<div class="toolbar">
|
||
<button class="primary t-reconnect">reconnect</button>
|
||
<button class="t-snapshot">snapshot</button>
|
||
<button class="t-close">close</button>
|
||
</div>
|
||
</div>
|
||
<div class="logwrap"><pre class="log"></pre></div>`;
|
||
return div;
|
||
}
|
||
|
||
function buildTabs(){
|
||
// Legacy tabs (hidden)
|
||
els.tabs.innerHTML='';
|
||
state.services.forEach(svc=>{
|
||
const b = document.createElement('button');
|
||
b.className='tab' + ((state.current && svc.id===state.current.id) ? ' active':'');
|
||
b.textContent = (svc.project?(`[${svc.project}] `):'') + (svc.service||svc.name);
|
||
b.title = `${svc.name} • ${svc.image} • ${svc.status}`;
|
||
b.onclick = ()=> switchToSingle(svc);
|
||
els.tabs.appendChild(b);
|
||
});
|
||
|
||
// Modern container list
|
||
els.containerList.innerHTML = '';
|
||
state.services.forEach(svc => {
|
||
const item = document.createElement('div');
|
||
item.className = 'container-item';
|
||
if (state.current && svc.id === state.current.id) {
|
||
item.classList.add('active');
|
||
}
|
||
item.setAttribute('data-cid', svc.id);
|
||
|
||
const statusClass = svc.status === 'running' ? 'running' :
|
||
svc.status === 'stopped' ? 'stopped' : 'paused';
|
||
|
||
item.innerHTML = `
|
||
<div class="container-name">
|
||
<i class="fas fa-cube"></i>
|
||
${escapeHtml(svc.name)}
|
||
</div>
|
||
<div class="container-service">
|
||
${escapeHtml(svc.service || svc.name)}
|
||
${svc.project ? ` • ${escapeHtml(svc.project)}` : ''}
|
||
</div>
|
||
<div class="container-status">
|
||
<span class="status-indicator ${statusClass}"></span>
|
||
${escapeHtml(svc.status)}
|
||
</div>
|
||
`;
|
||
|
||
item.onclick = () => 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 select = els.projectSelect;
|
||
if (select) {
|
||
select.innerHTML = '<option value="all">All Projects</option>';
|
||
projects.forEach(project => {
|
||
const option = document.createElement('option');
|
||
option.value = project;
|
||
option.textContent = project;
|
||
select.appendChild(option);
|
||
});
|
||
|
||
// Устанавливаем сохраненный проект
|
||
if (localStorage.lb_project && projects.includes(localStorage.lb_project)) {
|
||
select.value = localStorage.lb_project;
|
||
}
|
||
}
|
||
|
||
// Обновляем селектор проектов в заголовке
|
||
const headerSelect = document.getElementById('projectSelectHeader');
|
||
console.log('Header select element found:', !!headerSelect);
|
||
if (headerSelect) {
|
||
headerSelect.innerHTML = '<option value="all">All Projects</option>';
|
||
console.log('Adding projects to header select:', projects);
|
||
projects.forEach(project => {
|
||
const option = document.createElement('option');
|
||
option.value = project;
|
||
option.textContent = project;
|
||
headerSelect.appendChild(option);
|
||
});
|
||
|
||
// Устанавливаем сохраненный проект
|
||
if (localStorage.lb_project && projects.includes(localStorage.lb_project)) {
|
||
headerSelect.value = localStorage.lb_project;
|
||
}
|
||
console.log('Header select updated, current value:', headerSelect.value);
|
||
} else {
|
||
console.error('Header select element not found!');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error fetching projects:', error);
|
||
}
|
||
}
|
||
|
||
async function fetchServices(){
|
||
try {
|
||
console.log('Fetching services...');
|
||
const url = new URL(location.origin + '/api/services');
|
||
const selectedProject = els.projectSelect ? els.projectSelect.value : 'all';
|
||
|
||
if (selectedProject && selectedProject !== 'all') {
|
||
url.searchParams.set('projects', selectedProject);
|
||
localStorage.lb_project = selectedProject;
|
||
} else {
|
||
localStorage.removeItem('lb_project');
|
||
}
|
||
|
||
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;
|
||
const pj = selectedProject === 'all' ? 'all' : selectedProject;
|
||
|
||
// Обновляем селектор в заголовке
|
||
const headerSelect = document.getElementById('projectSelectHeader');
|
||
if (headerSelect) {
|
||
headerSelect.value = selectedProject;
|
||
}
|
||
|
||
buildTabs();
|
||
if (!state.current && state.services.length) switchToSingle(state.services[0]);
|
||
|
||
// Добавляем обработчики для счетчиков после загрузки сервисов
|
||
addCounterClickHandlers();
|
||
} catch (error) {
|
||
console.error('Error fetching services:', error);
|
||
}
|
||
}
|
||
|
||
function wsUrl(containerId, service, project){
|
||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||
const tail = els.tail.value || '500';
|
||
const token = encodeURIComponent(state.token || '');
|
||
const sp = service?`&service=${encodeURIComponent(service)}`:'';
|
||
const pj = project?`&project=${encodeURIComponent(project)}`:'';
|
||
if (els.aggregate && els.aggregate.checked && service){
|
||
// fan-in by service
|
||
return `${proto}://${location.host}/ws/fan/${encodeURIComponent(service)}?tail=${tail}&token=${token}${pj}`;
|
||
}
|
||
return `${proto}://${location.host}/ws/logs/${encodeURIComponent(containerId)}?tail=${tail}&token=${token}${sp}${pj}`;
|
||
}
|
||
|
||
function closeWs(id){
|
||
const o = state.open[id];
|
||
if (!o) return;
|
||
|
||
try { o.ws.close(); } catch(e){}
|
||
delete state.open[id];
|
||
}
|
||
|
||
async function sendSnapshot(id){
|
||
const o = state.open[id];
|
||
if (!o){ alert('not open'); return; }
|
||
|
||
// Получаем текст логов из современного интерфейса или из legacy
|
||
let text = '';
|
||
if (state.current && state.current.id === id && els.logContent) {
|
||
text = els.logContent.textContent;
|
||
} else if (o.logEl) {
|
||
text = o.logEl.textContent;
|
||
}
|
||
|
||
if (!text || text.trim() === '') {
|
||
alert('No logs to save');
|
||
return;
|
||
}
|
||
|
||
console.log('Saving snapshot with content length:', text.length);
|
||
|
||
const payload = {container_id: id, service: o.serviceName || id, content: text};
|
||
const res = await fetch('/api/snapshot', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload)});
|
||
if (!res.ok){
|
||
console.error('Snapshot failed:', res.status, res.statusText);
|
||
alert('snapshot failed');
|
||
return;
|
||
}
|
||
const js = await res.json();
|
||
const a = document.createElement('a');
|
||
a.href = js.url; a.download = js.file; a.click();
|
||
}
|
||
|
||
function openWs(svc, panel){
|
||
const id = svc.id;
|
||
const logEl = panel.querySelector('.log');
|
||
const wrapEl = panel.querySelector('.logwrap');
|
||
|
||
// Ищем счетчики в panel или в глобальных элементах
|
||
const cdbg = panel.querySelector('.cdbg') || document.querySelector('.cdbg');
|
||
const cinfo = panel.querySelector('.cinfo') || document.querySelector('.cinfo');
|
||
const cwarn = panel.querySelector('.cwarn') || document.querySelector('.cwarn');
|
||
const cerr = panel.querySelector('.cerr') || document.querySelector('.cerr');
|
||
const counters = {dbg:0,info:0,warn:0,err:0};
|
||
|
||
const ws = new WebSocket(wsUrl(id, (svc.service||svc.name), svc.project||''));
|
||
state.open[id] = {ws, logEl, wrapEl, counters, pausedBuffer:[], serviceName: (svc.service||svc.name)};
|
||
|
||
ws.onopen = ()=> setWsState('on');
|
||
ws.onclose = ()=> setWsState(Object.keys(state.open).length? 'on':'off');
|
||
ws.onerror = ()=> setWsState('err');
|
||
ws.onmessage = (ev)=>{
|
||
console.log(`Received WebSocket message: ${ev.data.substring(0, 100)}...`);
|
||
|
||
const parts = (ev.data||'').split(/\r?\n/);
|
||
|
||
for (let i=0;i<parts.length;i++){
|
||
if (parts[i].length===0 && i===parts.length-1) continue;
|
||
// harvest instance ids if present
|
||
const pr = parsePrefixAndStrip(parts[i]);
|
||
if (pr){ if (!(pr.id8 in inst.filters)) { inst.filters[pr.id8] = true; updateIdFiltersBar(); } }
|
||
handleLine(id, parts[i]);
|
||
}
|
||
|
||
// Обновляем счетчики после обработки всех строк
|
||
cdbg.textContent = counters.dbg;
|
||
cinfo.textContent = counters.info;
|
||
cwarn.textContent = counters.warn;
|
||
cerr.textContent = counters.err;
|
||
};
|
||
|
||
// Убираем автоматический refresh - теперь только по кнопке
|
||
|
||
function handleLine(id, line){
|
||
const cls = classify(line);
|
||
if (cls==='dbg') counters.dbg++;
|
||
if (cls==='ok') counters.info++;
|
||
if (cls==='warn') counters.warn++;
|
||
if (cls==='err') counters.err++;
|
||
|
||
// Отладочная информация для первых нескольких строк
|
||
if (counters.dbg + counters.info + counters.warn + counters.err < 10) {
|
||
console.log(`Line: "${line.substring(0, 100)}..." -> Class: ${cls}`);
|
||
}
|
||
|
||
// Отладочная информация о счетчиках
|
||
if (counters.dbg + counters.info + counters.warn + counters.err < 5) {
|
||
console.log(`Counters: DEBUG=${counters.dbg}, INFO=${counters.info}, WARN=${counters.warn}, ERROR=${counters.err}`);
|
||
}
|
||
|
||
const html = `<span class="line ${cls}">${ansiToHtml(line)}</span>\n`;
|
||
const obj = state.open[id];
|
||
if (!obj) return;
|
||
|
||
// Сохраняем все логи в буфере (всегда)
|
||
if (!obj.allLogs) obj.allLogs = [];
|
||
obj.allLogs.push({html: html, line: line, cls: cls});
|
||
|
||
// Ограничиваем размер буфера
|
||
if (obj.allLogs.length > 10000) {
|
||
obj.allLogs = obj.allLogs.slice(-5000);
|
||
}
|
||
|
||
// Проверяем фильтры для отображения
|
||
if (!allowedByLevel(cls)) return;
|
||
if (!applyFilter(line)) return;
|
||
|
||
// Добавляем логи в отображение
|
||
obj.logEl.insertAdjacentHTML('beforeend', html);
|
||
if (els.autoscroll.checked && obj.wrapEl) obj.wrapEl.scrollTop = obj.wrapEl.scrollHeight;
|
||
|
||
// Update modern interface
|
||
if (state.current && state.current.id === id && els.logContent) {
|
||
els.logContent.innerHTML = obj.logEl.innerHTML;
|
||
const logContent = document.querySelector('.log-content');
|
||
if (logContent && els.autoscroll.checked) {
|
||
logContent.scrollTop = logContent.scrollHeight;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function ensurePanel(svc){
|
||
let panel = els.grid.querySelector(`.panel[data-cid="${svc.id}"]`);
|
||
if (!panel){
|
||
panel = panelTemplate(svc);
|
||
els.grid.appendChild(panel);
|
||
panel.querySelector('.t-reconnect').onclick = ()=>{
|
||
const id = svc.id;
|
||
const o = state.open[id];
|
||
if (o){ o.logEl.textContent=''; closeWs(id); }
|
||
openWs(svc, panel);
|
||
};
|
||
panel.querySelector('.t-close').onclick = ()=>{
|
||
closeWs(svc.id);
|
||
panel.remove();
|
||
if (!Object.keys(state.open).length) setWsState('off');
|
||
};
|
||
panel.querySelector('.t-snapshot').onclick = ()=> sendSnapshot(svc.id);
|
||
}
|
||
return panel;
|
||
}
|
||
|
||
function switchToSingle(svc){
|
||
// Legacy functionality
|
||
setLayout('tabs');
|
||
els.grid.innerHTML='';
|
||
const panel = ensurePanel(svc);
|
||
panel.querySelector('.log').textContent='';
|
||
closeWs(svc.id);
|
||
openWs(svc, panel);
|
||
state.current = svc;
|
||
buildTabs();
|
||
for (const p of [...els.grid.children]) if (p!==panel) p.remove();
|
||
|
||
// Modern interface updates
|
||
if (els.logTitle) {
|
||
els.logTitle.textContent = `${svc.name} (${svc.service || svc.name})`;
|
||
}
|
||
if (els.logContent) {
|
||
els.logContent.textContent = 'Connecting...';
|
||
}
|
||
|
||
// Update active state in container list
|
||
document.querySelectorAll('.container-item').forEach(item => {
|
||
item.classList.remove('active');
|
||
});
|
||
const activeItem = document.querySelector(`.container-item[data-cid="${svc.id}"]`);
|
||
if (activeItem) {
|
||
activeItem.classList.add('active');
|
||
}
|
||
|
||
// Добавляем обработчики для счетчиков после переключения контейнера
|
||
addCounterClickHandlers();
|
||
}
|
||
|
||
function openMulti(ids){
|
||
els.grid.innerHTML='';
|
||
const chosen = state.services.filter(s=> ids.includes(s.id));
|
||
const n = chosen.length;
|
||
if (n<=1){ if (n===1) switchToSingle(chosen[0]); return; }
|
||
setLayout(n>=4 ? 'grid4' : n===3 ? 'grid3' : 'grid2');
|
||
for (const svc of chosen){
|
||
const panel = ensurePanel(svc);
|
||
panel.querySelector('.log').textContent='';
|
||
closeWs(svc.id);
|
||
openWs(svc, panel);
|
||
}
|
||
|
||
// Добавляем обработчики для счетчиков после открытия мульти-контейнеров
|
||
addCounterClickHandlers();
|
||
}
|
||
|
||
// ----- Copy on selection -----
|
||
function getSelectionText(){
|
||
const sel = window.getSelection();
|
||
return sel && sel.rangeCount ? sel.toString() : "";
|
||
}
|
||
function showCopyFabNearSelection(){
|
||
const sel = window.getSelection();
|
||
if (!sel || sel.rangeCount===0) return hideCopyFab();
|
||
const text = sel.toString();
|
||
if (!text.trim()) return hideCopyFab();
|
||
// Only show if selection inside a .log or .logwrap
|
||
const range = sel.getRangeAt(0);
|
||
const common = range.commonAncestorContainer;
|
||
const el = common.nodeType===1 ? common : common.parentElement;
|
||
if (!el || !el.closest('.logwrap')) return hideCopyFab();
|
||
const rect = range.getBoundingClientRect();
|
||
const top = rect.bottom + 8 + window.scrollY;
|
||
const left = rect.right + 8 + window.scrollX;
|
||
els.copyFab.style.top = top + 'px';
|
||
els.copyFab.style.left = left + 'px';
|
||
els.copyFab.classList.add('show');
|
||
}
|
||
function hideCopyFab(){
|
||
els.copyFab.classList.remove('show');
|
||
}
|
||
document.addEventListener('selectionchange', ()=>{
|
||
// throttle-ish using requestAnimationFrame
|
||
window.requestAnimationFrame(showCopyFabNearSelection);
|
||
});
|
||
document.addEventListener('scroll', hideCopyFab, true);
|
||
els.copyFab.addEventListener('click', async ()=>{
|
||
const text = getSelectionText();
|
||
if (!text) return;
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
const old = els.copyFab.textContent;
|
||
els.copyFab.textContent = 'скопировано';
|
||
setTimeout(()=> els.copyFab.textContent = old, 1000);
|
||
hideCopyFab();
|
||
window.getSelection()?.removeAllRanges();
|
||
} catch(e){
|
||
alert('не удалось скопировать: ' + e);
|
||
}
|
||
});
|
||
|
||
|
||
function fanGroupUrl(servicesCsv, project){
|
||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||
const tail = els.tail.value || '500';
|
||
const token = encodeURIComponent(state.token || '');
|
||
const pj = project?`&project=${encodeURIComponent(project)}`:'';
|
||
return `${proto}://${location.host}/ws/fan_group?services=${encodeURIComponent(servicesCsv)}&tail=${tail}&token=${token}${pj}`;
|
||
}
|
||
|
||
function openFanGroup(services){
|
||
// Build a special panel named after the group
|
||
els.grid.innerHTML='';
|
||
const fake = { id: 'group-'+services.join(','), name:'group', service: 'group', project: (localStorage.lb_project||'') };
|
||
const panel = ensurePanel(fake);
|
||
panel.querySelector('.log').textContent='';
|
||
closeWs(fake.id);
|
||
|
||
// Override ws creation to fan_group
|
||
const id = fake.id;
|
||
const logEl = panel.querySelector('.log');
|
||
const wrapEl = panel.querySelector('.logwrap');
|
||
const cdbg = panel.querySelector('.cdbg') || document.querySelector('.cdbg');
|
||
const cinfo = panel.querySelector('.cinfo') || document.querySelector('.cinfo');
|
||
const cwarn = panel.querySelector('.cwarn') || document.querySelector('.cwarn');
|
||
const cerr = panel.querySelector('.cerr') || document.querySelector('.cerr');
|
||
const counters = {dbg:0,info:0,warn:0,err:0};
|
||
|
||
const ws = new WebSocket(fanGroupUrl(services.join(','), fake.project||''));
|
||
state.open[id] = {ws, logEl, wrapEl, counters, pausedBuffer:[], serviceName: ('group:'+services.join(','))};
|
||
|
||
ws.onopen = ()=> setWsState('on');
|
||
ws.onclose = ()=> setWsState(Object.keys(state.open).length? 'on':'off');
|
||
ws.onerror = ()=> setWsState('err');
|
||
ws.onmessage = (ev)=>{
|
||
const parts = (ev.data||'').split(/\r?\n/);
|
||
for (let i=0;i<parts.length;i++){
|
||
if (parts[i].length===0 && i===parts.length-1) continue;
|
||
const pr = parsePrefixAndStrip(parts[i]);
|
||
if (pr){ if (!(pr.id8 in inst.filters)) { inst.filters[pr.id8] = true; updateIdFiltersBar(); } }
|
||
handleLine(id, parts[i]);
|
||
}
|
||
cdbg.textContent = counters.dbg;
|
||
cinfo.textContent = counters.info;
|
||
cwarn.textContent = counters.warn;
|
||
cerr.textContent = counters.err;
|
||
|
||
// Обновляем видимость счетчиков
|
||
updateCounterVisibility();
|
||
|
||
// Добавляем обработчики для счетчиков
|
||
addCounterClickHandlers();
|
||
};
|
||
|
||
// Show filter bar and clear previous filters
|
||
inst.filters = {};
|
||
updateIdFiltersBar();
|
||
}
|
||
|
||
if (els.groupBtn && els.groupBtn.onclick !== null) {
|
||
els.groupBtn.onclick = ()=>{
|
||
const list = state.services.map(s=> `${s.service}`).filter((v,i,a)=> a.indexOf(v)===i).join(', ');
|
||
const ans = prompt('Введите имена сервисов через запятую:\n'+list);
|
||
if (ans){
|
||
const services = ans.split(',').map(x=>x.trim()).filter(Boolean);
|
||
if (services.length) openFanGroup(services);
|
||
}
|
||
};
|
||
}
|
||
|
||
// Функция для обновления счетчиков через Ajax
|
||
async function updateCounters(containerId) {
|
||
try {
|
||
const response = await fetch(`/api/logs/stats/${containerId}`);
|
||
if (response.ok) {
|
||
const stats = await response.json();
|
||
const cdbg = document.querySelector('.cdbg');
|
||
const cinfo = document.querySelector('.cinfo');
|
||
const cwarn = document.querySelector('.cwarn');
|
||
const cerr = document.querySelector('.cerr');
|
||
|
||
if (cdbg) cdbg.textContent = stats.debug || 0;
|
||
if (cinfo) cinfo.textContent = stats.info || 0;
|
||
if (cwarn) cwarn.textContent = stats.warn || 0;
|
||
if (cerr) cerr.textContent = stats.error || 0;
|
||
|
||
// Обновляем видимость счетчиков
|
||
updateCounterVisibility();
|
||
|
||
// Добавляем обработчики для счетчиков
|
||
addCounterClickHandlers();
|
||
}
|
||
} catch (error) {
|
||
console.error('Error updating counters:', error);
|
||
}
|
||
}
|
||
|
||
// Функция для обновления видимости счетчиков
|
||
function updateCounterVisibility() {
|
||
const debugBtn = document.querySelector('.debug-btn');
|
||
const infoBtn = document.querySelector('.info-btn');
|
||
const warnBtn = document.querySelector('.warn-btn');
|
||
const errorBtn = document.querySelector('.error-btn');
|
||
|
||
if (debugBtn) {
|
||
debugBtn.classList.toggle('disabled', !state.levels.debug);
|
||
}
|
||
if (infoBtn) {
|
||
infoBtn.classList.toggle('disabled', !state.levels.info);
|
||
}
|
||
if (warnBtn) {
|
||
warnBtn.classList.toggle('disabled', !state.levels.warn);
|
||
}
|
||
if (errorBtn) {
|
||
errorBtn.classList.toggle('disabled', !state.levels.err);
|
||
}
|
||
}
|
||
|
||
// Функция для обновления логов и счетчиков
|
||
async function refreshLogsAndCounters() {
|
||
if (!state.current) {
|
||
console.log('No container selected');
|
||
return;
|
||
}
|
||
|
||
console.log('Refreshing logs and counters for:', state.current.id);
|
||
|
||
// Обновляем счетчики
|
||
await updateCounters(state.current.id);
|
||
|
||
// Перезапускаем WebSocket соединение для получения свежих логов
|
||
const currentId = state.current.id;
|
||
closeWs(currentId);
|
||
|
||
// Находим обновленный контейнер в списке
|
||
const updatedContainer = state.services.find(s => s.id === currentId);
|
||
if (updatedContainer) {
|
||
// Переключаемся на обновленный контейнер
|
||
switchToSingle(updatedContainer);
|
||
}
|
||
}
|
||
|
||
// 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 = '';
|
||
}
|
||
// Сбрасываем счетчики
|
||
document.querySelectorAll('.cdbg, .cinfo, .cwarn, .cerr').forEach(el => {
|
||
el.textContent = '0';
|
||
});
|
||
};
|
||
|
||
els.refreshBtn.onclick = async () => {
|
||
console.log('Refreshing services...');
|
||
await fetchServices();
|
||
|
||
// Если есть текущий контейнер, перезапускаем его WebSocket соединение
|
||
if (state.current) {
|
||
console.log('Reconnecting to current container:', state.current.id);
|
||
const currentId = state.current.id;
|
||
|
||
// Закрываем текущее соединение
|
||
closeWs(currentId);
|
||
|
||
// Находим обновленный контейнер в списке
|
||
const updatedContainer = state.services.find(s => s.id === currentId);
|
||
if (updatedContainer) {
|
||
// Переключаемся на обновленный контейнер
|
||
switchToSingle(updatedContainer);
|
||
} else {
|
||
// Если контейнер больше не существует, переключаемся на первый доступный
|
||
if (state.services.length > 0) {
|
||
switchToSingle(state.services[0]);
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
// Обработчик для кнопки refresh логов
|
||
els.logRefreshBtn.onclick = refreshLogsAndCounters;
|
||
|
||
// Обработчики для счетчиков
|
||
function addCounterClickHandlers() {
|
||
const debugBtn = document.querySelector('.debug-btn');
|
||
const infoBtn = document.querySelector('.info-btn');
|
||
const warnBtn = document.querySelector('.warn-btn');
|
||
const errorBtn = document.querySelector('.error-btn');
|
||
|
||
if (debugBtn) {
|
||
debugBtn.onclick = () => {
|
||
state.levels.debug = !state.levels.debug;
|
||
updateCounterVisibility();
|
||
refreshAllLogs();
|
||
// Добавляем refresh для обновления логов
|
||
if (state.current) {
|
||
refreshLogsAndCounters();
|
||
}
|
||
};
|
||
}
|
||
|
||
if (infoBtn) {
|
||
infoBtn.onclick = () => {
|
||
state.levels.info = !state.levels.info;
|
||
updateCounterVisibility();
|
||
refreshAllLogs();
|
||
// Добавляем refresh для обновления логов
|
||
if (state.current) {
|
||
refreshLogsAndCounters();
|
||
}
|
||
};
|
||
}
|
||
|
||
if (warnBtn) {
|
||
warnBtn.onclick = () => {
|
||
state.levels.warn = !state.levels.warn;
|
||
updateCounterVisibility();
|
||
refreshAllLogs();
|
||
// Добавляем refresh для обновления логов
|
||
if (state.current) {
|
||
refreshLogsAndCounters();
|
||
}
|
||
};
|
||
}
|
||
|
||
if (errorBtn) {
|
||
errorBtn.onclick = () => {
|
||
state.levels.err = !state.levels.err;
|
||
updateCounterVisibility();
|
||
refreshAllLogs();
|
||
// Добавляем refresh для обновления логов
|
||
if (state.current) {
|
||
refreshLogsAndCounters();
|
||
}
|
||
};
|
||
}
|
||
}
|
||
if (els.projectSelect) {
|
||
els.projectSelect.onchange = fetchServices;
|
||
}
|
||
|
||
// Функция для добавления обработчика выпадающего списка проектов в заголовке
|
||
function addHeaderProjectSelectHandler() {
|
||
const headerProjectSelect = document.getElementById('projectSelectHeader');
|
||
console.log('Adding handler for header project select, element found:', !!headerProjectSelect);
|
||
if (headerProjectSelect) {
|
||
headerProjectSelect.onchange = () => {
|
||
console.log('Header project select changed to:', headerProjectSelect.value);
|
||
// Синхронизируем с селектором в сайдбаре
|
||
if (els.projectSelect) {
|
||
els.projectSelect.value = headerProjectSelect.value;
|
||
}
|
||
fetchServices();
|
||
};
|
||
}
|
||
}
|
||
|
||
// Mobile menu toggle
|
||
if (els.mobileToggle) {
|
||
els.mobileToggle.onclick = () => {
|
||
const sidebar = document.querySelector('.sidebar');
|
||
if (sidebar) {
|
||
sidebar.classList.toggle('open');
|
||
}
|
||
};
|
||
}
|
||
|
||
// Collapsible sections
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
// Обработчики для сворачивания секций
|
||
document.querySelectorAll('.control-header').forEach(header => {
|
||
header.addEventListener('click', (e) => {
|
||
if (e.target.closest('.collapse-btn')) return; // Не сворачиваем при клике на кнопку
|
||
|
||
const group = header.closest('.control-group');
|
||
|
||
// Если секция минимизирована, сначала разворачиваем
|
||
if (group.classList.contains('minimized')) {
|
||
group.classList.remove('minimized');
|
||
group.classList.add('collapsed');
|
||
const section = group.dataset.section;
|
||
localStorage.setItem(`lb_minimized_${section}`, 'false');
|
||
localStorage.setItem(`lb_collapsed_${section}`, 'true');
|
||
} else {
|
||
// Обычное сворачивание/разворачивание
|
||
group.classList.toggle('collapsed');
|
||
const section = group.dataset.section;
|
||
localStorage.setItem(`lb_collapsed_${section}`, group.classList.contains('collapsed'));
|
||
localStorage.setItem(`lb_minimized_${section}`, 'false');
|
||
}
|
||
});
|
||
});
|
||
|
||
// Обработчики для кнопок сворачивания
|
||
document.querySelectorAll('.collapse-btn').forEach(btn => {
|
||
btn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
const group = btn.closest('.control-group');
|
||
|
||
// Если секция минимизирована, сначала разворачиваем
|
||
if (group.classList.contains('minimized')) {
|
||
group.classList.remove('minimized');
|
||
group.classList.add('collapsed');
|
||
const section = group.dataset.section;
|
||
localStorage.setItem(`lb_minimized_${section}`, 'false');
|
||
localStorage.setItem(`lb_collapsed_${section}`, 'true');
|
||
} else {
|
||
// Обычное сворачивание/разворачивание
|
||
group.classList.toggle('collapsed');
|
||
const section = group.dataset.section;
|
||
localStorage.setItem(`lb_collapsed_${section}`, group.classList.contains('collapsed'));
|
||
localStorage.setItem(`lb_minimized_${section}`, 'false');
|
||
}
|
||
});
|
||
});
|
||
|
||
// Восстанавливаем состояние секций из localStorage или сворачиваем по умолчанию
|
||
document.querySelectorAll('.control-group.collapsible').forEach(group => {
|
||
const section = group.dataset.section;
|
||
const savedCollapsed = localStorage.getItem(`lb_collapsed_${section}`);
|
||
const savedMinimized = localStorage.getItem(`lb_minimized_${section}`);
|
||
|
||
// Если состояние не сохранено, сворачиваем по умолчанию
|
||
if (savedCollapsed === null && savedMinimized === null) {
|
||
group.classList.add('collapsed');
|
||
localStorage.setItem(`lb_collapsed_${section}`, 'true');
|
||
localStorage.setItem(`lb_minimized_${section}`, 'false');
|
||
} else if (savedMinimized === 'true') {
|
||
group.classList.add('minimized');
|
||
group.classList.remove('collapsed');
|
||
} else if (savedCollapsed === 'true') {
|
||
group.classList.add('collapsed');
|
||
group.classList.remove('minimized');
|
||
}
|
||
});
|
||
|
||
// Обработчик для кнопки Options
|
||
if (els.optionsBtn) {
|
||
els.optionsBtn.addEventListener('click', () => {
|
||
const sidebarControls = document.querySelector('.sidebar-controls');
|
||
const isHidden = sidebarControls.classList.contains('hidden');
|
||
|
||
if (isHidden) {
|
||
// Показываем настройки
|
||
sidebarControls.classList.remove('hidden');
|
||
els.optionsBtn.classList.remove('active');
|
||
els.optionsBtn.title = 'Скрыть настройки';
|
||
localStorage.setItem('lb_options_hidden', 'false');
|
||
} else {
|
||
// Скрываем настройки
|
||
sidebarControls.classList.add('hidden');
|
||
els.optionsBtn.classList.add('active');
|
||
els.optionsBtn.title = 'Показать настройки';
|
||
localStorage.setItem('lb_options_hidden', 'true');
|
||
}
|
||
});
|
||
|
||
// Восстанавливаем состояние кнопки Options (по умолчанию скрыто)
|
||
const optionsHidden = localStorage.getItem('lb_options_hidden');
|
||
if (optionsHidden === null || optionsHidden === 'true') {
|
||
document.querySelector('.sidebar-controls').classList.add('hidden');
|
||
els.optionsBtn.classList.add('active');
|
||
els.optionsBtn.title = 'Показать настройки';
|
||
localStorage.setItem('lb_options_hidden', 'true');
|
||
}
|
||
}
|
||
|
||
// Обработчик для кнопки выхода
|
||
if (els.logoutBtn) {
|
||
els.logoutBtn.addEventListener('click', () => {
|
||
if (confirm('Вы уверены, что хотите выйти?')) {
|
||
// Очищаем localStorage
|
||
localStorage.clear();
|
||
// Перенаправляем на страницу входа
|
||
window.location.href = '/';
|
||
}
|
||
});
|
||
}
|
||
|
||
|
||
});
|
||
if (els.snapshotBtn) {
|
||
els.snapshotBtn.onclick = ()=>{
|
||
if (state.current) {
|
||
sendSnapshot(state.current.id);
|
||
} else {
|
||
alert('No container selected');
|
||
}
|
||
};
|
||
}
|
||
if (els.tail) {
|
||
els.tail.onchange = ()=> {
|
||
Object.keys(state.open).forEach(id=>{
|
||
const svc = state.services.find(s=> s.id===id);
|
||
if (!svc) return;
|
||
const panel = els.grid.querySelector(`.panel[data-cid="${id}"]`);
|
||
if (!panel) return;
|
||
state.open[id].logEl.textContent='';
|
||
closeWs(id); openWs(svc, panel);
|
||
});
|
||
|
||
// Обновляем современный интерфейс
|
||
if (state.current && els.logContent) {
|
||
els.logContent.textContent = 'Reconnecting...';
|
||
}
|
||
};
|
||
}
|
||
if (els.wrapToggle) {
|
||
els.wrapToggle.onchange = ()=> {
|
||
document.querySelectorAll('.log').forEach(el=> el.style.whiteSpace = els.wrapToggle.checked ? 'pre-wrap' : 'pre');
|
||
if (els.logContent) {
|
||
els.logContent.style.whiteSpace = els.wrapToggle.checked ? 'pre-wrap' : 'pre';
|
||
}
|
||
};
|
||
}
|
||
|
||
// Добавляем обработчики для 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 (els.filter) {
|
||
els.filter.oninput = ()=> {
|
||
state.filter = els.filter.value.trim();
|
||
refreshAllLogs();
|
||
};
|
||
}
|
||
// Обработчики для LogLevels (если элементы существуют)
|
||
if (els.lvlDebug) {
|
||
els.lvlDebug.onchange = ()=> {
|
||
state.levels.debug = els.lvlDebug.checked;
|
||
updateCounterVisibility();
|
||
refreshAllLogs();
|
||
};
|
||
}
|
||
if (els.lvlInfo) {
|
||
els.lvlInfo.onchange = ()=> {
|
||
state.levels.info = els.lvlInfo.checked;
|
||
updateCounterVisibility();
|
||
refreshAllLogs();
|
||
};
|
||
}
|
||
if (els.lvlWarn) {
|
||
els.lvlWarn.onchange = ()=> {
|
||
state.levels.warn = els.lvlWarn.checked;
|
||
updateCounterVisibility();
|
||
refreshAllLogs();
|
||
};
|
||
}
|
||
if (els.lvlErr) {
|
||
els.lvlErr.onchange = ()=> {
|
||
state.levels.err = els.lvlErr.checked;
|
||
updateCounterVisibility();
|
||
refreshAllLogs();
|
||
};
|
||
}
|
||
|
||
// Hotkeys: [ ] — tabs, M — multi, R — refresh
|
||
window.addEventListener('keydown', (e)=>{
|
||
if (e.key==='[' || (e.ctrlKey && e.key==='ArrowLeft')){
|
||
e.preventDefault();
|
||
const idx = state.services.findIndex(s=> s.id===state.current?.id);
|
||
if (idx>0) switchToSingle(state.services[idx-1]);
|
||
}
|
||
if (e.key===']' || (e.ctrlKey && e.key==='ArrowRight')){
|
||
e.preventDefault();
|
||
const idx = state.services.findIndex(s=> s.id===state.current?.id);
|
||
if (idx>=0 && idx<state.services.length-1) switchToSingle(state.services[idx+1]);
|
||
}
|
||
if (e.key.toLowerCase()==='m'){
|
||
const list = state.services.map(s=> `${s.id}:${s.service}`).join(', ');
|
||
const ans = prompt('IDs через запятую:\n'+list);
|
||
if (ans) openMulti(ans.split(',').map(x=>x.trim()).filter(Boolean));
|
||
}
|
||
if (e.key.toLowerCase()==='r' || e.key.toLowerCase()==='к'){
|
||
e.preventDefault();
|
||
refreshLogsAndCounters();
|
||
}
|
||
});
|
||
|
||
// Инициализация
|
||
(async function init() {
|
||
console.log('Initializing LogBoard+...');
|
||
console.log('Elements found:', {
|
||
projectSelect: !!els.projectSelect,
|
||
containerList: !!els.containerList,
|
||
logTitle: !!els.logTitle,
|
||
logContent: !!els.logContent,
|
||
mobileToggle: !!els.mobileToggle,
|
||
themeSwitch: !!els.themeSwitch
|
||
});
|
||
|
||
// Проверяем header project select
|
||
const headerSelect = document.getElementById('projectSelectHeader');
|
||
console.log('Header project select found during init:', !!headerSelect);
|
||
|
||
await fetchProjects();
|
||
await fetchServices();
|
||
|
||
// Инициализируем видимость счетчиков
|
||
updateCounterVisibility();
|
||
|
||
// Добавляем обработчики для счетчиков
|
||
addCounterClickHandlers();
|
||
|
||
// Добавляем обработчик для выпадающего списка проектов в заголовке
|
||
addHeaderProjectSelectHandler();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|