Move excluded containers to separate sidebar section and improve UI

This commit is contained in:
Сергей Антропов 2025-08-16 16:31:15 +03:00
parent f3e1966f3e
commit 293e9c8cba
3 changed files with 682 additions and 70 deletions

184
app.py
View File

@ -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
View 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": "Список контейнеров, которые генерируют слишком много логов и исключаются из отображения"
}

View File

@ -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>