diff --git a/app.py b/app.py index 1722969..e5a08b4 100644 --- a/app.py +++ b/app.py @@ -17,6 +17,7 @@ DEFAULT_TAIL = int(os.getenv("LOGBOARD_TAIL", "500")) BASIC_USER = os.getenv("LOGBOARD_USER", "admin") BASIC_PASS = os.getenv("LOGBOARD_PASS", "admin") DEFAULT_PROJECT = os.getenv("COMPOSE_PROJECT_NAME") # filter by compose project +DEFAULT_PROJECTS = os.getenv("LOGBOARD_PROJECTS") # multiple projects filter SKIP_UNHEALTHY = os.getenv("LOGBOARD_SKIP_UNHEALTHY", "true").lower() == "true" CONTAINER_LIST_TIMEOUT = int(os.getenv("LOGBOARD_CONTAINER_LIST_TIMEOUT", "10")) CONTAINER_INFO_TIMEOUT = int(os.getenv("LOGBOARD_CONTAINER_INFO_TIMEOUT", "3")) @@ -51,9 +52,48 @@ def verify_ws_token(token: str) -> bool: return raw == f"{BASIC_USER}:{BASIC_PASS}" # ---------- DOCKER HELPERS ---------- -def list_containers(project: Optional[str] = None, include_stopped: bool = False) -> List[Dict]: +def get_all_projects() -> List[str]: """ - Получает список контейнеров с упрощенной логикой для предотвращения зависания + Получает список всех проектов Docker Compose + Автор: Сергей Антропов + Сайт: https://devops.org.ru + """ + projects = set() + + try: + containers = docker_client.containers.list(all=True) + + for c in containers: + try: + labels = c.labels or {} + project = labels.get("com.docker.compose.project") + if project: + projects.add(project) + except Exception: + continue + + # Добавляем контейнеры без проекта как "standalone" + standalone_count = 0 + for c in containers: + try: + labels = c.labels or {} + if not labels.get("com.docker.compose.project"): + standalone_count += 1 + except Exception: + continue + + if standalone_count > 0: + projects.add("standalone") + + except Exception as e: + print(f"❌ Ошибка получения списка проектов: {e}") + return [] + + return sorted(list(projects)) + +def list_containers(projects: Optional[List[str]] = None, include_stopped: bool = False) -> List[Dict]: + """ + Получает список контейнеров с поддержкой множественных проектов Автор: Сергей Антропов Сайт: https://devops.org.ru """ @@ -93,9 +133,12 @@ def list_containers(project: Optional[str] = None, include_stopped: bool = False except Exception: pass # Оставляем "unknown" - # Фильтрация по проекту - if project and basic_info["project"] != project: - continue + # Фильтрация по проектам + if projects: + # Если проект не указан, считаем его standalone + container_project = basic_info["project"] or "standalone" + if container_project not in projects: + continue # Добавляем контейнер в список items.append(basic_info) @@ -130,11 +173,27 @@ def index(creds: HTTPBasicCredentials = Depends(check_basic)): def healthz(): return "ok" +@app.get("/api/projects") +def api_projects(_: HTTPBasicCredentials = Depends(check_basic)): + """Получить список всех проектов Docker Compose""" + return JSONResponse(get_all_projects()) + @app.get("/api/services") -def api_services(project: Optional[str] = Query(None), include_stopped: bool = Query(False), +def api_services(projects: Optional[str] = Query(None), include_stopped: bool = Query(False), _: HTTPBasicCredentials = Depends(check_basic)): - proj = project or DEFAULT_PROJECT - return JSONResponse(list_containers(project=proj, include_stopped=include_stopped)) + """ + Получить список контейнеров с поддержкой множественных проектов + projects: список проектов через запятую (например: "project1,project2") + """ + project_list = None + if projects: + project_list = [p.strip() for p in projects.split(",") if p.strip()] + elif DEFAULT_PROJECTS: + project_list = [p.strip() for p in DEFAULT_PROJECTS.split(",") if p.strip()] + elif DEFAULT_PROJECT: + project_list = [DEFAULT_PROJECT] + + return JSONResponse(list_containers(projects=project_list, include_stopped=include_stopped)) @app.post("/api/snapshot") def api_snapshot( diff --git a/env.example b/env.example index c49af5c..a82e6ae 100644 --- a/env.example +++ b/env.example @@ -20,6 +20,10 @@ TZ_TS= # Фильтр по проекту Docker Compose (опционально) # COMPOSE_PROJECT_NAME=myproj +# Настройки множественных проектов +# Укажите проекты через запятую для отображения контейнеров из нескольких проектов +# LOGBOARD_PROJECTS=project1,project2,project3 + # Настройки Docker DOCKER_HOST=unix:///var/run/docker.sock DOCKER_TLS_VERIFY= diff --git a/templates/index.html b/templates/index.html index 7383cf8..2c97492 100644 --- a/templates/index.html +++ b/templates/index.html @@ -73,6 +73,11 @@ footer{position:fixed;right:10px;bottom:10px;opacity:.6;font-size:11px}

