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:
parent
351b2ac041
commit
3fcaa8ad5d
103
app.py
103
app.py
@ -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
|
||||
|
@ -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 => ({'&':'&','<':'<
|
||||
|
||||
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 () => {
|
||||
}
|
||||
}
|
||||
};
|
||||
els.projectSelect.onchange = fetchServices;
|
||||
|
||||
// Обработчик для кнопки 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
|
||||
els.mobileToggle.onclick = () => {
|
||||
document.querySelector('.sidebar').classList.toggle('open');
|
||||
};
|
||||
if (els.mobileToggle) {
|
||||
els.mobileToggle.onclick = () => {
|
||||
const sidebar = document.querySelector('.sidebar');
|
||||
if (sidebar) {
|
||||
sidebar.classList.toggle('open');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Collapsible sections
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
@ -1642,14 +2030,17 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
|
||||
});
|
||||
els.snapshotBtn.onclick = ()=>{
|
||||
if (els.snapshotBtn) {
|
||||
els.snapshotBtn.onclick = ()=>{
|
||||
if (state.current) {
|
||||
sendSnapshot(state.current.id);
|
||||
} else {
|
||||
alert('No container selected');
|
||||
}
|
||||
};
|
||||
els.tail.onchange = ()=> {
|
||||
};
|
||||
}
|
||||
if (els.tail) {
|
||||
els.tail.onchange = ()=> {
|
||||
Object.keys(state.open).forEach(id=>{
|
||||
const svc = state.services.find(s=> s.id===id);
|
||||
if (!svc) return;
|
||||
@ -1663,16 +2054,20 @@ els.tail.onchange = ()=> {
|
||||
if (state.current && els.logContent) {
|
||||
els.logContent.textContent = 'Reconnecting...';
|
||||
}
|
||||
};
|
||||
els.wrapToggle.onchange = ()=> {
|
||||
};
|
||||
}
|
||||
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
|
||||
els.autoscroll.onchange = ()=> {
|
||||
if (els.autoscroll) {
|
||||
els.autoscroll.onchange = ()=> {
|
||||
// Обновляем настройку автопрокрутки для всех открытых логов
|
||||
Object.keys(state.open).forEach(id => {
|
||||
const obj = state.open[id];
|
||||
@ -1690,56 +2085,48 @@ els.autoscroll.onchange = ()=> {
|
||||
logContent.scrollTop = logContent.scrollHeight;
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
els.filter.oninput = ()=> {
|
||||
// Обработчик для фильтра (если элемент существует)
|
||||
if (els.filter) {
|
||||
els.filter.oninput = ()=> {
|
||||
state.filter = els.filter.value.trim();
|
||||
refreshAllLogs();
|
||||
};
|
||||
els.lvlDebug.onchange = ()=> {
|
||||
};
|
||||
}
|
||||
// Обработчики для LogLevels (если элементы существуют)
|
||||
if (els.lvlDebug) {
|
||||
els.lvlDebug.onchange = ()=> {
|
||||
state.levels.debug = els.lvlDebug.checked;
|
||||
updateCounterVisibility();
|
||||
refreshAllLogs();
|
||||
};
|
||||
els.lvlInfo.onchange = ()=> {
|
||||
};
|
||||
}
|
||||
if (els.lvlInfo) {
|
||||
els.lvlInfo.onchange = ()=> {
|
||||
state.levels.info = els.lvlInfo.checked;
|
||||
updateCounterVisibility();
|
||||
refreshAllLogs();
|
||||
};
|
||||
els.lvlWarn.onchange = ()=> {
|
||||
};
|
||||
}
|
||||
if (els.lvlWarn) {
|
||||
els.lvlWarn.onchange = ()=> {
|
||||
state.levels.warn = els.lvlWarn.checked;
|
||||
updateCounterVisibility();
|
||||
refreshAllLogs();
|
||||
};
|
||||
els.lvlErr.onchange = ()=> {
|
||||
};
|
||||
}
|
||||
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>
|
||||
|
Loading…
x
Reference in New Issue
Block a user