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 = ` +
+
+ + Локальные контейнеры + ${localContainers.length} +
+
+
`; + 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 = ` +
+
+ + Удаленные контейнеры + ${remoteContainers.length} +
+ +
+
+ `; + 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 = ` +
+
+ + ${escapeHtml(hostname)} + ${containers.length} +
+ +
+
+ `; + 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()