Move excluded containers to separate sidebar section and improve UI
This commit is contained in:
parent
f3e1966f3e
commit
293e9c8cba
184
app.py
184
app.py
@ -2,6 +2,7 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from typing import Optional, List, Dict
|
from typing import Optional, List, Dict
|
||||||
@ -52,44 +53,95 @@ 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 load_excluded_containers() -> List[str]:
|
||||||
|
"""
|
||||||
|
Загружает список исключенных контейнеров из JSON файла
|
||||||
|
Автор: Сергей Антропов
|
||||||
|
Сайт: https://devops.org.ru
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open("excluded_containers.json", "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return data.get("excluded_containers", [])
|
||||||
|
except FileNotFoundError:
|
||||||
|
print("⚠️ Файл excluded_containers.json не найден, используем пустой список")
|
||||||
|
return []
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
print(f"❌ Ошибка парсинга excluded_containers.json: {e}")
|
||||||
|
return []
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Ошибка загрузки excluded_containers.json: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def save_excluded_containers(containers: List[str]) -> bool:
|
||||||
|
"""
|
||||||
|
Сохраняет список исключенных контейнеров в JSON файл
|
||||||
|
Автор: Сергей Антропов
|
||||||
|
Сайт: https://devops.org.ru
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = {
|
||||||
|
"excluded_containers": containers,
|
||||||
|
"description": "Список контейнеров, которые генерируют слишком много логов и исключаются из отображения"
|
||||||
|
}
|
||||||
|
with open("excluded_containers.json", "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Ошибка сохранения excluded_containers.json: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
def get_all_projects() -> List[str]:
|
def get_all_projects() -> List[str]:
|
||||||
"""
|
"""
|
||||||
Получает список всех проектов Docker Compose
|
Получает список всех проектов Docker Compose с учетом исключенных контейнеров
|
||||||
Автор: Сергей Антропов
|
Автор: Сергей Антропов
|
||||||
Сайт: https://devops.org.ru
|
Сайт: https://devops.org.ru
|
||||||
"""
|
"""
|
||||||
projects = set()
|
projects = set()
|
||||||
|
excluded_containers = load_excluded_containers()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
containers = docker_client.containers.list(all=True)
|
containers = docker_client.containers.list(all=True)
|
||||||
|
|
||||||
|
# Словарь для подсчета контейнеров по проектам
|
||||||
|
project_containers = {}
|
||||||
|
standalone_containers = []
|
||||||
|
|
||||||
for c in containers:
|
for c in containers:
|
||||||
try:
|
try:
|
||||||
|
# Пропускаем исключенные контейнеры
|
||||||
|
if c.name in excluded_containers:
|
||||||
|
continue
|
||||||
|
|
||||||
labels = c.labels or {}
|
labels = c.labels or {}
|
||||||
project = labels.get("com.docker.compose.project")
|
project = labels.get("com.docker.compose.project")
|
||||||
|
|
||||||
if project:
|
if project:
|
||||||
projects.add(project)
|
if project not in project_containers:
|
||||||
|
project_containers[project] = 0
|
||||||
|
project_containers[project] += 1
|
||||||
|
else:
|
||||||
|
standalone_containers.append(c.name)
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Добавляем контейнеры без проекта как "standalone"
|
# Добавляем проекты, у которых есть хотя бы один неисключенный контейнер
|
||||||
standalone_count = 0
|
for project, count in project_containers.items():
|
||||||
for c in containers:
|
if count > 0:
|
||||||
try:
|
projects.add(project)
|
||||||
labels = c.labels or {}
|
|
||||||
if not labels.get("com.docker.compose.project"):
|
|
||||||
standalone_count += 1
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if standalone_count > 0:
|
# Добавляем standalone, если есть неисключенные контейнеры без проекта
|
||||||
|
if standalone_containers:
|
||||||
projects.add("standalone")
|
projects.add("standalone")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Ошибка получения списка проектов: {e}")
|
print(f"❌ Ошибка получения списка проектов: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
return sorted(list(projects))
|
result = sorted(list(projects))
|
||||||
|
print(f"📋 Доступные проекты (с учетом исключенных контейнеров): {result}")
|
||||||
|
return result
|
||||||
|
|
||||||
def list_containers(projects: Optional[List[str]] = None, include_stopped: bool = False) -> List[Dict]:
|
def list_containers(projects: Optional[List[str]] = None, include_stopped: bool = False) -> List[Dict]:
|
||||||
"""
|
"""
|
||||||
@ -97,21 +149,13 @@ def list_containers(projects: Optional[List[str]] = None, include_stopped: bool
|
|||||||
Автор: Сергей Антропов
|
Автор: Сергей Антропов
|
||||||
Сайт: https://devops.org.ru
|
Сайт: https://devops.org.ru
|
||||||
"""
|
"""
|
||||||
# Список контейнеров, которые генерируют слишком много логов
|
# Загружаем список исключенных контейнеров из JSON файла
|
||||||
excluded_containers = [
|
excluded_containers = load_excluded_containers()
|
||||||
"buildx_buildkit_multiarch-builder0",
|
|
||||||
"buildx_buildkit_multiarch-builder1",
|
print(f"🚫 Список исключенных контейнеров: {excluded_containers}")
|
||||||
"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 = []
|
items = []
|
||||||
|
excluded_count = 0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Получаем список контейнеров с базовой обработкой ошибок
|
# Получаем список контейнеров с базовой обработкой ошибок
|
||||||
@ -128,6 +172,8 @@ def list_containers(projects: Optional[List[str]] = None, include_stopped: bool
|
|||||||
"service": c.name,
|
"service": c.name,
|
||||||
"project": None,
|
"project": None,
|
||||||
"health": None,
|
"health": None,
|
||||||
|
"ports": [],
|
||||||
|
"url": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Безопасно получаем метки
|
# Безопасно получаем метки
|
||||||
@ -147,6 +193,31 @@ def list_containers(projects: Optional[List[str]] = None, include_stopped: bool
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass # Оставляем "unknown"
|
pass # Оставляем "unknown"
|
||||||
|
|
||||||
|
# Безопасно получаем информацию о портах
|
||||||
|
try:
|
||||||
|
ports = c.ports or {}
|
||||||
|
if ports:
|
||||||
|
basic_info["ports"] = list(ports.keys())
|
||||||
|
|
||||||
|
# Пытаемся найти HTTP/HTTPS порт для создания URL
|
||||||
|
for port_mapping in ports.values():
|
||||||
|
if port_mapping:
|
||||||
|
for mapping in port_mapping:
|
||||||
|
if isinstance(mapping, dict) and mapping.get("HostPort"):
|
||||||
|
host_port = mapping["HostPort"]
|
||||||
|
# Проверяем, что это HTTP порт (80, 443, 8080, 3000, etc.)
|
||||||
|
host_port_int = int(host_port)
|
||||||
|
if (host_port_int in [80, 443] or
|
||||||
|
(3000 <= host_port_int <= 4000) or
|
||||||
|
(8000 <= host_port_int <= 9000)):
|
||||||
|
protocol = "https" if host_port == "443" else "http"
|
||||||
|
basic_info["url"] = f"{protocol}://localhost:{host_port}"
|
||||||
|
break
|
||||||
|
if basic_info["url"]:
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass # Оставляем пустые значения
|
||||||
|
|
||||||
# Фильтрация по проектам
|
# Фильтрация по проектам
|
||||||
if projects:
|
if projects:
|
||||||
# Если проект не указан, считаем его standalone
|
# Если проект не указан, считаем его standalone
|
||||||
@ -156,6 +227,7 @@ def list_containers(projects: Optional[List[str]] = None, include_stopped: bool
|
|||||||
|
|
||||||
# Фильтрация исключенных контейнеров
|
# Фильтрация исключенных контейнеров
|
||||||
if basic_info["name"] in excluded_containers:
|
if basic_info["name"] in excluded_containers:
|
||||||
|
excluded_count += 1
|
||||||
print(f"⚠️ Пропускаем исключенный контейнер: {basic_info['name']}")
|
print(f"⚠️ Пропускаем исключенный контейнер: {basic_info['name']}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@ -173,6 +245,31 @@ def list_containers(projects: Optional[List[str]] = None, include_stopped: bool
|
|||||||
|
|
||||||
# Сортируем по проекту, сервису и имени
|
# Сортируем по проекту, сервису и имени
|
||||||
items.sort(key=lambda x: (x.get("project") or "", x.get("service") or "", x.get("name") or ""))
|
items.sort(key=lambda x: (x.get("project") or "", x.get("service") or "", x.get("name") or ""))
|
||||||
|
|
||||||
|
# Подсчитываем статистику по проектам
|
||||||
|
project_stats = {}
|
||||||
|
for item in items:
|
||||||
|
project = item.get("project") or "standalone"
|
||||||
|
if project not in project_stats:
|
||||||
|
project_stats[project] = {"visible": 0, "excluded": 0}
|
||||||
|
project_stats[project]["visible"] += 1
|
||||||
|
|
||||||
|
# Подсчитываем исключенные контейнеры по проектам
|
||||||
|
for c in containers:
|
||||||
|
try:
|
||||||
|
if c.name in excluded_containers:
|
||||||
|
labels = c.labels or {}
|
||||||
|
project = labels.get("com.docker.compose.project") or "standalone"
|
||||||
|
if project not in project_stats:
|
||||||
|
project_stats[project] = {"visible": 0, "excluded": 0}
|
||||||
|
project_stats[project]["excluded"] += 1
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f"📊 Статистика: найдено {len(items)} контейнеров, исключено {excluded_count} контейнеров")
|
||||||
|
for project, stats in project_stats.items():
|
||||||
|
print(f" 📦 {project}: {stats['visible']} видимых, {stats['excluded']} исключенных")
|
||||||
|
|
||||||
return items
|
return items
|
||||||
|
|
||||||
# ---------- HTML ----------
|
# ---------- HTML ----------
|
||||||
@ -246,6 +343,41 @@ def api_logs_stats(container_id: str, _: HTTPBasicCredentials = Depends(check_ba
|
|||||||
print(f"Error getting log stats for {container_id}: {e}")
|
print(f"Error getting log stats for {container_id}: {e}")
|
||||||
return JSONResponse({"error": str(e)}, status_code=500)
|
return JSONResponse({"error": str(e)}, status_code=500)
|
||||||
|
|
||||||
|
@app.get("/api/excluded-containers")
|
||||||
|
def api_get_excluded_containers(_: HTTPBasicCredentials = Depends(check_basic)):
|
||||||
|
"""
|
||||||
|
Получить список исключенных контейнеров
|
||||||
|
"""
|
||||||
|
return JSONResponse(
|
||||||
|
content={"excluded_containers": load_excluded_containers()},
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||||
|
"Pragma": "no-cache",
|
||||||
|
"Expires": "0"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.post("/api/excluded-containers")
|
||||||
|
def api_update_excluded_containers(
|
||||||
|
containers: List[str] = Body(...),
|
||||||
|
_: HTTPBasicCredentials = Depends(check_basic)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Обновить список исключенных контейнеров
|
||||||
|
"""
|
||||||
|
success = save_excluded_containers(containers)
|
||||||
|
if success:
|
||||||
|
return JSONResponse(
|
||||||
|
content={"status": "success", "message": "Список исключенных контейнеров обновлен"},
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||||
|
"Pragma": "no-cache",
|
||||||
|
"Expires": "0"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=500, detail="Ошибка сохранения списка")
|
||||||
|
|
||||||
@app.get("/api/projects")
|
@app.get("/api/projects")
|
||||||
def api_projects(_: HTTPBasicCredentials = Depends(check_basic)):
|
def api_projects(_: HTTPBasicCredentials = Depends(check_basic)):
|
||||||
"""Получить список всех проектов Docker Compose"""
|
"""Получить список всех проектов Docker Compose"""
|
||||||
|
15
excluded_containers.json
Normal file
15
excluded_containers.json
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
],
|
||||||
|
"description": "Список контейнеров, которые генерируют слишком много логов и исключаются из отображения"
|
||||||
|
}
|
@ -328,6 +328,93 @@ a{color:var(--link)}
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Секция исключенных контейнеров */
|
||||||
|
.excluded-containers-list {
|
||||||
|
max-height: 150px;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.excluded-container-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.excluded-container-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.excluded-container-item:first-child {
|
||||||
|
border-top-left-radius: 6px;
|
||||||
|
border-top-right-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.excluded-container-item:last-child {
|
||||||
|
border-bottom-left-radius: 6px;
|
||||||
|
border-bottom-right-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.excluded-container-name {
|
||||||
|
color: var(--fg);
|
||||||
|
flex: 1;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-excluded-btn {
|
||||||
|
background: var(--err);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
margin-left: 8px;
|
||||||
|
min-width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-excluded-btn:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.excluded-containers-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.excluded-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
font-size: 12px;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.excluded-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.excluded-input::placeholder {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
/* Buttons */
|
/* Buttons */
|
||||||
.btn {
|
.btn {
|
||||||
background: var(--chip);
|
background: var(--chip);
|
||||||
@ -550,6 +637,82 @@ a{color:var(--link)}
|
|||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Мультивыбор проектов */
|
||||||
|
.multi-select-container {
|
||||||
|
position: relative;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-display {
|
||||||
|
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;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-display:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-display.active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--tab-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-text {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||||
|
z-index: 1000;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-option:hover {
|
||||||
|
background: var(--chip);
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-option input[type="checkbox"] {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-option label {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--fg);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.header-controls {
|
.header-controls {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -716,6 +879,19 @@ a{color:var(--link)}
|
|||||||
.status-indicator.stopped { background: var(--err); }
|
.status-indicator.stopped { background: var(--err); }
|
||||||
.status-indicator.paused { background: var(--warn); }
|
.status-indicator.paused { background: var(--warn); }
|
||||||
|
|
||||||
|
.container-link {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-link:hover {
|
||||||
|
opacity: 1;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
/* Log Area */
|
/* Log Area */
|
||||||
.log-area {
|
.log-area {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@ -900,6 +1076,8 @@ footer{position:fixed;right:10px;bottom:10px;opacity:.6;font-size:11px}
|
|||||||
<label for="wrap">Wrap text</label>
|
<label for="wrap">Wrap text</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -920,6 +1098,24 @@ footer{position:fixed;right:10px;bottom:10px;opacity:.6;font-size:11px}
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="control-group collapsible" data-section="excluded">
|
||||||
|
<div class="control-header">
|
||||||
|
<label>Excluded</label>
|
||||||
|
<button class="collapse-btn" data-target="excluded">
|
||||||
|
<i class="fas fa-chevron-down"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="control-content" id="excluded-content">
|
||||||
|
<div class="excluded-containers-list" id="excludedContainersList">
|
||||||
|
<!-- Список будет загружен динамически -->
|
||||||
|
</div>
|
||||||
|
<div class="excluded-containers-controls">
|
||||||
|
<input type="text" id="newExcludedContainer" placeholder="Имя контейнера" class="excluded-input">
|
||||||
|
<button id="addExcludedContainer" class="btn btn-small">Добавить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Container List -->
|
<!-- Container List -->
|
||||||
@ -942,9 +1138,18 @@ footer{position:fixed;right:10px;bottom:10px;opacity:.6;font-size:11px}
|
|||||||
</button>
|
</button>
|
||||||
<h2 class="header-title">Logs</h2>
|
<h2 class="header-title">Logs</h2>
|
||||||
<span class="header-badge" id="projectBadge">
|
<span class="header-badge" id="projectBadge">
|
||||||
<select id="projectSelectHeader">
|
<div class="multi-select-container">
|
||||||
<option value="all">All Projects</option>
|
<div class="multi-select-display" id="projectSelectDisplay">
|
||||||
</select>
|
<span class="multi-select-text">All Projects</span>
|
||||||
|
<i class="fas fa-chevron-down"></i>
|
||||||
|
</div>
|
||||||
|
<div class="multi-select-dropdown" id="projectSelectDropdown" style="display: none;">
|
||||||
|
<div class="multi-select-option" data-value="all">
|
||||||
|
<input type="checkbox" id="project-all" checked>
|
||||||
|
<label for="project-all">All Projects</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</span>
|
</span>
|
||||||
<input id="filter" type="text" placeholder="Filter logs (regex)…" class="header-filter"/>
|
<input id="filter" type="text" placeholder="Filter logs (regex)…" class="header-filter"/>
|
||||||
<div class="header-controls">
|
<div class="header-controls">
|
||||||
@ -1277,10 +1482,18 @@ function buildTabs(){
|
|||||||
<div class="container-status">
|
<div class="container-status">
|
||||||
<span class="status-indicator ${statusClass}"></span>
|
<span class="status-indicator ${statusClass}"></span>
|
||||||
${escapeHtml(svc.status)}
|
${escapeHtml(svc.status)}
|
||||||
|
${svc.url ? `<a href="${svc.url}" target="_blank" class="container-link" title="Открыть сайт"><i class="fas fa-external-link-alt"></i></a>` : ''}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
item.onclick = () => switchToSingle(svc);
|
item.onclick = (e) => {
|
||||||
|
// Не переключаем контейнер, если кликнули на ссылку
|
||||||
|
if (e.target.closest('.container-link')) {
|
||||||
|
e.stopPropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switchToSingle(svc);
|
||||||
|
};
|
||||||
els.containerList.appendChild(item);
|
els.containerList.appendChild(item);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -1307,44 +1520,230 @@ async function fetchProjects(){
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Обновляем селектор проектов в заголовке
|
// Обновляем мультивыбор проектов в заголовке
|
||||||
const headerSelect = document.getElementById('projectSelectHeader');
|
const dropdown = document.getElementById('projectSelectDropdown');
|
||||||
console.log('Header select element found:', !!headerSelect);
|
const display = document.getElementById('projectSelectDisplay');
|
||||||
if (headerSelect) {
|
const displayText = display?.querySelector('.multi-select-text');
|
||||||
headerSelect.innerHTML = '<option value="all">All Projects</option>';
|
|
||||||
console.log('Adding projects to header select:', projects);
|
console.log('Multi-select elements found:', {dropdown: !!dropdown, display: !!display, displayText: !!displayText});
|
||||||
|
|
||||||
|
if (dropdown && displayText) {
|
||||||
|
// Очищаем dropdown
|
||||||
|
dropdown.innerHTML = '';
|
||||||
|
|
||||||
|
// Добавляем опцию "All Projects"
|
||||||
|
const allOption = document.createElement('div');
|
||||||
|
allOption.className = 'multi-select-option';
|
||||||
|
allOption.setAttribute('data-value', 'all');
|
||||||
|
allOption.innerHTML = `
|
||||||
|
<input type="checkbox" id="project-all" checked>
|
||||||
|
<label for="project-all">All Projects</label>
|
||||||
|
`;
|
||||||
|
dropdown.appendChild(allOption);
|
||||||
|
|
||||||
|
// Добавляем проекты
|
||||||
|
console.log('Adding projects to multi-select:', projects);
|
||||||
projects.forEach(project => {
|
projects.forEach(project => {
|
||||||
const option = document.createElement('option');
|
const option = document.createElement('div');
|
||||||
option.value = project;
|
option.className = 'multi-select-option';
|
||||||
option.textContent = project;
|
option.setAttribute('data-value', project);
|
||||||
headerSelect.appendChild(option);
|
option.innerHTML = `
|
||||||
|
<input type="checkbox" id="project-${project}">
|
||||||
|
<label for="project-${project}">${escapeHtml(project)}</label>
|
||||||
|
`;
|
||||||
|
dropdown.appendChild(option);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Устанавливаем сохраненный проект
|
// Восстанавливаем сохраненные выбранные проекты
|
||||||
if (localStorage.lb_project && projects.includes(localStorage.lb_project)) {
|
const savedProjects = JSON.parse(localStorage.getItem('lb_selected_projects') || '["all"]');
|
||||||
headerSelect.value = localStorage.lb_project;
|
updateMultiSelect(savedProjects);
|
||||||
}
|
|
||||||
console.log('Header select updated, current value:', headerSelect.value);
|
console.log('Multi-select updated, current selection:', savedProjects);
|
||||||
} else {
|
} else {
|
||||||
console.error('Header select element not found!');
|
console.error('Multi-select elements not found!');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching projects:', error);
|
console.error('Error fetching projects:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Функция для обновления мультивыбора проектов
|
||||||
|
function updateMultiSelect(selectedProjects) {
|
||||||
|
const dropdown = document.getElementById('projectSelectDropdown');
|
||||||
|
const displayText = document.querySelector('.multi-select-text');
|
||||||
|
|
||||||
|
if (!dropdown || !displayText) return;
|
||||||
|
|
||||||
|
// Фильтруем выбранные проекты, оставляя только те, которые есть в dropdown
|
||||||
|
const availableProjects = [];
|
||||||
|
dropdown.querySelectorAll('.multi-select-option').forEach(option => {
|
||||||
|
const value = option.getAttribute('data-value');
|
||||||
|
availableProjects.push(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredProjects = selectedProjects.filter(project =>
|
||||||
|
project === 'all' || availableProjects.includes(project)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Если все выбранные проекты исчезли, выбираем "All Projects"
|
||||||
|
if (filteredProjects.length === 0 || (filteredProjects.length === 1 && filteredProjects[0] === 'all')) {
|
||||||
|
filteredProjects.length = 0;
|
||||||
|
filteredProjects.push('all');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем чекбоксы
|
||||||
|
dropdown.querySelectorAll('.multi-select-option').forEach(option => {
|
||||||
|
const value = option.getAttribute('data-value');
|
||||||
|
const checkbox = option.querySelector('input[type="checkbox"]');
|
||||||
|
if (checkbox) {
|
||||||
|
checkbox.checked = filteredProjects.includes(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Обновляем текст отображения
|
||||||
|
if (filteredProjects.includes('all') || filteredProjects.length === 0) {
|
||||||
|
displayText.textContent = 'All Projects';
|
||||||
|
} else if (filteredProjects.length === 1) {
|
||||||
|
displayText.textContent = filteredProjects[0];
|
||||||
|
} else {
|
||||||
|
displayText.textContent = `${filteredProjects.length} Projects`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохраняем в localStorage
|
||||||
|
localStorage.setItem('lb_selected_projects', JSON.stringify(filteredProjects));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для получения выбранных проектов
|
||||||
|
function getSelectedProjects() {
|
||||||
|
const dropdown = document.getElementById('projectSelectDropdown');
|
||||||
|
if (!dropdown) return ['all'];
|
||||||
|
|
||||||
|
const selectedProjects = [];
|
||||||
|
dropdown.querySelectorAll('.multi-select-option input[type="checkbox"]:checked').forEach(checkbox => {
|
||||||
|
const option = checkbox.closest('.multi-select-option');
|
||||||
|
const value = option.getAttribute('data-value');
|
||||||
|
selectedProjects.push(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
return selectedProjects.length > 0 ? selectedProjects : ['all'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функции для работы с исключенными контейнерами
|
||||||
|
async function loadExcludedContainers() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/excluded-containers');
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('Ошибка загрузки исключенных контейнеров:', response.status);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
return data.excluded_containers || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки исключенных контейнеров:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveExcludedContainers(containers) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/excluded-containers', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(containers)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('Ошибка сохранения исключенных контейнеров:', response.status);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
console.log('Исключенные контейнеры сохранены:', data);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка сохранения исключенных контейнеров:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderExcludedContainers(containers) {
|
||||||
|
const list = document.getElementById('excludedContainersList');
|
||||||
|
if (!list) return;
|
||||||
|
|
||||||
|
list.innerHTML = '';
|
||||||
|
|
||||||
|
if (containers.length === 0) {
|
||||||
|
list.innerHTML = '<div class="excluded-container-item"><span class="excluded-container-name">Нет исключенных контейнеров</span></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
containers.forEach(container => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'excluded-container-item';
|
||||||
|
item.innerHTML = `
|
||||||
|
<span class="excluded-container-name">${escapeHtml(container)}</span>
|
||||||
|
<button class="remove-excluded-btn" onclick="removeExcludedContainer('${escapeHtml(container)}')">×</button>
|
||||||
|
`;
|
||||||
|
list.appendChild(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addExcludedContainer() {
|
||||||
|
const input = document.getElementById('newExcludedContainer');
|
||||||
|
const containerName = input.value.trim();
|
||||||
|
|
||||||
|
if (!containerName) {
|
||||||
|
alert('Введите имя контейнера');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentContainers = await loadExcludedContainers();
|
||||||
|
if (currentContainers.includes(containerName)) {
|
||||||
|
alert('Контейнер уже в списке исключенных');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentContainers.push(containerName);
|
||||||
|
const success = await saveExcludedContainers(currentContainers);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
renderExcludedContainers(currentContainers);
|
||||||
|
input.value = '';
|
||||||
|
// Обновляем список проектов и контейнеров
|
||||||
|
await fetchProjects();
|
||||||
|
await fetchServices();
|
||||||
|
} else {
|
||||||
|
alert('Ошибка сохранения');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeExcludedContainer(containerName) {
|
||||||
|
const currentContainers = await loadExcludedContainers();
|
||||||
|
const updatedContainers = currentContainers.filter(name => name !== containerName);
|
||||||
|
|
||||||
|
const success = await saveExcludedContainers(updatedContainers);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
renderExcludedContainers(updatedContainers);
|
||||||
|
// Обновляем список проектов и контейнеров
|
||||||
|
await fetchProjects();
|
||||||
|
await fetchServices();
|
||||||
|
} else {
|
||||||
|
alert('Ошибка удаления');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchServices(){
|
async function fetchServices(){
|
||||||
try {
|
try {
|
||||||
console.log('Fetching services...');
|
console.log('Fetching services...');
|
||||||
const url = new URL(location.origin + '/api/services');
|
const url = new URL(location.origin + '/api/services');
|
||||||
const projectSelectHeader = document.getElementById('projectSelectHeader');
|
const selectedProjects = getSelectedProjects();
|
||||||
const selectedProject = projectSelectHeader ? projectSelectHeader.value : 'all';
|
|
||||||
|
|
||||||
if (selectedProject && selectedProject !== 'all') {
|
// Если выбраны конкретные проекты (не "all"), добавляем их в URL как строку через запятую
|
||||||
url.searchParams.set('projects', selectedProject);
|
if (selectedProjects.length > 0 && !selectedProjects.includes('all')) {
|
||||||
localStorage.lb_project = selectedProject;
|
url.searchParams.set('projects', selectedProjects.join(','));
|
||||||
} else {
|
|
||||||
localStorage.removeItem('lb_project');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
@ -1356,13 +1755,6 @@ async function fetchServices(){
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
console.log('Services loaded:', data);
|
console.log('Services loaded:', data);
|
||||||
state.services = data;
|
state.services = data;
|
||||||
const pj = selectedProject === 'all' ? 'all' : selectedProject;
|
|
||||||
|
|
||||||
// Обновляем селектор в заголовке
|
|
||||||
const headerSelect = document.getElementById('projectSelectHeader');
|
|
||||||
if (headerSelect) {
|
|
||||||
headerSelect.value = selectedProject;
|
|
||||||
}
|
|
||||||
|
|
||||||
buildTabs();
|
buildTabs();
|
||||||
if (!state.current && state.services.length) switchToSingle(state.services[0]);
|
if (!state.current && state.services.length) switchToSingle(state.services[0]);
|
||||||
@ -1874,15 +2266,67 @@ function addCounterClickHandlers() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Функция для добавления обработчика выпадающего списка проектов в заголовке
|
// Функция для добавления обработчиков мультивыбора проектов
|
||||||
function addHeaderProjectSelectHandler() {
|
function addMultiSelectHandlers() {
|
||||||
const headerProjectSelect = document.getElementById('projectSelectHeader');
|
const display = document.getElementById('projectSelectDisplay');
|
||||||
console.log('Adding handler for header project select, element found:', !!headerProjectSelect);
|
const dropdown = document.getElementById('projectSelectDropdown');
|
||||||
if (headerProjectSelect) {
|
|
||||||
headerProjectSelect.onchange = () => {
|
console.log('Adding multi-select handlers, elements found:', {display: !!display, dropdown: !!dropdown});
|
||||||
console.log('Header project select changed to:', headerProjectSelect.value);
|
|
||||||
|
if (display && dropdown) {
|
||||||
|
// Обработчик клика по дисплею
|
||||||
|
display.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const isOpen = dropdown.style.display !== 'none';
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
dropdown.style.display = 'none';
|
||||||
|
display.classList.remove('active');
|
||||||
|
} else {
|
||||||
|
dropdown.style.display = 'block';
|
||||||
|
display.classList.add('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Обработчики кликов по опциям
|
||||||
|
dropdown.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const option = e.target.closest('.multi-select-option');
|
||||||
|
if (!option) return;
|
||||||
|
|
||||||
|
const checkbox = option.querySelector('input[type="checkbox"]');
|
||||||
|
if (!checkbox) return;
|
||||||
|
|
||||||
|
const value = option.getAttribute('data-value');
|
||||||
|
|
||||||
|
// Специальная логика для "All Projects"
|
||||||
|
if (value === 'all') {
|
||||||
|
if (checkbox.checked) {
|
||||||
|
// Если "All Projects" выбран, снимаем все остальные
|
||||||
|
dropdown.querySelectorAll('.multi-select-option input[type="checkbox"]').forEach(cb => {
|
||||||
|
if (cb !== checkbox) cb.checked = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Если выбран конкретный проект, снимаем "All Projects"
|
||||||
|
const allCheckbox = dropdown.querySelector('#project-all');
|
||||||
|
if (allCheckbox) allCheckbox.checked = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем отображение и загружаем сервисы
|
||||||
|
const selectedProjects = getSelectedProjects();
|
||||||
|
updateMultiSelect(selectedProjects);
|
||||||
fetchServices();
|
fetchServices();
|
||||||
};
|
});
|
||||||
|
|
||||||
|
// Закрытие dropdown при клике вне его
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!display.contains(e.target) && !dropdown.contains(e.target)) {
|
||||||
|
dropdown.style.display = 'none';
|
||||||
|
display.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2154,8 +2598,29 @@ window.addEventListener('keydown', (e)=>{
|
|||||||
// Добавляем обработчики для счетчиков
|
// Добавляем обработчики для счетчиков
|
||||||
addCounterClickHandlers();
|
addCounterClickHandlers();
|
||||||
|
|
||||||
// Добавляем обработчик для выпадающего списка проектов в заголовке
|
// Добавляем обработчик для выпадающего списка проектов в заголовке
|
||||||
addHeaderProjectSelectHandler();
|
addMultiSelectHandlers();
|
||||||
|
|
||||||
|
// Загружаем и отображаем исключенные контейнеры
|
||||||
|
loadExcludedContainers().then(containers => {
|
||||||
|
renderExcludedContainers(containers);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Добавляем обработчики для исключенных контейнеров
|
||||||
|
const addExcludedBtn = document.getElementById('addExcludedContainer');
|
||||||
|
const newExcludedInput = document.getElementById('newExcludedContainer');
|
||||||
|
|
||||||
|
if (addExcludedBtn) {
|
||||||
|
addExcludedBtn.onclick = addExcludedContainer;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newExcludedInput) {
|
||||||
|
newExcludedInput.onkeypress = (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
addExcludedContainer();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user