Files
LogBoard/templates/index.html
T

2163 lines
63 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'),
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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 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 projectSelectHeader = document.getElementById('projectSelectHeader');
const selectedProject = projectSelectHeader ? projectSelectHeader.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();
}
};
}
}
// Функция для добавления обработчика выпадающего списка проектов в заголовке
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);
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:', {
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>