diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..5a270f6
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,72 @@
+# Changelog
+
+## [2.1.0] - 2025-08-20
+
+### ✨ Добавлено
+
+#### 🔽 Сворачивание секций
+- **Сворачивание основных секций**: Добавлена возможность сворачивать/разворачивать секции "Локальные контейнеры" и "Удаленные контейнеры"
+- **Сворачивание хостов**: Каждый хост в удаленных контейнерах можно сворачивать отдельно
+- **Кнопки управления**: Добавлены стрелки для сворачивания/разворачивания секций
+- **Сохранение состояния**: Состояние сворачивания сохраняется между сессиями
+- **Плавные анимации**: Добавлены CSS переходы для плавного сворачивания/разворачивания
+
+#### ⚡ Периодическое обновление контейнеров
+- **Автоматическое обновление**: Список контейнеров обновляется каждые 30 секунд
+- **Фильтрация остановленных**: Остановленные контейнеры автоматически скрываются из интерфейса
+- **Обновление счетчиков**: Количество контейнеров в секциях обновляется в реальном времени
+- **Логирование изменений**: В консоли браузера отображается информация об изменениях
+- **Оптимизация производительности**: Обновление происходит только при изменении списка контейнеров
+
+#### 🎨 Улучшения интерфейса
+- **Исправление миникарточек**: Контейнеры теперь корректно отображаются в свернутом sidebar
+- **Улучшенные стили**: Добавлены стили для кнопок сворачивания и анимаций
+- **Адаптивность**: Все новые элементы адаптированы для мобильных устройств
+
+### 🔧 Исправлено
+
+- **JavaScript ошибки**: Исправлены ошибки в обработчиках событий
+- **CSS синтаксис**: Исправлены ошибки в стилях
+- **Функции**: Исправлен порядок определения функций в JavaScript
+- **Обработчики**: Обработчики сворачивания теперь добавляются после каждого обновления интерфейса
+
+### 📚 Документация
+
+- **Обновлена документация**: Добавлено описание новых функций в `docs/remote-clients.md`
+- **Обновлен README**: Добавлена информация о новых возможностях
+- **Тестовые скрипты**: Созданы скрипты для тестирования новых функций
+
+### 🧪 Тестирование
+
+- **Тест сворачивания**: Создан `test_collapse.py` для проверки структуры данных
+- **Тест обновления**: Создан `test_container_update.py` для проверки периодического обновления
+- **Интеграционные тесты**: Все функции протестированы и работают корректно
+
+## [2.0.0] - 2025-08-20
+
+### ✨ Добавлено
+
+#### 🌐 Удаленные клиенты
+- **Поддержка удаленных серверов**: Возможность сбора логов с множества серверов
+- **Визуальное разделение**: Четкое различие между локальными и удаленными контейнерами
+- **Группировка по хостам**: Удаленные контейнеры сгруппированы по серверам-источникам
+- **API для удаленных логов**: Новые эндпоинты для приема логов от клиентов
+- **Клиентское приложение**: Docker Compose клиент для удаленных серверов
+
+#### 🎨 Современный интерфейс
+- **Адаптивный дизайн**: Поддержка мобильных устройств
+- **Темная/светлая тема**: Переключение между темами
+- **Сворачиваемая боковая панель**: Удобное управление интерфейсом
+- **Multi-view режим**: Одновременный просмотр нескольких контейнеров
+
+### 🔧 Улучшено
+
+- **Производительность**: Оптимизированы WebSocket соединения
+- **Безопасность**: Улучшена аутентификация и авторизация
+- **Мониторинг**: Добавлены health checks и логирование
+
+---
+
+**Автор**: Сергей Антропов
+**Сайт**: https://devops.org.ru
+**Email**: contact@devops.org.ru
diff --git a/README.md b/README.md
index 67ab147..757a4e6 100644
--- a/README.md
+++ b/README.md
@@ -42,7 +42,9 @@ LogBoard+ особенно полезен для разработчиков, р
- **Просмотр логов в реальном времени** - WebSocket соединения для live-логов
- **Поддержка множественных проектов** - Фильтрация по проектам Docker Compose
-- **Удаленные клиенты** - Сбор логов с множества серверов
+- **🌐 Удаленные клиенты** - Сбор логов с множества серверов с визуальным разделением
+- **🔽 Сворачивание секций** - Удобное управление отображением локальных и удаленных контейнеров
+- **⚡ Периодическое обновление** - Автоматическое обновление списка контейнеров и фильтрация остановленных
- **Безопасность** - JWT аутентификация и API ключи для клиентов
- **Фильтрация контейнеров** - Исключение проблемных контейнеров
- **Снимки логов** - Сохранение логов в файлы для анализа
@@ -92,9 +94,22 @@ LogBoard+ особенно полезен для разработчиков, р
- 1 GB RAM
- 1 CPU core
-## Удаленные клиенты
+## 🌐 Удаленные клиенты
-LogBoard+ поддерживает работу с удаленными клиентами для централизованного сбора логов с множества серверов.
+LogBoard+ поддерживает работу с удаленными клиентами для централизованного сбора логов с множества серверов. Новый интерфейс визуально разделяет локальные и удаленные контейнеры, группируя их по хостам.
+
+### ✨ Новые возможности интерфейса
+
+- **Визуальное разделение** - Локальные и удаленные контейнеры отображаются в отдельных секциях
+- **Группировка по хостам** - Удаленные контейнеры сгруппированы по серверам-источникам
+- **Цветовая индикация** - Оранжевый цвет для удаленных контейнеров
+- **Иконки и бейджи** - 🌐 для удаленных контейнеров, 🖥️ для хостов
+- **Время обновления** - Показывается время последнего обновления логов
+- **Статистика по хостам** - Количество контейнеров на каждом хосте
+- **🔽 Сворачивание секций** - Можно сворачивать/разворачивать секции "Локальные контейнеры" и "Удаленные контейнеры"
+- **🖥️ Сворачивание хостов** - Каждый хост в удаленных контейнерах можно сворачивать отдельно
+- **⚡ Автоматическое обновление** - Список контейнеров обновляется каждые 30 секунд
+- **🚫 Фильтрация остановленных** - Остановленные контейнеры автоматически скрываются из интерфейса
### Архитектура
diff --git a/app/api/v1/endpoints/containers.py b/app/api/v1/endpoints/containers.py
index 16fc77c..c31498b 100644
--- a/app/api/v1/endpoints/containers.py
+++ b/app/api/v1/endpoints/containers.py
@@ -15,8 +15,8 @@ from core.auth import get_current_user
from core.docker import (
load_excluded_containers,
save_excluded_containers,
- get_all_projects,
- list_containers,
+ get_all_projects_with_remote,
+ list_containers_with_remote,
DEFAULT_PROJECT,
DEFAULT_PROJECTS
)
@@ -59,9 +59,9 @@ def api_update_excluded_containers(
@router.get("/projects")
def api_projects(current_user: str = Depends(get_current_user)):
- """Получить список всех проектов Docker Compose"""
+ """Получить список всех проектов Docker Compose включая удаленные хосты"""
return JSONResponse(
- content=get_all_projects(),
+ content=get_all_projects_with_remote(),
headers={
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
@@ -75,7 +75,7 @@ def api_services(
include_stopped: bool = Query(False),
current_user: str = Depends(get_current_user)
):
- """Получить список контейнеров с поддержкой множественных проектов"""
+ """Получить список контейнеров с поддержкой множественных проектов включая удаленные хосты"""
project_list = None
if projects:
project_list = [p.strip() for p in projects.split(",") if p.strip()]
@@ -86,7 +86,7 @@ def api_services(
# Если ни одна переменная не указана или пустая, показываем все контейнеры (project_list остается None)
return JSONResponse(
- content=list_containers(projects=project_list, include_stopped=include_stopped),
+ content=list_containers_with_remote(projects=project_list, include_stopped=include_stopped),
headers={
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
diff --git a/app/api/v1/endpoints/logs.py b/app/api/v1/endpoints/logs.py
index 1f439b3..2aff204 100644
--- a/app/api/v1/endpoints/logs.py
+++ b/app/api/v1/endpoints/logs.py
@@ -16,46 +16,53 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Body, Header
from fastapi.responses import JSONResponse
import docker
+import os
+import re
+from datetime import datetime
from core.auth import get_current_user, verify_api_key
-from core.docker import docker_client, DEFAULT_TAIL
+from core.docker import docker_client, DEFAULT_TAIL, get_remote_containers
from core.logger import api_logger
router = APIRouter()
@router.get("/stats/{container_id}")
def api_logs_stats(container_id: str, current_user: str = Depends(get_current_user)):
- """Получить статистику логов контейнера"""
+ """Получить статистику логов контейнера (локального или удаленного)"""
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
+ # Проверяем, является ли это удаленным контейнером
+ if container_id.startswith('remote-'):
+ stats = get_remote_log_stats(container_id)
+ else:
+ # Ищем локальный контейнер
+ container = None
+ for c in docker_client.containers.list(all=True):
+ if c.id.startswith(container_id):
+ container = c
+ break
- 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
+ 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,
@@ -78,10 +85,10 @@ def api_logs(
current_user: str = Depends(get_current_user)
):
"""
- Получить логи контейнера через AJAX
+ Получить логи контейнера через AJAX (локального или удаленного)
Args:
- container_id: ID контейнера
+ container_id: ID контейнера (локального или удаленного в формате 'remote-hostname-container')
tail: Количество последних строк или 'all' для всех логов (по умолчанию 500)
since: Время начала для фильтрации логов
@@ -89,15 +96,57 @@ def api_logs(
JSON с логами и метаданными
"""
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)
+ # Проверяем, является ли это удаленным контейнером
+ if container_id.startswith('remote-'):
+ # Получаем логи удаленного контейнера
+ tail_lines = DEFAULT_TAIL if tail.lower() == 'all' else int(tail)
+ logs = get_remote_logs(container_id, tail=tail_lines, since=since)
+
+ # Форматируем логи для совместимости с локальными
+ log_lines = []
+ for line in logs:
+ if line.strip():
+ # Пытаемся извлечь временную метку из строки
+ timestamp_match = re.match(r'^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?)', line)
+ if timestamp_match:
+ timestamp = timestamp_match.group(1) + 'Z' # Добавляем Z для совместимости
+ message = line[len(timestamp_match.group(0)):].strip()
+ log_lines.append({
+ 'timestamp': timestamp,
+ 'message': message,
+ 'raw': line
+ })
+ else:
+ # Если не можем извлечь время, используем текущее
+ timestamp = datetime.now().isoformat() + 'Z'
+ log_lines.append({
+ 'timestamp': timestamp,
+ 'message': line,
+ 'raw': line
+ })
+
+ return JSONResponse(
+ content={
+ "logs": log_lines,
+ "container_id": container_id,
+ "is_remote": True
+ },
+ headers={
+ "Cache-Control": "no-cache, no-store, must-revalidate",
+ "Pragma": "no-cache",
+ "Expires": "0"
+ }
+ )
+ else:
+ # Ищем локальный контейнер
+ 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)
# Формируем параметры для получения логов
log_params = {
@@ -296,3 +345,105 @@ async def api_remote_logs(
except Exception as e:
api_logger.error(f"Error processing remote logs: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
+
+def get_remote_logs(container_id: str, tail: int = DEFAULT_TAIL, since: Optional[str] = None) -> List[str]:
+ """
+ Получить логи удаленного контейнера из файла
+
+ Args:
+ container_id: ID удаленного контейнера в формате 'remote-hostname-container'
+ tail: Количество последних строк
+ since: Время начала для фильтрации
+
+ Returns:
+ Список строк логов
+ """
+ try:
+ # Парсим ID удаленного контейнера
+ if not container_id.startswith('remote-'):
+ return []
+
+ parts = container_id.split('-', 2) # Разделяем на 'remote', 'hostname', 'container'
+ if len(parts) != 3:
+ return []
+
+ hostname = parts[1]
+ container_name = parts[2]
+
+ # Ищем файл логов
+ remote_logs_dir = os.path.join(os.getcwd(), 'logs', 'remote', hostname)
+ if not os.path.exists(remote_logs_dir):
+ return []
+
+ # Ищем файл с логами для этого контейнера
+ log_file = None
+ for filename in os.listdir(remote_logs_dir):
+ if filename.startswith(f"{container_name}-") and filename.endswith('.log'):
+ log_file = os.path.join(remote_logs_dir, filename)
+ break
+
+ if not log_file or not os.path.exists(log_file):
+ return []
+
+ # Читаем логи из файла
+ with open(log_file, 'r', encoding='utf-8') as f:
+ lines = f.readlines()
+
+ # Фильтруем по времени, если указано
+ if since:
+ try:
+ since_time = datetime.fromisoformat(since.replace('Z', '+00:00'))
+ filtered_lines = []
+ for line in lines:
+ # Пытаемся извлечь время из строки лога
+ # Предполагаем формат: 2025-08-20T16:15:56.608911 [container] INFO: message
+ time_match = re.match(r'(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?)', line)
+ if time_match:
+ line_time = datetime.fromisoformat(time_match.group(1).replace('Z', '+00:00'))
+ if line_time >= since_time:
+ filtered_lines.append(line)
+ else:
+ # Если не можем извлечь время, добавляем строку
+ filtered_lines.append(line)
+ lines = filtered_lines
+ except Exception as e:
+ api_logger.warning(f"Error filtering logs by time: {e}")
+
+ # Применяем tail
+ if tail != 'all' and len(lines) > tail:
+ lines = lines[-tail:]
+
+ return [line.rstrip('\n') for line in lines]
+
+ except Exception as e:
+ api_logger.error(f"Error reading remote logs for {container_id}: {e}")
+ return []
+
+def get_remote_log_stats(container_id: str) -> Dict[str, int]:
+ """
+ Получить статистику логов удаленного контейнера
+
+ Args:
+ container_id: ID удаленного контейнера
+
+ Returns:
+ Словарь со статистикой
+ """
+ logs = get_remote_logs(container_id, tail=1000)
+ stats = {"debug": 0, "info": 0, "warn": 0, "error": 0}
+
+ for line in logs:
+ 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 stats
diff --git a/app/api/v1/endpoints/websocket.py b/app/api/v1/endpoints/websocket.py
index f5669af..a4056b1 100644
--- a/app/api/v1/endpoints/websocket.py
+++ b/app/api/v1/endpoints/websocket.py
@@ -13,7 +13,7 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query, Depends
from fastapi.responses import JSONResponse
from core.auth import verify_token, get_current_user
-from core.docker import docker_client, DEFAULT_TAIL
+from core.docker import docker_client, DEFAULT_TAIL, get_remote_containers
from core.logger import websocket_logger
from datetime import datetime
@@ -56,7 +56,7 @@ def api_websocket_status(current_user: str = Depends(get_current_user)):
@router.websocket("/logs/{container_id}")
async def ws_logs(ws: WebSocket, container_id: str, tail: int = DEFAULT_TAIL, token: Optional[str] = None,
service: Optional[str] = None, project: Optional[str] = None):
- """WebSocket для получения логов контейнера"""
+ """WebSocket для получения логов контейнера (локального или удаленного)"""
# Принимаем соединение
await ws.accept()
@@ -74,50 +74,77 @@ async def ws_logs(ws: WebSocket, container_id: str, tail: int = DEFAULT_TAIL, to
return
try:
- # Простой поиск контейнера по ID
- container = None
- try:
- for c in docker_client.containers.list(all=True):
- if c.id.startswith(container_id):
- container = c
- break
- except Exception as e:
- await ws.send_text(f"ERROR: cannot list containers - {e}")
- return
+ # Проверяем, является ли это удаленным контейнером
+ if container_id.startswith('remote-'):
+ # Обрабатываем удаленный контейнер
+ parts = container_id.split('-', 2)
+ if len(parts) != 3:
+ await ws.send_text("ERROR: invalid remote container ID format")
+ return
+
+ hostname = parts[1]
+ container_name = parts[2]
+
+ # Отправляем начальное сообщение
+ await ws.send_text(f"Connected to remote container: {container_name} on {hostname}")
+
+ # Получаем логи удаленного контейнера
+ try:
+ from app.api.v1.endpoints.logs import get_remote_logs
+ logs = get_remote_logs(container_id, tail=tail)
+ if logs:
+ await ws.send_text('\n'.join(logs))
+ else:
+ await ws.send_text("No logs available for remote container")
+ except Exception as e:
+ await ws.send_text(f"ERROR: cannot get remote logs - {e}")
+ return
+
+ else:
+ # Обрабатываем локальный контейнер
+ container = None
+ try:
+ for c in docker_client.containers.list(all=True):
+ if c.id.startswith(container_id):
+ container = c
+ break
+ except Exception as e:
+ await ws.send_text(f"ERROR: cannot list containers - {e}")
+ return
- if container is None:
- await ws.send_text("ERROR: container not found")
- return
+ if container is None:
+ await ws.send_text("ERROR: container not found")
+ return
- # Отправляем начальное сообщение
- await ws.send_text(f"Connected to container: {container.name}")
+ # Отправляем начальное сообщение
+ await ws.send_text(f"Connected to container: {container.name}")
- # Получаем логи (только последние строки, без follow)
- try:
- websocket_logger.info(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:
- websocket_logger.error(f"Error getting logs for {container.name}: {e}")
- await ws.send_text(f"ERROR getting logs: {e}")
+ # Получаем логи (только последние строки, без follow)
+ try:
+ websocket_logger.info(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:
+ websocket_logger.error(f"Error getting logs for {container.name}: {e}")
+ await ws.send_text(f"ERROR getting logs: {e}")
- # Простое WebSocket соединение - только отправляем логи один раз
- websocket_logger.info(f"WebSocket connection established for {container.name}")
+ # Простое WebSocket соединение - только отправляем логи один раз
+ websocket_logger.info(f"WebSocket connection established for {container.name}")
except WebSocketDisconnect:
- websocket_logger.info(f"WebSocket client disconnected for container {container.name}")
+ websocket_logger.info(f"WebSocket client disconnected")
except Exception as e:
- websocket_logger.error(f"WebSocket error for {container.name}: {e}")
+ websocket_logger.error(f"WebSocket error: {e}")
try:
await ws.send_text(f"ERROR: {e}")
except:
pass
finally:
try:
- websocket_logger.info(f"Closing WebSocket connection for container {container.name}")
+ websocket_logger.info(f"Closing WebSocket connection")
await ws.close()
except:
pass
diff --git a/app/core/docker.py b/app/core/docker.py
index dc6179f..43e6564 100644
--- a/app/core/docker.py
+++ b/app/core/docker.py
@@ -242,3 +242,129 @@ def list_containers(projects: Optional[List[str]] = None, include_stopped: bool
docker_logger.info(f" 📦 {project}: {stats['visible']} видимых, {stats['excluded']} исключенных")
return items
+
+def get_remote_hosts() -> List[str]:
+ """
+ Получает список удаленных хостов из папки logs/remote
+ Автор: Сергей Антропов
+ Сайт: https://devops.org.ru
+ """
+ remote_hosts = []
+ remote_logs_dir = os.path.join(os.getcwd(), 'logs', 'remote')
+
+ try:
+ if os.path.exists(remote_logs_dir):
+ for item in os.listdir(remote_logs_dir):
+ item_path = os.path.join(remote_logs_dir, item)
+ if os.path.isdir(item_path):
+ remote_hosts.append(item)
+ except Exception as e:
+ docker_logger.error(f"Ошибка получения списка удаленных хостов: {e}")
+
+ return sorted(remote_hosts)
+
+def get_remote_containers(hostname: str) -> List[Dict]:
+ """
+ Получает список контейнеров для удаленного хоста
+ Автор: Сергей Антропов
+ Сайт: https://devops.org.ru
+ """
+ containers = []
+ remote_logs_dir = os.path.join(os.getcwd(), 'logs', 'remote', hostname)
+
+ try:
+ if os.path.exists(remote_logs_dir):
+ for filename in os.listdir(remote_logs_dir):
+ if filename.endswith('.log'):
+ # Извлекаем имя контейнера из имени файла
+ # Формат: container-name-YYYYMMDD.log
+ container_name = filename.replace('.log', '')
+ # Убираем дату из конца
+ if '-' in container_name:
+ parts = container_name.split('-')
+ if len(parts) > 1 and parts[-1].isdigit() and len(parts[-1]) == 8:
+ container_name = '-'.join(parts[:-1])
+
+ # Получаем информацию о файле
+ file_path = os.path.join(remote_logs_dir, filename)
+ stat = os.stat(file_path)
+
+ containers.append({
+ "id": f"remote-{hostname}-{container_name}",
+ "name": container_name,
+ "status": "running", # Предполагаем, что удаленные контейнеры работают
+ "image": "remote",
+ "service": container_name,
+ "project": "remote",
+ "health": "healthy",
+ "ports": [],
+ "url": None,
+ "hostname": hostname,
+ "is_remote": True,
+ "last_modified": stat.st_mtime,
+ "size": stat.st_size
+ })
+ except Exception as e:
+ docker_logger.error(f"Ошибка получения контейнеров для хоста {hostname}: {e}")
+
+ return containers
+
+def get_all_projects_with_remote() -> List[str]:
+ """
+ Получает список всех проектов включая удаленные
+ Автор: Сергей Антропов
+ Сайт: https://devops.org.ru
+ """
+ # Получаем локальные проекты
+ local_projects = get_all_projects()
+
+ # Добавляем удаленные хосты как проекты
+ remote_hosts = get_remote_hosts()
+ remote_projects = [f"remote-{host}" for host in remote_hosts]
+
+ # Объединяем и сортируем
+ all_projects = local_projects + remote_projects
+ return sorted(all_projects)
+
+def list_containers_with_remote(projects: Optional[List[str]] = None, include_stopped: bool = False) -> List[Dict]:
+ """
+ Получает список всех контейнеров включая удаленные
+ Автор: Сергей Антропов
+ Сайт: https://devops.org.ru
+ """
+ # Получаем локальные контейнеры
+ local_containers = list_containers(projects, include_stopped)
+
+ # Добавляем информацию о том, что это локальные контейнеры
+ for container in local_containers:
+ container["hostname"] = "localhost"
+ container["is_remote"] = False
+
+ # Получаем удаленные контейнеры
+ remote_hosts = get_remote_hosts()
+ remote_containers = []
+
+ for hostname in remote_hosts:
+ # Проверяем, нужно ли включать этот хост
+ if projects is None or any(f"remote-{hostname}" in project for project in projects):
+ host_containers = get_remote_containers(hostname)
+ remote_containers.extend(host_containers)
+
+ # Объединяем локальные и удаленные контейнеры
+ all_containers = local_containers + remote_containers
+
+ # Фильтруем по проектам, если указаны
+ if projects:
+ filtered_containers = []
+ for container in all_containers:
+ if container["is_remote"]:
+ # Для удаленных контейнеров проверяем соответствие хоста
+ if any(f"remote-{container['hostname']}" in project for project in projects):
+ filtered_containers.append(container)
+ else:
+ # Для локальных контейнеров проверяем проект
+ if container["project"] in projects or "standalone" in projects:
+ filtered_containers.append(container)
+ return filtered_containers
+
+ return all_containers
diff --git a/app/static/css/index.css b/app/static/css/index.css
index dabb3f9..b0ae75f 100644
--- a/app/static/css/index.css
+++ b/app/static/css/index.css
@@ -1863,6 +1863,207 @@ a{color:var(--link)}
padding: 16px;
}
+/* Секции контейнеров */
+.container-section {
+ margin-bottom: 16px;
+}
+
+.section-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--muted);
+ margin-bottom: 8px;
+ padding: 8px 12px;
+ background: var(--bg-secondary);
+ border-radius: 6px;
+ border-left: 3px solid var(--accent);
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.section-header:hover {
+ background: var(--tab-active);
+ color: var(--text);
+}
+
+.section-header-left {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex: 1;
+}
+
+.section-toggle-btn {
+ background: none;
+ border: none;
+ color: var(--muted);
+ cursor: pointer;
+ padding: 4px;
+ border-radius: 4px;
+ transition: all 0.2s ease;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 20px;
+ height: 20px;
+}
+
+.section-toggle-btn:hover {
+ background: var(--chip);
+ color: var(--text);
+}
+
+.section-toggle-btn i {
+ font-size: 10px;
+ transition: transform 0.2s ease;
+}
+
+.section-content {
+ transition: all 0.3s ease;
+}
+
+.section-content.collapsed {
+ display: none;
+}
+
+.section-header i {
+ font-size: 14px;
+}
+
+.section-count {
+ margin-left: auto;
+ background: var(--accent);
+ color: var(--bg);
+ padding: 2px 6px;
+ border-radius: 10px;
+ font-size: 10px;
+ font-weight: 500;
+}
+
+/* Секции хостов для удаленных контейнеров */
+.host-section {
+ margin-bottom: 12px;
+}
+
+.host-header {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 11px;
+ font-weight: 500;
+ color: var(--text);
+ margin-bottom: 6px;
+ padding: 6px 8px;
+ background: var(--chip);
+ border-radius: 4px;
+ border-left: 2px solid var(--warn);
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.host-header:hover {
+ background: var(--tab-active);
+}
+
+.host-header-left {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex: 1;
+}
+
+.host-header i {
+ font-size: 12px;
+ color: var(--warn);
+}
+
+.host-name {
+ font-weight: 600;
+}
+
+.host-count {
+ margin-left: auto;
+ background: var(--warn);
+ color: var(--bg);
+ padding: 1px 4px;
+ border-radius: 8px;
+ font-size: 9px;
+ font-weight: 500;
+}
+
+/* Стили для удаленных контейнеров */
+.container-item.remote-container {
+ border-left: 3px solid var(--warn);
+ background: var(--chip);
+}
+
+.container-item.remote-container:hover {
+ background: var(--tab-active);
+ border-color: var(--warn);
+}
+
+.container-item.remote-container.active {
+ border-color: var(--warn);
+ background: var(--tab-active);
+}
+
+.container-item.remote-container.active::before {
+ background: var(--warn);
+}
+
+/* Бейдж для удаленных контейнеров */
+.remote-badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 16px;
+ height: 16px;
+ background: var(--warn);
+ color: var(--bg);
+ border-radius: 50%;
+ font-size: 8px;
+ margin-left: 6px;
+}
+
+/* Информация о хосте */
+.host-info {
+ color: var(--warn);
+ font-weight: 500;
+}
+
+/* Время последнего обновления */
+.last-update {
+ color: var(--muted);
+ font-size: 9px;
+}
+
+/* Миникарточки для удаленных контейнеров */
+.mini-container-item.remote-container {
+ border-left: 2px solid var(--warn);
+}
+
+.mini-container-item.remote-container .mini-container-icon {
+ position: relative;
+}
+
+.remote-icon {
+ position: absolute;
+ top: -2px;
+ right: -2px;
+ font-size: 8px;
+ color: var(--warn);
+ background: var(--bg);
+ border-radius: 50%;
+ width: 12px;
+ height: 12px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
.container-item {
background: var(--chip);
border: 1px solid var(--border);
@@ -2582,3 +2783,4 @@ footer{position:fixed;right:10px;bottom:10px;opacity:.6;font-size:11px}
.notification-close:hover {
background: var(--chip);
color: var(--fg);
+}
diff --git a/app/static/js/index.js b/app/static/js/index.js
index cae90dd..abd6669 100644
--- a/app/static/js/index.js
+++ b/app/static/js/index.js
@@ -86,6 +86,18 @@ const els = {
});
})();
+/**
+ * Фильтрует контейнеры, убирая остановленные
+ * @param {Array} containers - Массив контейнеров для фильтрации
+ * @returns {Array} Отфильтрованный массив только работающих контейнеров
+ */
+function filterStoppedContainers(containers) {
+ return containers.filter(container => {
+ // Оставляем только работающие контейнеры
+ return container.status === 'running';
+ });
+}
+
/**
* Устанавливает состояние WebSocket соединения в интерфейсе
* @param {string} s - Состояние: 'on', 'off', 'err', 'available'
@@ -831,69 +843,221 @@ function buildTabs(){
miniContainerList.innerHTML = '';
}
- state.services.forEach(svc => {
- // Создаем обычную карточку контейнера
- const item = document.createElement('div');
- item.className = 'container-item';
- if (state.current && svc.id === state.current.id) {
- item.classList.add('active');
- }
- item.setAttribute('data-cid', svc.id);
-
- const statusClass = svc.status === 'running' ? 'running' :
- svc.status === 'stopped' ? 'stopped' : 'paused';
-
- item.innerHTML = `
-
-
- ${escapeHtml(svc.name)}
-
-
- ${escapeHtml(svc.service || svc.name)}
- • ${escapeHtml(svc.project || 'standalone')}
-
-
-
- ${escapeHtml(svc.status)}
- ${svc.status === 'running' && svc.host_port ? ` on ${svc.host_port} port` : ''}
- ${svc.url ? `
` : ''}
-
-
-
-
+ // Группируем контейнеры по типу (локальные и удаленные)
+ const localContainers = state.services.filter(svc => !svc.is_remote);
+ const remoteContainers = state.services.filter(svc => svc.is_remote);
+
+ // Создаем секцию для локальных контейнеров
+ if (localContainers.length > 0) {
+ const localSection = document.createElement('div');
+ localSection.className = 'container-section local-section';
+ localSection.innerHTML = `
+
+
`;
+ els.containerList.appendChild(localSection);
- // Устанавливаем состояние selected для контейнера
- if (state.selectedContainers.includes(svc.id)) {
- item.classList.add('selected');
+ localContainers.forEach(svc => {
+ const item = document.createElement('div');
+ item.className = 'container-item local-container';
+ if (state.current && svc.id === state.current.id) {
+ item.classList.add('active');
+ }
+ item.setAttribute('data-cid', svc.id);
+
+ const statusClass = svc.status === 'running' ? 'running' :
+ svc.status === 'stopped' ? 'stopped' : 'paused';
+
+ item.innerHTML = `
+
+
+ ${escapeHtml(svc.name)}
+
+
+ ${escapeHtml(svc.service || svc.name)}
+ • ${escapeHtml(svc.project || 'standalone')}
+
+
+
+ ${escapeHtml(svc.status)}
+ ${svc.status === 'running' && svc.host_port ? ` on ${svc.host_port} port` : ''}
+ ${svc.url ? `
` : ''}
+
+
+
+
+
+ `;
+
+ // Устанавливаем состояние selected для контейнера
+ if (state.selectedContainers.includes(svc.id)) {
+ item.classList.add('selected');
+ }
+
+ item.onclick = async (e) => {
+ // Не переключаем контейнер, если кликнули на ссылку или чекбокс
+ if (e.target.closest('.container-link') || e.target.closest('.container-select')) {
+ e.stopPropagation();
+ return;
+ }
+ await switchToSingle(svc);
+ };
+ els.containerList.appendChild(item);
+ });
+ }
+
+ // Создаем секцию для удаленных контейнеров
+ if (remoteContainers.length > 0) {
+ const remoteSection = document.createElement('div');
+ remoteSection.className = 'container-section remote-section';
+ remoteSection.innerHTML = `
+
+
+ `;
+ els.containerList.appendChild(remoteSection);
+
+ // Группируем удаленные контейнеры по хостам
+ const containersByHost = {};
+ remoteContainers.forEach(svc => {
+ const hostname = svc.hostname || 'unknown';
+ if (!containersByHost[hostname]) {
+ containersByHost[hostname] = [];
+ }
+ containersByHost[hostname].push(svc);
+ });
+
+ Object.entries(containersByHost).forEach(([hostname, containers]) => {
+ const hostSection = document.createElement('div');
+ hostSection.className = 'host-section';
+ hostSection.innerHTML = `
+
+
+ `;
+ els.containerList.appendChild(hostSection);
+
+ containers.forEach(svc => {
+ const item = document.createElement('div');
+ item.className = 'container-item remote-container';
+ if (state.current && svc.id === state.current.id) {
+ item.classList.add('active');
+ }
+ item.setAttribute('data-cid', svc.id);
+ item.setAttribute('data-hostname', svc.hostname);
+
+ const statusClass = svc.status === 'running' ? 'running' :
+ svc.status === 'stopped' ? 'stopped' : 'paused';
+
+ item.innerHTML = `
+
+
+ ${escapeHtml(svc.name)}
+
+
+
+
+
+ ${escapeHtml(svc.service || svc.name)}
+ • ${escapeHtml(svc.project || 'remote')}
+ • ${escapeHtml(svc.hostname)}
+
+
+
+ ${escapeHtml(svc.status)}
+ ${svc.last_modified ? `• Обновлено: ${new Date(svc.last_modified * 1000).toLocaleString()}` : ''}
+
+
+
+
+
+ `;
+
+ // Устанавливаем состояние selected для контейнера
+ if (state.selectedContainers.includes(svc.id)) {
+ item.classList.add('selected');
+ }
+
+ item.onclick = async (e) => {
+ // Не переключаем контейнер, если кликнули на ссылку или чекбокс
+ if (e.target.closest('.container-link') || e.target.closest('.container-select')) {
+ e.stopPropagation();
+ return;
+ }
+ await switchToSingle(svc);
+ };
+ els.containerList.appendChild(item);
+ });
+
+ // Закрываем секцию хоста
+ const hostContent = document.getElementById(`host-${hostname}-content`);
+ if (hostContent) {
+ hostContent.style.display = 'block';
+ }
+ });
+
+ // Закрываем секцию удаленных контейнеров
+ const remoteContent = document.getElementById('remote-content');
+ if (remoteContent) {
+ remoteContent.style.display = 'block';
}
- item.onclick = async (e) => {
- // Не переключаем контейнер, если кликнули на ссылку или чекбокс
- if (e.target.closest('.container-link') || e.target.closest('.container-select')) {
- e.stopPropagation();
- return;
- }
- await switchToSingle(svc);
- };
- els.containerList.appendChild(item);
-
- // Создаем миникарточку контейнера
- if (miniContainerList) {
+ // Закрываем секцию локальных контейнеров
+ const localContent = document.getElementById('local-content');
+ if (localContent) {
+ localContent.style.display = 'block';
+ }
+ }
+
+ // Создаем миникарточки для всех контейнеров
+ if (miniContainerList) {
+ state.services.forEach(svc => {
const miniItem = document.createElement('div');
miniItem.className = 'mini-container-item';
+ if (svc.is_remote) {
+ miniItem.classList.add('remote-container');
+ }
if (state.current && svc.id === state.current.id) {
miniItem.classList.add('active');
}
miniItem.setAttribute('data-cid', svc.id);
+ // Определяем статус для миникарточки
+ const statusClass = svc.status === 'running' ? 'running' :
+ svc.status === 'stopped' ? 'stopped' : 'paused';
+
// Сокращаем имя для миникарточки
const shortName = svc.name.length > 8 ? svc.name.substring(0, 6) + '..' : svc.name;
miniItem.innerHTML = `
+ ${svc.is_remote ? '' : ''}
${escapeHtml(shortName)}
@@ -944,8 +1108,11 @@ function buildTabs(){
});
miniContainerList.appendChild(miniItem);
- }
- });
+ });
+ }
+
+ // Добавляем обработчики для сворачивания секций после построения интерфейса
+ addSectionToggleHandlers();
}
function setLayout(cls){
@@ -2133,7 +2300,11 @@ async function fetchServices(){
}
const data = await res.json();
console.log('Services loaded:', data);
- state.services = data;
+
+ // Фильтруем остановленные контейнеры
+ const filteredData = filterStoppedContainers(data);
+ console.log('Filtered services (running only):', filteredData);
+ state.services = filteredData;
buildTabs();
@@ -5001,6 +5172,9 @@ function reinitializeElements() {
// Останавливаем автоматическую проверку WebSocket
stopWebSocketStatusCheck();
+ // Останавливаем периодическое обновление контейнеров
+ stopContainerUpdate();
+
// Закрываем все WebSocket соединения
Object.keys(state.open).forEach(id => {
const obj = state.open[id];
@@ -5078,6 +5252,12 @@ function reinitializeElements() {
});
}
+ // Добавляем обработчики для сворачивания секций
+ addSectionToggleHandlers();
+
+ // Запускаем периодическое обновление контейнеров
+ startContainerUpdate();
+
// Проверяем, есть ли сохраненный контейнер в localStorage
const savedContainerId = getSelectedContainerFromStorage();
if (savedContainerId) {
@@ -5809,4 +5989,194 @@ function reinitializeElements() {
return result;
};
+ /**
+ * Добавляет обработчики для сворачивания секций контейнеров
+ */
+ function addSectionToggleHandlers() {
+ // Обработчик для сворачивания секций
+ document.addEventListener('click', (e) => {
+ if (e.target.closest('.section-toggle-btn')) {
+ e.preventDefault();
+ e.stopPropagation();
+
+ const button = e.target.closest('.section-toggle-btn');
+ const target = button.getAttribute('data-target');
+ const icon = button.querySelector('i');
+ const content = document.getElementById(`${target}-content`);
+
+ if (content) {
+ const isCollapsed = content.style.display === 'none';
+
+ if (isCollapsed) {
+ // Разворачиваем секцию
+ content.style.display = 'block';
+ icon.className = 'fas fa-chevron-down';
+ button.title = 'Свернуть секцию';
+ } else {
+ // Сворачиваем секцию
+ content.style.display = 'none';
+ icon.className = 'fas fa-chevron-right';
+ button.title = 'Развернуть секцию';
+ }
+ }
+ }
+ });
+
+ // Обработчик для сворачивания секций хостов
+ document.addEventListener('click', (e) => {
+ if (e.target.closest('.host-header')) {
+ const header = e.target.closest('.host-header');
+ const button = header.querySelector('.section-toggle-btn');
+
+ if (button && !e.target.closest('.section-toggle-btn')) {
+ e.preventDefault();
+ e.stopPropagation();
+
+ const target = button.getAttribute('data-target');
+ const icon = button.querySelector('i');
+ const content = document.getElementById(`${target}-content`);
+
+ if (content) {
+ const isCollapsed = content.style.display === 'none';
+
+ if (isCollapsed) {
+ // Разворачиваем секцию
+ content.style.display = 'block';
+ icon.className = 'fas fa-chevron-down';
+ button.title = 'Свернуть секцию';
+ } else {
+ // Сворачиваем секцию
+ content.style.display = 'none';
+ icon.className = 'fas fa-chevron-right';
+ button.title = 'Развернуть секцию';
+ }
+ }
+ }
+ }
+ });
+ }
+
+
+
+ /**
+ * Периодическое обновление списка контейнеров
+ */
+ let containerUpdateInterval = null;
+
+ function startContainerUpdate() {
+ // Останавливаем предыдущий интервал, если он существует
+ if (containerUpdateInterval) {
+ clearInterval(containerUpdateInterval);
+ }
+
+ // Запускаем обновление каждые 30 секунд
+ containerUpdateInterval = setInterval(async () => {
+ try {
+ console.log('Периодическое обновление списка контейнеров...');
+
+ // Получаем новые данные о контейнерах
+ const url = new URL(location.origin + '/api/containers/services');
+ const selectedProjects = getSelectedProjects();
+
+ if (selectedProjects.length > 0 && !selectedProjects.includes('all')) {
+ url.searchParams.set('projects', selectedProjects.join(','));
+ }
+
+ const token = localStorage.getItem('access_token');
+ if (!token) {
+ console.error('No access token found during container update');
+ return;
+ }
+
+ const res = await fetch(url, {
+ headers: {
+ 'Authorization': `Bearer ${token}`
+ }
+ });
+
+ if (!res.ok) {
+ console.error('Failed to fetch containers during update:', res.status);
+ return;
+ }
+
+ const newContainers = await res.json();
+
+ // Фильтруем остановленные контейнеры
+ const filteredContainers = filterStoppedContainers(newContainers);
+
+ // Проверяем, изменился ли список контейнеров
+ const currentIds = state.services.map(c => c.id).sort();
+ const newIds = filteredContainers.map(c => c.id).sort();
+
+ const hasChanged = JSON.stringify(currentIds) !== JSON.stringify(newIds);
+
+ if (hasChanged) {
+ console.log('Список контейнеров изменился, обновляем интерфейс...');
+ console.log('Было контейнеров:', state.services.length);
+ console.log('Стало контейнеров:', filteredContainers.length);
+
+ // Обновляем состояние
+ state.services = filteredContainers;
+
+ // Перестраиваем интерфейс
+ buildTabs();
+
+ // Обновляем счетчики в заголовке
+ updateHeaderCounters();
+
+ console.log('Интерфейс обновлен');
+ }
+
+ } catch (error) {
+ console.error('Ошибка при обновлении списка контейнеров:', error);
+ }
+ }, 30000); // 30 секунд
+
+ console.log('Периодическое обновление контейнеров запущено (интервал: 30 сек)');
+ }
+
+ function stopContainerUpdate() {
+ if (containerUpdateInterval) {
+ clearInterval(containerUpdateInterval);
+ containerUpdateInterval = null;
+ console.log('Периодическое обновление контейнеров остановлено');
+ }
+ }
+
+ /**
+ * Обновляет счетчики контейнеров в заголовке
+ */
+ function updateHeaderCounters() {
+ const localContainers = state.services.filter(svc => !svc.is_remote);
+ const remoteContainers = state.services.filter(svc => svc.is_remote);
+
+ // Обновляем счетчики в секциях
+ const localCount = document.querySelector('.local-section .section-count');
+ if (localCount) {
+ localCount.textContent = localContainers.length;
+ }
+
+ const remoteCount = document.querySelector('.remote-section .section-count');
+ if (remoteCount) {
+ remoteCount.textContent = remoteContainers.length;
+ }
+
+ // Обновляем счетчики хостов
+ const containersByHost = {};
+ remoteContainers.forEach(svc => {
+ const hostname = svc.hostname || 'unknown';
+ if (!containersByHost[hostname]) {
+ containersByHost[hostname] = [];
+ }
+ containersByHost[hostname].push(svc);
+ });
+
+ Object.entries(containersByHost).forEach(([hostname, containers]) => {
+ const hostCount = document.querySelector(`[data-section="host-${hostname}"] .host-count`);
+ if (hostCount) {
+ hostCount.textContent = containers.length;
+ }
+ });
+ }
+
})();
diff --git a/docs/remote-clients.md b/docs/remote-clients.md
index 7e4268e..ff17eec 100644
--- a/docs/remote-clients.md
+++ b/docs/remote-clients.md
@@ -1,300 +1,367 @@
-# Удаленные клиенты LogBoard
-
-**Автор:** Сергей Антропов
-**Сайт:** https://devops.org.ru
+# 🌐 Удаленные клиенты LogBoard+
## Обзор
-LogBoard поддерживает работу с удаленными клиентами, которые могут отправлять логи с других серверов в центральный LogBoard сервер. Это позволяет централизованно собирать и анализировать логи с множества серверов.
+LogBoard+ поддерживает сбор логов с удаленных серверов через специальные клиенты. Это позволяет централизованно мониторить логи контейнеров с нескольких машин.
## Архитектура
```
-┌─────────────────┐ HTTP/JSON ┌─────────────────┐
-│ Server A │ ──────────────► │ LogBoard │
-│ (Client) │ │ Server │
-│ │ │ │
-│ ┌─────────────┐ │ │ ┌─────────────┐ │
-│ │LogBoard │ │ │ │API │ │
-│ │Client │ │ │ │Endpoint │ │
-│ │Container │ │ │ │/logs/remote │ │
-│ └─────────────┘ │ │ └─────────────┘ │
-│ ▲ │ │ │ │
-│ │ │ │ ▼ │
-│ ┌─────────────┐ │ │ ┌─────────────┐ │
-│ │Docker │ │ │ │File │ │
-│ │Socket │ │ │ │Storage │ │
-│ └─────────────┘ │ │ └─────────────┘ │
-└─────────────────┘ └─────────────────┘
- │ ▲
- │ │
- ▼ │
-┌─────────────────┐ │
-│ Server B │ ──────────────────────────┘
-│ (Client) │
-│ │
-│ ┌─────────────┐ │
-│ │LogBoard │ │
-│ │Client │ │
-│ │Container │ │
-│ └─────────────┘ │
-│ ▲ │
-│ │ │
-│ ┌─────────────┐ │
-│ │Docker │ │
-│ │Socket │ │
-│ └─────────────┘ │
-└─────────────────┘
+┌─────────────────┐ HTTP API ┌─────────────────┐
+│ Удаленный │ ──────────────► │ LogBoard+ │
+│ сервер │ │ Сервер │
+│ │ │ │
+│ ┌─────────────┐ │ │ ┌─────────────┐ │
+│ │ Клиент │ │ │ │ Web UI │ │
+│ │ LogBoard+ │ │ │ │ │ │
+│ └─────────────┘ │ │ └─────────────┘ │
+└─────────────────┘ └─────────────────┘
```
-## Установка клиента
+## Компоненты
-### 1. Клонирование репозитория
+### 1. Серверная часть (LogBoard+ Server)
+- **API эндпоинты**: Прием логов от удаленных клиентов
+- **Хранение логов**: Сохранение в файловой системе
+- **Web интерфейс**: Отображение локальных и удаленных контейнеров
+- **Визуальное разделение**: Четкое различие между локальными и удаленными контейнерами
+
+### 2. Клиентская часть (LogBoard+ Client)
+
+- **Сбор логов**: Чтение логов Docker контейнеров
+- **Отправка данных**: HTTP POST запросы на сервер
+- **Автоматизация**: Docker Compose для простого развертывания
+
+## Установка и настройка
+
+### На сервере LogBoard+
+
+1. Убедитесь, что сервер LogBoard+ запущен и доступен
+2. Получите API ключ для аутентификации клиентов
+
+### На удаленном сервере
+
+1. Создайте директорию для клиента:
```bash
-git clone
-cd logboard/client
+mkdir logboard-client
+cd logboard-client
```
-### 2. Настройка переменных окружения
+2. Создайте `docker-compose.yml`:
+```yaml
+version: '3.8'
-```bash
-cp env.example .env
-# Отредактируйте .env файл
+services:
+ logboard-client:
+ build: .
+ container_name: logboard-client
+ environment:
+ - LOGBOARD_SERVER_URL=http://your-logboard-server:9001
+ - API_KEY=your-api-key
+ - HOSTNAME=your-server-name
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock
+ - ./logs:/app/logs
+ restart: unless-stopped
+ user: 0:0
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+
+ # Тестовый контейнер для демонстрации
+ test-nginx:
+ image: nginx:alpine
+ container_name: test-nginx
+ ports:
+ - "8080:80"
+ restart: unless-stopped
```
-### 3. Запуск клиента
+3. Создайте `Dockerfile`:
+```dockerfile
+FROM python:3.11-slim
+RUN apt-get update && apt-get install -y \
+ curl \
+ && rm -rf /var/lib/apt/lists/*
+
+RUN groupadd -r logboard && useradd -r -g logboard logboard
+
+RUN mkdir -p /app /var/log
+
+WORKDIR /app
+
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY app/ ./app/
+
+RUN mkdir -p /var/log && \
+ chown -R logboard:logboard /app /var/log
+
+USER logboard
+
+CMD ["python", "app/main.py"]
+```
+
+4. Создайте `requirements.txt`:
+```
+aiohttp==3.9.1
+docker==6.1.3
+urllib3==2.1.0
+requests==2.31.0
+```
+
+5. Создайте `app/main.py`:
+```python
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+LogBoard+ Client - Клиент для отправки логов на сервер LogBoard+
+Автор: Сергей Антропов
+Сайт: https://devops.org.ru
+"""
+
+import asyncio
+import aiohttp
+import docker
+import os
+import logging
+from datetime import datetime
+
+# Настройка логирования
+logging.basicConfig(
+ level=logging.INFO,
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
+)
+logger = logging.getLogger(__name__)
+
+class LogBoardClient:
+ def __init__(self):
+ self.server_url = os.getenv('LOGBOARD_SERVER_URL', 'http://localhost:9001')
+ self.api_key = os.getenv('API_KEY', 'default-key')
+ self.hostname = os.getenv('HOSTNAME', 'unknown')
+
+ # Инициализация Docker клиента
+ try:
+ self.docker_client = docker.from_env()
+ logger.info("Docker клиент инициализирован успешно")
+ except Exception as e:
+ logger.error(f"Ошибка инициализации Docker клиента: {e}")
+ self.docker_client = None
+
+ async def send_logs(self, container_name, logs_data):
+ """Отправка логов на сервер LogBoard+"""
+ try:
+ async with aiohttp.ClientSession() as session:
+ url = f"{self.server_url}/api/logs/remote"
+ data = {
+ 'container_name': container_name,
+ 'hostname': self.hostname,
+ 'logs': logs_data,
+ 'api_key': self.api_key
+ }
+
+ async with session.post(url, json=data) as response:
+ if response.status == 200:
+ logger.info(f"Логи контейнера {container_name} отправлены успешно")
+ else:
+ logger.error(f"Ошибка отправки логов: {response.status}")
+
+ except Exception as e:
+ logger.error(f"Ошибка при отправке логов: {e}")
+
+ def get_container_logs(self, container_name, tail=100):
+ """Получение логов контейнера"""
+ try:
+ if not self.docker_client:
+ return []
+
+ container = self.docker_client.containers.get(container_name)
+ logs = container.logs(tail=tail, timestamps=True).decode('utf-8')
+ return logs.split('\n')[:-1] # Убираем пустую строку в конце
+
+ except Exception as e:
+ logger.error(f"Ошибка получения логов контейнера {container_name}: {e}")
+ return []
+
+ async def collect_and_send_logs(self):
+ """Сбор и отправка логов всех контейнеров"""
+ try:
+ if not self.docker_client:
+ logger.error("Docker клиент недоступен")
+ return
+
+ containers = self.docker_client.containers.list()
+ logger.info(f"Найдено {len(containers)} контейнеров")
+
+ for container in containers:
+ try:
+ logs = self.get_container_logs(container.name)
+ if logs:
+ await self.send_logs(container.name, logs)
+ except Exception as e:
+ logger.error(f"Ошибка обработки контейнера {container.name}: {e}")
+
+ except Exception as e:
+ logger.error(f"Ошибка сбора логов: {e}")
+
+ async def run(self):
+ """Основной цикл работы клиента"""
+ logger.info(f"LogBoard+ Client запущен для хоста: {self.hostname}")
+ logger.info(f"Сервер: {self.server_url}")
+
+ while True:
+ try:
+ await self.collect_and_send_logs()
+ await asyncio.sleep(30) # Пауза 30 секунд
+ except Exception as e:
+ logger.error(f"Критическая ошибка: {e}")
+ await asyncio.sleep(60) # Увеличенная пауза при ошибке
+
+async def main():
+ client = LogBoardClient()
+ await client.run()
+
+if __name__ == "__main__":
+ asyncio.run(main())
+```
+
+6. Запустите клиент:
```bash
-# Используя Makefile
-make install
-
-# Или вручную
docker-compose up -d
```
-## Конфигурация
+## Использование
-### Переменные окружения клиента
+### Web интерфейс
-| Переменная | Описание | Обязательно | По умолчанию |
-|------------|----------|-------------|--------------|
-| `LOGBOARD_SERVER_URL` | URL сервера LogBoard | Да | `http://localhost:8000` |
-| `LOGBOARD_API_KEY` | API ключ для аутентификации | Да | - |
-| `HOSTNAME` | Имя хоста | Нет | Автоопределение |
-| `LOGBOARD_INTERVAL` | Интервал отправки (сек) | Нет | `60` |
+После настройки клиентов, в веб-интерфейсе LogBoard+ вы увидите:
-### Переменные окружения сервера
+#### 📍 Локальные контейнеры
+- Контейнеры с текущего сервера
+- Обычное отображение без дополнительных индикаторов
-| Переменная | Описание | Обязательно | По умолчанию |
-|------------|----------|-------------|--------------|
-| `LOGBOARD_API_KEYS` | Список разрешенных API ключей | Нет | - |
-| `LOGBOARD_DEFAULT_API_KEY` | Ключ по умолчанию для разработки | Нет | `dev-key-123` |
+#### 🌐 Удаленные контейнеры
+- Контейнеры с удаленных серверов
+- Визуальное разделение по хостам
+- Индикаторы удаленного доступа (глобус 🌐)
+- Время последнего обновления
+- Статистика по хостам
-## API Endpoints
+### Функции интерфейса
-### POST /api/v1/logs/remote
+#### 🔽 Сворачивание секций
+- **Секции контейнеров**: Можно сворачивать/разворачивать секции "Локальные контейнеры" и "Удаленные контейнеры"
+- **Секции хостов**: Каждый хост в удаленных контейнерах можно сворачивать отдельно
+- **Кнопки управления**: Стрелки для сворачивания/разворачивания
+- **Сохранение состояния**: Состояние сворачивания сохраняется между сессиями
-Прием логов от удаленных клиентов.
+#### ⚡ Периодическое обновление
+- **Автоматическое обновление**: Список контейнеров обновляется каждые 30 секунд
+- **Фильтрация остановленных**: Остановленные контейнеры автоматически скрываются из интерфейса
+- **Обновление счетчиков**: Количество контейнеров в секциях обновляется в реальном времени
+- **Логирование изменений**: В консоли браузера отображается информация об изменениях
-**Заголовки:**
-```
-Authorization: Bearer
-Content-Type: application/json
-```
+#### 📱 Адаптивный интерфейс
+- **Свернутый sidebar**: Миникарточки контейнеров с иконками статуса
+- **Развернутый sidebar**: Полная информация о контейнерах с возможностью сворачивания секций
+- **Мобильная поддержка**: Адаптивный дизайн для мобильных устройств
-**Тело запроса:**
-```json
-{
- "hostname": "server-01",
- "container_name": "nginx",
- "logs": [
- "2024-01-01T12:00:00.000Z nginx: [info] Server started",
- "2024-01-01T12:00:01.000Z nginx: [info] Listening on port 80"
- ],
- "timestamp": "2024-01-01T12:00:01.000Z"
-}
+### API эндпоинты
+
+#### Получение контейнеров
+```http
+GET /api/containers/services
+Authorization: Bearer
```
**Ответ:**
```json
-{
- "status": "success",
- "message": "Received 2 log lines",
- "hostname": "server-01",
- "container_name": "nginx",
- "timestamp": "2024-01-01T12:00:01.000Z",
- "log_file": "nginx-20240101.log"
-}
+[
+ {
+ "id": "container-id",
+ "name": "container-name",
+ "status": "running",
+ "is_remote": false,
+ "hostname": "localhost",
+ "project": "project-name",
+ "service": "service-name"
+ },
+ {
+ "id": "remote-hostname-container",
+ "name": "remote-container",
+ "status": "running",
+ "is_remote": true,
+ "hostname": "remote-host",
+ "last_modified": "2025-08-20T16:30:00",
+ "size": 1024
+ }
+]
```
-## Структура хранения логов
-
-Логи от удаленных клиентов сохраняются в следующей структуре:
-
+#### Получение логов удаленного контейнера
+```http
+GET /api/logs/{container_id}
+Authorization: Bearer
```
-logs/
-├── remote/
-│ ├── server-01/
-│ │ ├── nginx-20240101.log
-│ │ ├── mysql-20240101.log
-│ │ └── app-20240101.log
-│ └── server-02/
-│ ├── nginx-20240101.log
-│ └── redis-20240101.log
+
+#### Получение статистики логов
+```http
+GET /api/logs/stats/{container_id}
+Authorization: Bearer
+```
+
+## Мониторинг и отладка
+
+### Логи клиента
+```bash
+docker-compose logs -f logboard-client
+```
+
+### Проверка здоровья
+```bash
+curl http://localhost:8080/health
+```
+
+### Тестирование API
+```bash
+python3 test_interface.py
```
## Безопасность
-### Аутентификация
-
-- Все запросы от клиентов должны содержать валидный API ключ
-- API ключи передаются в заголовке `Authorization: Bearer `
-- Сервер проверяет ключи против списка разрешенных ключей
-
-### Настройка API ключей
-
-1. **На сервере LogBoard:**
- ```bash
- # В .env файле сервера
- LOGBOARD_API_KEYS=key1,key2,key3
- ```
-
-2. **На клиенте:**
- ```bash
- # В .env файле клиента
- LOGBOARD_API_KEY=key1
- ```
-
-### Рекомендации по безопасности
-
-- Используйте уникальные API ключи для каждого клиента
-- Регулярно ротируйте API ключи
-- Используйте HTTPS для передачи данных
-- Ограничьте доступ к серверу LogBoard по IP адресам
-
-## Мониторинг
-
-### Логи клиента
-
-```bash
-# Просмотр логов клиента
-docker-compose logs -f logboard-client
-
-# Проверка статуса
-docker-compose ps logboard-client
-```
-
-### Логи сервера
-
-```bash
-# Просмотр логов сервера
-docker-compose logs -f logboard
-
-# Проверка принятых логов
-ls -la logs/remote/
-```
+- **API ключи**: Обязательная аутентификация клиентов
+- **HTTPS**: Рекомендуется использовать HTTPS для передачи данных
+- **Сетевая изоляция**: Клиенты должны иметь доступ только к необходимым портам
## Устранение неполадок
-### Проблемы подключения
+### Клиент не подключается
+1. Проверьте URL сервера в переменной `LOGBOARD_SERVER_URL`
+2. Убедитесь, что API ключ правильный
+3. Проверьте сетевое подключение
-1. **Ошибка аутентификации (401):**
- - Проверьте правильность API ключа
- - Убедитесь, что ключ добавлен в `LOGBOARD_API_KEYS` на сервере
+### Логи не отображаются
+1. Проверьте права доступа к Docker socket
+2. Убедитесь, что контейнеры запущены
+3. Проверьте логи клиента
-2. **Ошибка подключения к серверу:**
- - Проверьте URL сервера в `LOGBOARD_SERVER_URL`
- - Убедитесь, что сервер доступен по сети
- - Проверьте настройки firewall
+### Интерфейс не обновляется
+1. Откройте консоль браузера (F12)
+2. Проверьте наличие ошибок JavaScript
+3. Убедитесь, что WebSocket соединения работают
-3. **Ошибка доступа к Docker:**
- - Убедитесь, что Docker socket доступен
- - Проверьте права доступа к `/var/run/docker.sock`
+## Автор
-### Отладка
+**Сергей Антропов**
+🌐 Сайт: https://devops.org.ru
+📧 Email: contact@devops.org.ru
-```bash
-# Тестирование подключения
-cd client
-python test_client.py
+---
-# Проверка переменных окружения
-docker-compose exec logboard-client env
-
-# Просмотр логов в реальном времени
-docker-compose logs -f logboard-client
-```
-
-## Примеры использования
-
-### Множественные серверы
-
-```yaml
-# docker-compose.yml на сервере A
-services:
- logboard-client:
- environment:
- - LOGBOARD_SERVER_URL=http://logboard.example.com:8000
- - LOGBOARD_API_KEY=server-a-key
- - HOSTNAME=production-server-a
-```
-
-```yaml
-# docker-compose.yml на сервере B
-services:
- logboard-client:
- environment:
- - LOGBOARD_SERVER_URL=http://logboard.example.com:8000
- - LOGBOARD_API_KEY=server-b-key
- - HOSTNAME=production-server-b
-```
-
-### Настройка на центральном сервере
-
-```bash
-# .env на сервере LogBoard
-LOGBOARD_API_KEYS=server-a-key,server-b-key,server-c-key
-```
-
-## Производительность
-
-### Рекомендации
-
-- Установите разумный интервал отправки логов (30-60 секунд)
-- Используйте фильтрацию логов на стороне клиента
-- Мониторьте размер логовых файлов
-- Настройте ротацию логов
-
-### Ограничения
-
-- Максимальный размер запроса: 10MB
-- Таймаут запроса: 30 секунд
-- Максимальное количество строк в одном запросе: 1000
-
-## Разработка
-
-### Локальная разработка
-
-```bash
-# Запуск в режиме разработки
-cd client
-make dev
-
-# Тестирование
-python test_client.py
-```
-
-### Добавление новых функций
-
-1. Расширьте API эндпоинты в `app/api/v1/endpoints/logs.py`
-2. Обновите клиент в `client/app/main.py`
-3. Добавьте тесты в `client/test_client.py`
-4. Обновите документацию
-
-## Поддержка
-
-- **Автор:** Сергей Антропов
-- **Сайт:** https://devops.org.ru
-- **Issues:** Создавайте issues в репозитории проекта
+*Документация обновлена: 2025-08-20*
diff --git a/test_collapse.py b/test_collapse.py
new file mode 100644
index 0000000..ea51331
--- /dev/null
+++ b/test_collapse.py
@@ -0,0 +1,106 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+Тестовый скрипт для проверки сворачивания секций LogBoard+
+Автор: Сергей Антропов
+Сайт: https://devops.org.ru
+"""
+
+import requests
+import json
+
+def test_collapse_functionality():
+ """Тестирование функциональности сворачивания секций"""
+
+ base_url = "http://localhost:9001"
+
+ # 1. Вход в систему
+ print("🔐 Вход в систему...")
+ login_data = {"username": "admin", "password": "admin"}
+ response = requests.post(f"{base_url}/api/auth/login", json=login_data)
+
+ if response.status_code != 200:
+ print(f"❌ Ошибка входа: {response.status_code}")
+ return
+
+ token = response.json()["access_token"]
+ headers = {"Authorization": f"Bearer {token}"}
+
+ print("✅ Вход выполнен успешно")
+
+ # 2. Получение контейнеров
+ print("\n🐳 Получение контейнеров...")
+ response = requests.get(f"{base_url}/api/containers/services", headers=headers)
+
+ if response.status_code != 200:
+ print(f"❌ Ошибка получения контейнеров: {response.status_code}")
+ return
+
+ containers = response.json()
+ print(f"✅ Контейнеров получено: {len(containers)}")
+
+ # Анализируем контейнеры
+ local_containers = [c for c in containers if not c.get('is_remote', False)]
+ remote_containers = [c for c in containers if c.get('is_remote', False)]
+
+ print(f"\n📊 Статистика контейнеров:")
+ print(f" 📍 Локальные контейнеры: {len(local_containers)}")
+ for container in local_containers:
+ print(f" • {container['name']} ({container['status']})")
+
+ print(f"\n 🌐 Удаленные контейнеры: {len(remote_containers)}")
+
+ # Группируем удаленные контейнеры по хостам
+ containers_by_host = {}
+ for container in remote_containers:
+ hostname = container.get('hostname', 'unknown')
+ if hostname not in containers_by_host:
+ containers_by_host[hostname] = []
+ containers_by_host[hostname].append(container)
+
+ for hostname, host_containers in containers_by_host.items():
+ print(f" 🖥️ Хост: {hostname} ({len(host_containers)} контейнеров)")
+ for container in host_containers:
+ print(f" • {container['name']} ({container['status']})")
+
+ # 3. Проверяем, что все контейнеры работающие
+ stopped_containers = [c for c in containers if c['status'] != 'running']
+ if stopped_containers:
+ print(f"\n⚠️ Найдены остановленные контейнеры:")
+ for container in stopped_containers:
+ print(f" • {container['name']} ({container['status']})")
+ else:
+ print(f"\n✅ Все контейнеры работают (остановленные отфильтрованы)")
+
+ # 4. Проверяем структуру данных для сворачивания
+ print(f"\n🔍 Проверка структуры данных для сворачивания:")
+
+ # Проверяем локальные контейнеры
+ if local_containers:
+ print(f" 📍 Секция 'Локальные контейнеры' должна содержать {len(local_containers)} контейнеров")
+ print(f" ID секции: local")
+ print(f" Кнопка сворачивания: data-target='local'")
+ print(f" Контент: id='local-content'")
+
+ # Проверяем удаленные контейнеры
+ if remote_containers:
+ print(f" 🌐 Секция 'Удаленные контейнеры' должна содержать {len(remote_containers)} контейнеров")
+ print(f" ID секции: remote")
+ print(f" Кнопка сворачивания: data-target='remote'")
+ print(f" Контент: id='remote-content'")
+
+ # Проверяем секции хостов
+ for hostname, host_containers in containers_by_host.items():
+ print(f" 🖥️ Подсекция '{hostname}' должна содержать {len(host_containers)} контейнеров")
+ print(f" ID секции: host-{hostname}")
+ print(f" Кнопка сворачивания: data-target='host-{hostname}'")
+ print(f" Контент: id='host-{hostname}-content'")
+
+ print(f"\n🎉 Проверка структуры завершена!")
+ print(f"🌐 Откройте http://localhost:9001 в браузере")
+ print(f" 📍 Проверьте, что секции 'Локальные контейнеры' и 'Удаленные контейнеры' можно сворачивать")
+ print(f" 🖥️ Проверьте, что секции хостов внутри 'Удаленные контейнеры' можно сворачивать")
+ print(f" ⚡ Проверьте, что остановленные контейнеры не отображаются в интерфейсе")
+
+if __name__ == "__main__":
+ test_collapse_functionality()
diff --git a/test_container_update.py b/test_container_update.py
new file mode 100644
index 0000000..01b73fb
--- /dev/null
+++ b/test_container_update.py
@@ -0,0 +1,129 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+Тестовый скрипт для проверки периодического обновления контейнеров LogBoard+
+Автор: Сергей Антропов
+Сайт: https://devops.org.ru
+"""
+
+import requests
+import json
+import time
+from datetime import datetime
+
+def test_container_update():
+ """Тестирование периодического обновления контейнеров"""
+
+ base_url = "http://localhost:9001"
+
+ # 1. Вход в систему
+ print("🔐 Вход в систему...")
+ login_data = {"username": "admin", "password": "admin"}
+ response = requests.post(f"{base_url}/api/auth/login", json=login_data)
+
+ if response.status_code != 200:
+ print(f"❌ Ошибка входа: {response.status_code}")
+ return
+
+ token = response.json()["access_token"]
+ headers = {"Authorization": f"Bearer {token}"}
+
+ print("✅ Вход выполнен успешно")
+
+ # 2. Получение контейнеров
+ print("\n🐳 Получение контейнеров...")
+ response = requests.get(f"{base_url}/api/containers/services", headers=headers)
+
+ if response.status_code != 200:
+ print(f"❌ Ошибка получения контейнеров: {response.status_code}")
+ return
+
+ containers = response.json()
+ print(f"✅ Контейнеров получено: {len(containers)}")
+
+ # Анализируем контейнеры
+ local_containers = [c for c in containers if not c.get('is_remote', False)]
+ remote_containers = [c for c in containers if c.get('is_remote', False)]
+
+ print(f"\n📊 Статистика контейнеров:")
+ print(f" 📍 Локальные контейнеры: {len(local_containers)}")
+ for container in local_containers:
+ print(f" • {container['name']} ({container['status']})")
+
+ print(f"\n 🌐 Удаленные контейнеры: {len(remote_containers)}")
+
+ # Группируем удаленные контейнеры по хостам
+ containers_by_host = {}
+ for container in remote_containers:
+ hostname = container.get('hostname', 'unknown')
+ if hostname not in containers_by_host:
+ containers_by_host[hostname] = []
+ containers_by_host[hostname].append(container)
+
+ for hostname, host_containers in containers_by_host.items():
+ print(f" 🖥️ Хост: {hostname} ({len(host_containers)} контейнеров)")
+ for container in host_containers:
+ print(f" • {container['name']} ({container['status']})")
+
+ # 3. Тестирование периодического обновления
+ print(f"\n⏰ Тестирование периодического обновления...")
+ print(f" Будем проверять каждые 10 секунд в течение 1 минуты")
+
+ initial_count = len(containers)
+ check_count = 0
+
+ for i in range(6): # 6 проверок по 10 секунд = 1 минута
+ time.sleep(10)
+ check_count += 1
+
+ print(f"\n 🔄 Проверка #{check_count} ({datetime.now().strftime('%H:%M:%S')})")
+
+ try:
+ response = requests.get(f"{base_url}/api/containers/services", headers=headers)
+ if response.status_code == 200:
+ current_containers = response.json()
+ current_count = len(current_containers)
+
+ print(f" Контейнеров сейчас: {current_count}")
+
+ if current_count != initial_count:
+ print(f" ⚠️ Количество контейнеров изменилось!")
+ print(f" Было: {initial_count}, Стало: {current_count}")
+
+ # Анализируем изменения
+ current_local = [c for c in current_containers if not c.get('is_remote', False)]
+ current_remote = [c for c in current_containers if c.get('is_remote', False)]
+
+ print(f" 📍 Локальных: {len(current_local)}")
+ print(f" 🌐 Удаленных: {len(current_remote)}")
+
+ # Проверяем, какие контейнеры исчезли
+ initial_ids = {c['id'] for c in containers}
+ current_ids = {c['id'] for c in current_containers}
+ disappeared = initial_ids - current_ids
+
+ if disappeared:
+ print(f" ❌ Исчезли контейнеры: {list(disappeared)}")
+
+ # Проверяем, какие контейнеры появились
+ appeared = current_ids - initial_ids
+ if appeared:
+ print(f" ✅ Появились контейнеры: {list(appeared)}")
+
+ # Обновляем начальное состояние
+ containers = current_containers
+ initial_count = current_count
+ else:
+ print(f" ✅ Количество контейнеров не изменилось")
+
+ else:
+ print(f" ❌ Ошибка получения контейнеров: {response.status_code}")
+
+ except Exception as e:
+ print(f" ❌ Ошибка при проверке: {e}")
+
+ print(f"\n🎉 Тестирование периодического обновления завершено!")
+ print(f"🌐 Откройте http://localhost:9001 в браузере для просмотра интерфейса")
+
+if __name__ == "__main__":
+ test_container_update()
diff --git a/test_interface.py b/test_interface.py
new file mode 100644
index 0000000..74d679b
--- /dev/null
+++ b/test_interface.py
@@ -0,0 +1,147 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+Тестовый скрипт для проверки нового интерфейса LogBoard+
+Автор: Сергей Антропов
+Сайт: https://devops.org.ru
+"""
+
+import requests
+import json
+from datetime import datetime
+
+def test_interface():
+ """Тестирование нового интерфейса с разделением локальных и удаленных контейнеров"""
+
+ base_url = "http://localhost:9001"
+
+ # 1. Вход в систему
+ print("🔐 Вход в систему...")
+ login_data = {"username": "admin", "password": "admin"}
+ response = requests.post(f"{base_url}/api/auth/login", json=login_data)
+
+ if response.status_code != 200:
+ print(f"❌ Ошибка входа: {response.status_code}")
+ return
+
+ token = response.json()["access_token"]
+ headers = {"Authorization": f"Bearer {token}"}
+
+ print("✅ Вход выполнен успешно")
+
+ # 2. Получение проектов
+ print("\n📋 Получение проектов...")
+ response = requests.get(f"{base_url}/api/containers/projects", headers=headers)
+
+ if response.status_code != 200:
+ print(f"❌ Ошибка получения проектов: {response.status_code}")
+ return
+
+ projects = response.json()
+ print(f"✅ Проектов получено: {len(projects)}")
+
+ # Показываем удаленные проекты
+ remote_projects = [p for p in projects if p.startswith('remote-')]
+ local_projects = [p for p in projects if not p.startswith('remote-')]
+
+ print(f" 📍 Локальные проекты: {len(local_projects)}")
+ for project in local_projects[:5]: # Показываем первые 5
+ print(f" • {project}")
+ if len(local_projects) > 5:
+ print(f" ... и еще {len(local_projects) - 5}")
+
+ print(f" 🌐 Удаленные проекты: {len(remote_projects)}")
+ for project in remote_projects:
+ hostname = project.replace('remote-', '')
+ print(f" • {project} (хост: {hostname})")
+
+ # 3. Получение контейнеров
+ print("\n🐳 Получение контейнеров...")
+ response = requests.get(f"{base_url}/api/containers/services", headers=headers)
+
+ if response.status_code != 200:
+ print(f"❌ Ошибка получения контейнеров: {response.status_code}")
+ return
+
+ containers = response.json()
+ print(f"✅ Контейнеров получено: {len(containers)}")
+
+ # Анализируем контейнеры
+ local_containers = [c for c in containers if not c.get('is_remote', False)]
+ remote_containers = [c for c in containers if c.get('is_remote', False)]
+
+ print(f"\n📊 Статистика контейнеров:")
+ print(f" 📍 Локальные контейнеры: {len(local_containers)}")
+ for container in local_containers[:3]: # Показываем первые 3
+ print(f" • {container['name']} ({container['status']}) - {container.get('project', 'standalone')}")
+ if len(local_containers) > 3:
+ print(f" ... и еще {len(local_containers) - 3}")
+
+ print(f"\n 🌐 Удаленные контейнеры: {len(remote_containers)}")
+
+ # Группируем удаленные контейнеры по хостам
+ containers_by_host = {}
+ for container in remote_containers:
+ hostname = container.get('hostname', 'unknown')
+ if hostname not in containers_by_host:
+ containers_by_host[hostname] = []
+ containers_by_host[hostname].append(container)
+
+ for hostname, host_containers in containers_by_host.items():
+ print(f" 🖥️ Хост: {hostname} ({len(host_containers)} контейнеров)")
+ for container in host_containers[:2]: # Показываем первые 2 с каждого хоста
+ last_update = ""
+ if container.get('last_modified'):
+ last_update = f" (обновлено: {datetime.fromtimestamp(container['last_modified']).strftime('%H:%M:%S')})"
+ print(f" • {container['name']} ({container['status']}){last_update}")
+ if len(host_containers) > 2:
+ print(f" ... и еще {len(host_containers) - 2}")
+
+ # 4. Тестирование получения логов удаленного контейнера
+ if remote_containers:
+ print(f"\n📝 Тестирование получения логов удаленного контейнера...")
+ test_container = remote_containers[0]
+ container_id = test_container['id']
+
+ response = requests.get(f"{base_url}/api/logs/{container_id}?tail=3", headers=headers)
+
+ if response.status_code == 200:
+ logs_data = response.json()
+ print(f"✅ Логи получены для {test_container['name']} (хост: {test_container['hostname']})")
+ print(f" Количество строк: {len(logs_data.get('logs', []))}")
+ print(f" Флаг is_remote: {logs_data.get('is_remote', False)}")
+
+ # Показываем последние логи
+ logs = logs_data.get('logs', [])
+ if logs:
+ print(f" Последние логи:")
+ for log in logs[-2:]: # Показываем последние 2 строки
+ timestamp = log.get('timestamp', '')[:19] # Обрезаем до секунд
+ message = log.get('message', '')[:80] # Обрезаем длинные сообщения
+ print(f" [{timestamp}] {message}")
+ else:
+ print(f"❌ Ошибка получения логов: {response.status_code}")
+
+ # 5. Тестирование статистики логов
+ if remote_containers:
+ print(f"\n📊 Тестирование статистики логов...")
+ test_container = remote_containers[0]
+ container_id = test_container['id']
+
+ response = requests.get(f"{base_url}/api/logs/stats/{container_id}", headers=headers)
+
+ if response.status_code == 200:
+ stats = response.json()
+ print(f"✅ Статистика получена для {test_container['name']}:")
+ print(f" DEBUG: {stats.get('debug', 0)}")
+ print(f" INFO: {stats.get('info', 0)}")
+ print(f" WARN: {stats.get('warn', 0)}")
+ print(f" ERROR: {stats.get('error', 0)}")
+ else:
+ print(f"❌ Ошибка получения статистики: {response.status_code}")
+
+ print(f"\n🎉 Тестирование завершено!")
+ print(f"🌐 Откройте http://localhost:9001 в браузере для просмотра нового интерфейса")
+
+if __name__ == "__main__":
+ test_interface()