feat: добавить поддержку множественных проектов Docker Compose
- Добавлен API эндпоинт /api/projects для получения списка проектов - Обновлен API /api/services для поддержки фильтрации по множественным проектам - Добавлен селектор проектов в веб-интерфейс - Добавлена переменная окружения LOGBOARD_PROJECTS - Обновлен HTML шаблон с JavaScript функциональностью - Добавлена функция fetchProjects() для загрузки списка проектов - Обновлена функция fetchServices() для работы с выбранными проектами Автор: Сергей Антропов Сайт: https://devops.org.ru
This commit is contained in:
parent
05a7a45b45
commit
04d1ae9a96
73
app.py
73
app.py
@ -17,6 +17,7 @@ DEFAULT_TAIL = int(os.getenv("LOGBOARD_TAIL", "500"))
|
|||||||
BASIC_USER = os.getenv("LOGBOARD_USER", "admin")
|
BASIC_USER = os.getenv("LOGBOARD_USER", "admin")
|
||||||
BASIC_PASS = os.getenv("LOGBOARD_PASS", "admin")
|
BASIC_PASS = os.getenv("LOGBOARD_PASS", "admin")
|
||||||
DEFAULT_PROJECT = os.getenv("COMPOSE_PROJECT_NAME") # filter by compose project
|
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"
|
SKIP_UNHEALTHY = os.getenv("LOGBOARD_SKIP_UNHEALTHY", "true").lower() == "true"
|
||||||
CONTAINER_LIST_TIMEOUT = int(os.getenv("LOGBOARD_CONTAINER_LIST_TIMEOUT", "10"))
|
CONTAINER_LIST_TIMEOUT = int(os.getenv("LOGBOARD_CONTAINER_LIST_TIMEOUT", "10"))
|
||||||
CONTAINER_INFO_TIMEOUT = int(os.getenv("LOGBOARD_CONTAINER_INFO_TIMEOUT", "3"))
|
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}"
|
return raw == f"{BASIC_USER}:{BASIC_PASS}"
|
||||||
|
|
||||||
# ---------- DOCKER HELPERS ----------
|
# ---------- 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
|
Сайт: https://devops.org.ru
|
||||||
"""
|
"""
|
||||||
@ -93,8 +133,11 @@ def list_containers(project: Optional[str] = None, include_stopped: bool = False
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass # Оставляем "unknown"
|
pass # Оставляем "unknown"
|
||||||
|
|
||||||
# Фильтрация по проекту
|
# Фильтрация по проектам
|
||||||
if project and basic_info["project"] != project:
|
if projects:
|
||||||
|
# Если проект не указан, считаем его standalone
|
||||||
|
container_project = basic_info["project"] or "standalone"
|
||||||
|
if container_project not in projects:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Добавляем контейнер в список
|
# Добавляем контейнер в список
|
||||||
@ -130,11 +173,27 @@ def index(creds: HTTPBasicCredentials = Depends(check_basic)):
|
|||||||
def healthz():
|
def healthz():
|
||||||
return "ok"
|
return "ok"
|
||||||
|
|
||||||
|
@app.get("/api/projects")
|
||||||
|
def api_projects(_: HTTPBasicCredentials = Depends(check_basic)):
|
||||||
|
"""Получить список всех проектов Docker Compose"""
|
||||||
|
return JSONResponse(get_all_projects())
|
||||||
|
|
||||||
@app.get("/api/services")
|
@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)):
|
_: 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")
|
@app.post("/api/snapshot")
|
||||||
def api_snapshot(
|
def api_snapshot(
|
||||||
|
@ -20,6 +20,10 @@ TZ_TS=
|
|||||||
# Фильтр по проекту Docker Compose (опционально)
|
# Фильтр по проекту Docker Compose (опционально)
|
||||||
# COMPOSE_PROJECT_NAME=myproj
|
# COMPOSE_PROJECT_NAME=myproj
|
||||||
|
|
||||||
|
# Настройки множественных проектов
|
||||||
|
# Укажите проекты через запятую для отображения контейнеров из нескольких проектов
|
||||||
|
# LOGBOARD_PROJECTS=project1,project2,project3
|
||||||
|
|
||||||
# Настройки Docker
|
# Настройки Docker
|
||||||
DOCKER_HOST=unix:///var/run/docker.sock
|
DOCKER_HOST=unix:///var/run/docker.sock
|
||||||
DOCKER_TLS_VERIFY=
|
DOCKER_TLS_VERIFY=
|
||||||
|
@ -73,6 +73,11 @@ footer{position:fixed;right:10px;bottom:10px;opacity:.6;font-size:11px}
|
|||||||
<h1>LogBoard+</h1>
|
<h1>LogBoard+</h1>
|
||||||
<span id="projectBadge" class="badge">project: <em>all</em></span>
|
<span id="projectBadge" class="badge">project: <em>all</em></span>
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
|
<label>Projects:
|
||||||
|
<select id="projectSelect">
|
||||||
|
<option value="all">All Projects</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<span class="filterlvl">
|
<span class="filterlvl">
|
||||||
<label><input type="checkbox" id="lvlDebug" checked>DEBUG</label>
|
<label><input type="checkbox" id="lvlDebug" checked>DEBUG</label>
|
||||||
<label><input type="checkbox" id="lvlInfo" checked>INFO</label>
|
<label><input type="checkbox" id="lvlInfo" checked>INFO</label>
|
||||||
@ -132,6 +137,7 @@ const els = {
|
|||||||
filter: document.getElementById('filter'),
|
filter: document.getElementById('filter'),
|
||||||
wsstate: document.getElementById('wsstate'),
|
wsstate: document.getElementById('wsstate'),
|
||||||
projectBadge: document.getElementById('projectBadge'),
|
projectBadge: document.getElementById('projectBadge'),
|
||||||
|
projectSelect: document.getElementById('projectSelect'),
|
||||||
clearBtn: document.getElementById('clear'),
|
clearBtn: document.getElementById('clear'),
|
||||||
refreshBtn: document.getElementById('refresh'),
|
refreshBtn: document.getElementById('refresh'),
|
||||||
snapshotBtn: document.getElementById('snapshot'),
|
snapshotBtn: document.getElementById('snapshot'),
|
||||||
@ -144,9 +150,6 @@ const els = {
|
|||||||
themeSwitch: document.getElementById('themeSwitch'),
|
themeSwitch: document.getElementById('themeSwitch'),
|
||||||
copyFab: document.getElementById('copyFab'),
|
copyFab: document.getElementById('copyFab'),
|
||||||
groupBtn: document.getElementById('groupBtn'),
|
groupBtn: document.getElementById('groupBtn'),
|
||||||
aggregate: document.getElementById('aggregate'),
|
|
||||||
themeSwitch: document.getElementById('themeSwitch'),
|
|
||||||
copyFab: document.getElementById('copyFab'),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ----- Theme toggle -----
|
// ----- 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');
|
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 = '<option value="all">All Projects</option>';
|
||||||
|
projects.forEach(project => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = project;
|
||||||
|
option.textContent = project;
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Устанавливаем сохраненный проект
|
||||||
|
if (localStorage.lb_project && projects.includes(localStorage.lb_project)) {
|
||||||
|
select.value = localStorage.lb_project;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchServices(){
|
async function fetchServices(){
|
||||||
const url = new URL(location.origin + '/api/services');
|
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);
|
const res = await fetch(url);
|
||||||
if (!res.ok){ alert('Auth failed (HTTP)'); return; }
|
if (!res.ok){ alert('Auth failed (HTTP)'); return; }
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
state.services = data;
|
state.services = data;
|
||||||
const pj = localStorage.lb_project || 'all';
|
const pj = selectedProject === 'all' ? 'all' : selectedProject;
|
||||||
els.projectBadge.innerHTML = 'project: <em>'+escapeHtml(pj)+'</em>';
|
els.projectBadge.innerHTML = 'project: <em>'+escapeHtml(pj)+'</em>';
|
||||||
buildTabs();
|
buildTabs();
|
||||||
if (!state.current && state.services.length) switchToSingle(state.services[0]);
|
if (!state.current && state.services.length) switchToSingle(state.services[0]);
|
||||||
@ -551,6 +584,7 @@ els.groupBtn.onclick = ()=>{
|
|||||||
// Controls
|
// Controls
|
||||||
els.clearBtn.onclick = ()=> Object.values(state.open).forEach(o=> o.logEl.textContent='');
|
els.clearBtn.onclick = ()=> Object.values(state.open).forEach(o=> o.logEl.textContent='');
|
||||||
els.refreshBtn.onclick = fetchServices;
|
els.refreshBtn.onclick = fetchServices;
|
||||||
|
els.projectSelect.onchange = fetchServices;
|
||||||
els.snapshotBtn.onclick = ()=>{ if (state.current) sendSnapshot(state.current.id); };
|
els.snapshotBtn.onclick = ()=>{ if (state.current) sendSnapshot(state.current.id); };
|
||||||
els.tail.onchange = ()=> {
|
els.tail.onchange = ()=> {
|
||||||
Object.keys(state.open).forEach(id=>{
|
Object.keys(state.open).forEach(id=>{
|
||||||
@ -588,7 +622,11 @@ window.addEventListener('keydown', (e)=>{
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
fetchServices();
|
// Инициализация
|
||||||
|
(async function init() {
|
||||||
|
await fetchProjects();
|
||||||
|
await fetchServices();
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
42
test_ws.py
Normal file
42
test_ws.py
Normal file
@ -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())
|
Loading…
x
Reference in New Issue
Block a user