UI improvements: removed pause from Options, updated Tail Lines gradation, renamed Snapshot to Download logs and made it full-width

This commit is contained in:
Сергей Антропов 2025-08-16 15:59:00 +03:00
parent 351b2ac041
commit 3fcaa8ad5d
2 changed files with 695 additions and 198 deletions

103
app.py
View File

@ -97,6 +97,20 @@ def list_containers(projects: Optional[List[str]] = None, include_stopped: bool
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
# Список контейнеров, которые генерируют слишком много логов
excluded_containers = [
"buildx_buildkit_multiarch-builder0",
"buildx_buildkit_multiarch-builder1",
"buildx_buildkit_multiarch-builder2",
"buildx_buildkit_multiarch-builder3",
"buildx_buildkit_multiarch-builder4",
"buildx_buildkit_multiarch-builder5",
"buildx_buildkit_multiarch-builder6",
"buildx_buildkit_multiarch-builder7",
"buildx_buildkit_multiarch-builder8",
"buildx_buildkit_multiarch-builder9"
]
items = []
try:
@ -140,6 +154,11 @@ def list_containers(projects: Optional[List[str]] = None, include_stopped: bool
if container_project not in projects:
continue
# Фильтрация исключенных контейнеров
if basic_info["name"] in excluded_containers:
print(f"⚠️ Пропускаем исключенный контейнер: {basic_info['name']}")
continue
# Добавляем контейнер в список
items.append(basic_info)
@ -167,16 +186,77 @@ def load_index_html(token: str) -> str:
@app.get("/", response_class=HTMLResponse)
def index(creds: HTTPBasicCredentials = Depends(check_basic)):
token = token_from_creds(creds)
return HTMLResponse(load_index_html(token))
return HTMLResponse(
content=load_index_html(token),
headers={
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
"Expires": "0"
}
)
@app.get("/healthz", response_class=PlainTextResponse)
def healthz():
return "ok"
@app.get("/api/logs/stats/{container_id}")
def api_logs_stats(container_id: str, _: HTTPBasicCredentials = Depends(check_basic)):
"""Получить статистику логов контейнера"""
try:
# Ищем контейнер
container = None
for c in docker_client.containers.list(all=True):
if c.id.startswith(container_id):
container = c
break
if container is None:
return JSONResponse({"error": "Container not found"}, status_code=404)
# Получаем логи
logs = container.logs(tail=1000).decode(errors="ignore")
# Подсчитываем статистику
stats = {"debug": 0, "info": 0, "warn": 0, "error": 0}
for line in logs.split('\n'):
if not line.strip():
continue
line_lower = line.lower()
if 'level=debug' in line_lower or 'debug' in line_lower:
stats["debug"] += 1
elif 'level=info' in line_lower or 'info' in line_lower:
stats["info"] += 1
elif 'level=warning' in line_lower or 'level=warn' in line_lower or 'warning' in line_lower or 'warn' in line_lower:
stats["warn"] += 1
elif 'level=error' in line_lower or 'error' in line_lower:
stats["error"] += 1
return JSONResponse(
content=stats,
headers={
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
"Expires": "0"
}
)
except Exception as e:
print(f"Error getting log stats for {container_id}: {e}")
return JSONResponse({"error": str(e)}, status_code=500)
@app.get("/api/projects")
def api_projects(_: HTTPBasicCredentials = Depends(check_basic)):
"""Получить список всех проектов Docker Compose"""
return JSONResponse(get_all_projects())
return JSONResponse(
content=get_all_projects(),
headers={
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
"Expires": "0"
}
)
@app.get("/api/services")
def api_services(projects: Optional[str] = Query(None), include_stopped: bool = Query(False),
@ -193,7 +273,14 @@ def api_services(projects: Optional[str] = Query(None), include_stopped: bool =
elif DEFAULT_PROJECT:
project_list = [DEFAULT_PROJECT]
return JSONResponse(list_containers(projects=project_list, include_stopped=include_stopped))
return JSONResponse(
content=list_containers(projects=project_list, include_stopped=include_stopped),
headers={
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
"Expires": "0"
}
)
@app.post("/api/snapshot")
def api_snapshot(
@ -252,24 +339,30 @@ async def ws_logs(ws: WebSocket, container_id: str, tail: int = DEFAULT_TAIL, to
# Получаем логи (только последние строки, без follow)
try:
print(f"Getting logs for container {container.name} (ID: {container.id[:12]})")
logs = container.logs(tail=tail).decode(errors="ignore")
if logs:
await ws.send_text(logs)
else:
await ws.send_text("No logs available")
except Exception as e:
print(f"Error getting logs for {container.name}: {e}")
await ws.send_text(f"ERROR getting logs: {e}")
# Простое WebSocket соединение - только отправляем логи один раз
print(f"WebSocket connection established for {container.name}")
except WebSocketDisconnect:
print("WebSocket client disconnected")
print(f"WebSocket client disconnected for container {container.name}")
except Exception as e:
print(f"WebSocket error: {e}")
print(f"WebSocket error for {container.name}: {e}")
try:
await ws.send_text(f"ERROR: {e}")
except:
pass
finally:
try:
print(f"Closing WebSocket connection for container {container.name}")
await ws.close()
except:
pass

View File

@ -50,7 +50,7 @@ a{color:var(--link)}
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
margin-bottom: 0px;
}
.header-buttons {
@ -128,21 +128,21 @@ a{color:var(--link)}
}
.ws-status-btn.ws-on {
background: var(--ok);
color: #0b0d12;
border-color: var(--ok);
background: #7ea855; /* Темнее на 20% */
color: white;
border-color: #7ea855;
}
.ws-status-btn.ws-off {
background: var(--err);
color: #0b0d12;
border-color: var(--err);
background: #f7768e;
color: white;
border-color: #f7768e;
}
.ws-status-btn.ws-err {
background: var(--warn);
color: #0b0d12;
border-color: var(--warn);
background: #e0af68;
color: white;
border-color: #e0af68;
}
/* Sidebar Controls */
@ -359,6 +359,122 @@ a{color:var(--link)}
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;
@ -372,6 +488,10 @@ a{color:var(--link)}
width: 100%;
}
.btn-group.actions-grid .btn-full-width {
grid-column: 1 / -1;
}
/* Main Content */
.main-content {
flex: 1;
@ -399,12 +519,35 @@ a{color:var(--link)}
}
.header-badge {
background: var(--chip);
color: var(--muted);
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
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 {
@ -414,6 +557,51 @@ a{color:var(--link)}
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;
@ -542,7 +730,8 @@ a{color:var(--link)}
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 12px;
justify-content: space-between;
gap: 16px;
}
.log-title {
@ -670,48 +859,9 @@ footer{position:fixed;right:10px;bottom:10px;opacity:.6;font-size:11px}
</div>
<div class="sidebar-controls">
<div class="control-group collapsible" data-section="project">
<div class="control-header">
<label>Project</label>
<button class="collapse-btn" data-target="project">
<i class="fas fa-chevron-down"></i>
</button>
</div>
<div class="control-content" id="project-content">
<select id="projectSelect">
<option value="all">All Projects</option>
</select>
</div>
</div>
<div class="control-group collapsible" data-section="levels">
<div class="control-header">
<label>Log Levels</label>
<button class="collapse-btn" data-target="levels">
<i class="fas fa-chevron-down"></i>
</button>
</div>
<div class="control-content" id="levels-content">
<div class="checkbox-group levels-grid">
<div class="checkbox-item">
<input type="checkbox" id="lvlDebug" checked>
<label for="lvlDebug">DEBUG</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="lvlInfo" checked>
<label for="lvlInfo">INFO</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="lvlWarn" checked>
<label for="lvlWarn">WARN</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="lvlErr" checked>
<label for="lvlErr">ERROR</label>
</div>
</div>
</div>
</div>
<div class="control-group collapsible" data-section="tail">
<div class="control-header">
@ -722,10 +872,12 @@ footer{position:fixed;right:10px;bottom:10px;opacity:.6;font-size:11px}
</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>
<option value="0">All</option>
</select>
</div>
</div>
@ -747,25 +899,11 @@ footer{position:fixed;right:10px;bottom:10px;opacity:.6;font-size:11px}
<input type="checkbox" id="wrap" checked>
<label for="wrap">Wrap text</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="pause">
<label for="pause">Pause</label>
</div>
</div>
</div>
</div>
<div class="control-group collapsible" data-section="filter">
<div class="control-header">
<label>Filter</label>
<button class="collapse-btn" data-target="filter">
<i class="fas fa-chevron-down"></i>
</button>
</div>
<div class="control-content" id="filter-content">
<input id="filter" type="text" placeholder="Filter logs (regex)…"/>
</div>
</div>
<div class="control-group collapsible" data-section="actions">
<div class="control-header">
@ -778,7 +916,7 @@ footer{position:fixed;right:10px;bottom:10px;opacity:.6;font-size:11px}
<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"><i class="fas fa-download"></i> Snapshot</button>
<button id="snapshot" class="btn btn-full-width"><i class="fas fa-download"></i> Download logs</button>
</div>
</div>
</div>
@ -803,7 +941,12 @@ footer{position:fixed;right:10px;bottom:10px;opacity:.6;font-size:11px}
<i class="fas fa-bars"></i>
</button>
<h2 class="header-title">Logs</h2>
<span class="header-badge" id="projectBadge">All Projects</span>
<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>
@ -818,10 +961,25 @@ footer{position:fixed;right:10px;bottom:10px;opacity:.6;font-size:11px}
<div class="log-header">
<h3 class="log-title" id="logTitle">Select a container to view logs</h3>
<div class="log-controls">
<span class="counter">DEBUG: <span class="cdbg">0</span></span>
<span class="counter">INFO: <span class="cinfo">0</span></span>
<span class="counter">WARN: <span class="cwarn">0</span></span>
<span class="counter">ERROR: <span class="cerr">0</span></span>
<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">
@ -856,7 +1014,7 @@ const els = {
tail: document.getElementById('tail'),
autoscroll: document.getElementById('autoscroll'),
wrapToggle: document.getElementById('wrap'),
pause: document.getElementById('pause'),
filter: document.getElementById('filter'),
wsstate: document.getElementById('wsstate'),
projectBadge: document.getElementById('projectBadge'),
@ -881,6 +1039,7 @@ const els = {
mobileToggle: document.getElementById('mobileToggle'),
optionsBtn: document.getElementById('optionsBtn'),
logoutBtn: document.getElementById('logoutBtn'),
logRefreshBtn: document.getElementById('logRefreshBtn'),
};
// ----- Theme toggle -----
@ -944,11 +1103,26 @@ function escapeHtml(s){ return s.replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt
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';
return 'other'; // Изменено с пустой строки на 'other'
// Дополнительные проверки для других форматов
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;
@ -960,7 +1134,14 @@ function allowedByLevel(cls){
}
function applyFilter(line){
if(!state.filter) return true;
try{ return new RegExp(state.filter, 'i').test(line); }catch(e){ 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)
@ -1124,7 +1305,7 @@ async function fetchProjects(){
const projects = await res.json();
console.log('Projects loaded:', projects);
// Обновляем селектор проектов
// Обновляем селектор проектов в сайдбаре
const select = els.projectSelect;
if (select) {
select.innerHTML = '<option value="all">All Projects</option>';
@ -1140,6 +1321,28 @@ async function fetchProjects(){
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);
}
@ -1169,12 +1372,17 @@ async function fetchServices(){
state.services = data;
const pj = selectedProject === 'all' ? 'all' : selectedProject;
if (els.projectBadge) {
els.projectBadge.innerHTML = 'project: <em>'+escapeHtml(pj)+'</em>';
// Обновляем селектор в заголовке
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);
}
@ -1196,6 +1404,7 @@ function wsUrl(containerId, service, project){
function closeWs(id){
const o = state.open[id];
if (!o) return;
try { o.ws.close(); } catch(e){}
delete state.open[id];
}
@ -1235,10 +1444,12 @@ function openWs(svc, panel){
const id = svc.id;
const logEl = panel.querySelector('.log');
const wrapEl = panel.querySelector('.logwrap');
const cdbg = panel.querySelector('.cdbg');
const cinfo = panel.querySelector('.cinfo');
const cwarn = panel.querySelector('.cwarn');
const cerr = panel.querySelector('.cerr');
// Ищем счетчики в 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||''));
@ -1248,7 +1459,10 @@ function openWs(svc, panel){
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
@ -1256,12 +1470,16 @@ function openWs(svc, panel){
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++;
@ -1269,6 +1487,16 @@ function openWs(svc, panel){
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;
@ -1286,13 +1514,7 @@ function openWs(svc, panel){
if (!allowedByLevel(cls)) return;
if (!applyFilter(line)) return;
if (els.pause.checked){
if (!obj.pausedBuffer) obj.pausedBuffer = [];
obj.pausedBuffer.push({html: html, line: line, cls: cls});
if (obj.pausedBuffer.length>5000) obj.pausedBuffer.shift();
return;
}
// Добавляем логи в отображение
obj.logEl.insertAdjacentHTML('beforeend', html);
if (els.autoscroll.checked && obj.wrapEl) obj.wrapEl.scrollTop = obj.wrapEl.scrollHeight;
@ -1356,6 +1578,9 @@ function switchToSingle(svc){
if (activeItem) {
activeItem.classList.add('active');
}
// Добавляем обработчики для счетчиков после переключения контейнера
addCounterClickHandlers();
}
function openMulti(ids){
@ -1370,6 +1595,9 @@ function openMulti(ids){
closeWs(svc.id);
openWs(svc, panel);
}
// Добавляем обработчики для счетчиков после открытия мульти-контейнеров
addCounterClickHandlers();
}
// ----- Copy on selection -----
@ -1438,10 +1666,10 @@ function openFanGroup(services){
const id = fake.id;
const logEl = panel.querySelector('.log');
const wrapEl = panel.querySelector('.logwrap');
const cdbg = panel.querySelector('.cdbg');
const cinfo = panel.querySelector('.cinfo');
const cwarn = panel.querySelector('.cwarn');
const cerr = panel.querySelector('.cerr');
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||''));
@ -1462,6 +1690,12 @@ function openFanGroup(services){
cinfo.textContent = counters.info;
cwarn.textContent = counters.warn;
cerr.textContent = counters.err;
// Обновляем видимость счетчиков
updateCounterVisibility();
// Добавляем обработчики для счетчиков
addCounterClickHandlers();
};
// Show filter bar and clear previous filters
@ -1480,6 +1714,78 @@ if (els.groupBtn && els.groupBtn.onclick !== null) {
};
}
// Функция для обновления счетчиков через 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 => {
@ -1521,12 +1827,94 @@ els.refreshBtn.onclick = async () => {
}
}
};
// Обработчик для кнопки 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 = () => {
document.querySelector('.sidebar').classList.toggle('open');
const sidebar = document.querySelector('.sidebar');
if (sidebar) {
sidebar.classList.toggle('open');
}
};
}
// Collapsible sections
document.addEventListener('DOMContentLoaded', () => {
@ -1642,6 +2030,7 @@ document.addEventListener('DOMContentLoaded', () => {
});
if (els.snapshotBtn) {
els.snapshotBtn.onclick = ()=>{
if (state.current) {
sendSnapshot(state.current.id);
@ -1649,6 +2038,8 @@ els.snapshotBtn.onclick = ()=>{
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);
@ -1664,14 +2055,18 @@ els.tail.onchange = ()=> {
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 => {
@ -1691,55 +2086,47 @@ els.autoscroll.onchange = ()=> {
}
}
};
}
els.pause.onchange = ()=> {
// При снятии паузы показываем накопленные логи с учетом фильтров
if (!els.pause.checked) {
Object.keys(state.open).forEach(id => {
const obj = state.open[id];
if (obj && obj.pausedBuffer && obj.pausedBuffer.length > 0) {
obj.pausedBuffer.forEach(logEntry => {
// Проверяем фильтры для каждого логированного сообщения
if (allowedByLevel(logEntry.cls) && applyFilter(logEntry.line)) {
obj.logEl.insertAdjacentHTML('beforeend', logEntry.html);
}
});
obj.pausedBuffer = [];
// Обновляем современный интерфейс
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;
}
}
}
});
}
};
// Обработчик для фильтра (если элемент существует)
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
// Hotkeys: [ ] — tabs, M — multi, R — refresh
window.addEventListener('keydown', (e)=>{
if (e.key==='[' || (e.ctrlKey && e.key==='ArrowLeft')){
e.preventDefault();
@ -1756,6 +2143,10 @@ window.addEventListener('keydown', (e)=>{
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();
}
});
// Инициализация
@ -1770,8 +2161,21 @@ window.addEventListener('keydown', (e)=>{
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>