LogBoard+

project: all
+ @@ -132,6 +137,7 @@ const els = { filter: document.getElementById('filter'), wsstate: document.getElementById('wsstate'), projectBadge: document.getElementById('projectBadge'), + projectSelect: document.getElementById('projectSelect'), clearBtn: document.getElementById('clear'), refreshBtn: document.getElementById('refresh'), snapshotBtn: document.getElementById('snapshot'), @@ -144,9 +150,6 @@ const els = { themeSwitch: document.getElementById('themeSwitch'), copyFab: document.getElementById('copyFab'), groupBtn: document.getElementById('groupBtn'), - aggregate: document.getElementById('aggregate'), - themeSwitch: document.getElementById('themeSwitch'), - copyFab: document.getElementById('copyFab'), }; // ----- Theme toggle ----- @@ -298,14 +301,44 @@ function setLayout(cls){ els.grid.className = cls==='tabs' ? 'grid-1' : (cls==='grid2'?'grid-2':cls==='grid3'?'grid-3':'grid-4'); } +async function fetchProjects(){ + const url = new URL(location.origin + '/api/projects'); + const res = await fetch(url); + if (!res.ok){ console.error('Failed to fetch projects'); return; } + const projects = await res.json(); + + // Обновляем селектор проектов + const select = els.projectSelect; + select.innerHTML = ''; + projects.forEach(project => { + const option = document.createElement('option'); + option.value = project; + option.textContent = project; + select.appendChild(option); + }); + + // Устанавливаем сохраненный проект + if (localStorage.lb_project && projects.includes(localStorage.lb_project)) { + select.value = localStorage.lb_project; + } +} + async function fetchServices(){ const url = new URL(location.origin + '/api/services'); - if (localStorage.lb_project) url.searchParams.set('project', localStorage.lb_project); + const selectedProject = els.projectSelect.value; + + 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){ alert('Auth failed (HTTP)'); return; } const data = await res.json(); state.services = data; - const pj = localStorage.lb_project || 'all'; + const pj = selectedProject === 'all' ? 'all' : selectedProject; els.projectBadge.innerHTML = 'project: '+escapeHtml(pj)+''; buildTabs(); if (!state.current && state.services.length) switchToSingle(state.services[0]); @@ -551,6 +584,7 @@ els.groupBtn.onclick = ()=>{ // Controls els.clearBtn.onclick = ()=> Object.values(state.open).forEach(o=> o.logEl.textContent=''); els.refreshBtn.onclick = fetchServices; +els.projectSelect.onchange = fetchServices; els.snapshotBtn.onclick = ()=>{ if (state.current) sendSnapshot(state.current.id); }; els.tail.onchange = ()=> { Object.keys(state.open).forEach(id=>{ @@ -588,7 +622,11 @@ window.addEventListener('keydown', (e)=>{ } }); -fetchServices(); +// Инициализация +(async function init() { + await fetchProjects(); + await fetchServices(); +})(); diff --git a/test_ws.py b/test_ws.py new file mode 100644 index 0000000..3558f12 --- /dev/null +++ b/test_ws.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Простой тест WebSocket соединения +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" + +import asyncio +import websockets +import base64 + +async def test_websocket(): + """Тестирует WebSocket соединение""" + + # Параметры подключения + uri = "ws://localhost:9001/ws/logs/c90f6c8bfbb6?tail=10&token=YWRtaW46YWRtaW4%3D" + + print(f"🔍 Тестирование WebSocket соединения...") + print(f"URI: {uri}") + print("-" * 50) + + try: + async with websockets.connect(uri) as websocket: + print("✅ WebSocket соединение установлено") + + # Ждем сообщения + try: + async for message in websocket: + print(f"📨 Получено сообщение: {message[:200]}...") + break # Получаем только первое сообщение + except websockets.exceptions.ConnectionClosed: + print("❌ WebSocket соединение закрыто") + + except Exception as e: + print(f"❌ Ошибка WebSocket: {e}") + return False + + return True + +if __name__ == "__main__": + asyncio.run(test_websocket())