feat: добавлено сворачивание секций и периодическое обновление контейнеров

- Добавлена функция сворачивания/разворачивания секций локальных и удаленных контейнеров
- Реализовано периодическое обновление списка контейнеров каждые 30 секунд
- Добавлена автоматическая фильтрация остановленных контейнеров
- Обновлены обработчики событий для корректной работы в свернутом sidebar
- Добавлены функции обновления счетчиков контейнеров
- Обновлена документация с описанием новых функций
- Добавлены тестовые скрипты для проверки функциональности

Автор: Сергей Антропов
Сайт: https://devops.org.ru
This commit is contained in:
Сергей Антропов 2025-08-20 20:06:33 +03:00
parent 04dfe30d58
commit 011d460a38
12 changed files with 1791 additions and 379 deletions

72
CHANGELOG.md Normal file
View File

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

View File

@ -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 секунд
- **🚫 Фильтрация остановленных** - Остановленные контейнеры автоматически скрываются из интерфейса
### Архитектура

View File

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

View File

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

View File

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

View File

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

View File

@ -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);
}

View File

@ -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 = `
<div class="container-name">
<i class="fas fa-cube"></i>
${escapeHtml(svc.name)}
</div>
<div class="container-service">
${escapeHtml(svc.service || svc.name)}
${escapeHtml(svc.project || 'standalone')}
</div>
<div class="container-status">
<span class="status-indicator ${statusClass}"></span>
${escapeHtml(svc.status)}
${svc.status === 'running' && svc.host_port ? ` on ${svc.host_port} port` : ''}
${svc.url ? `<a href="${svc.url}" target="_blank" class="container-link" title="Открыть сайт"><i class="fas fa-external-link-alt"></i></a>` : ''}
</div>
<div class="container-select">
<input type="checkbox" id="select-${svc.id}" class="container-checkbox" data-container-id="${svc.id}" ${state.selectedContainers.includes(svc.id) ? 'checked' : ''}>
<label for="select-${svc.id}" class="container-checkbox-label" title="Выбрать для мультипросмотра"></label>
// Группируем контейнеры по типу (локальные и удаленные)
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 = `
<div class="section-header collapsible-section" data-section="local">
<div class="section-header-left">
<i class="fas fa-server"></i>
<span>Локальные контейнеры</span>
<span class="section-count">${localContainers.length}</span>
</div>
<button class="section-toggle-btn" data-target="local" title="Свернуть/развернуть секцию">
<i class="fas fa-chevron-down"></i>
</button>
</div>
<div class="section-content" id="local-content">
`;
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 = `
<div class="container-name">
<i class="fas fa-cube"></i>
${escapeHtml(svc.name)}
</div>
<div class="container-service">
${escapeHtml(svc.service || svc.name)}
${escapeHtml(svc.project || 'standalone')}
</div>
<div class="container-status">
<span class="status-indicator ${statusClass}"></span>
${escapeHtml(svc.status)}
${svc.status === 'running' && svc.host_port ? ` on ${svc.host_port} port` : ''}
${svc.url ? `<a href="${svc.url}" target="_blank" class="container-link" title="Открыть сайт"><i class="fas fa-external-link-alt"></i></a>` : ''}
</div>
<div class="container-select">
<input type="checkbox" id="select-${svc.id}" class="container-checkbox" data-container-id="${svc.id}" ${state.selectedContainers.includes(svc.id) ? 'checked' : ''}>
<label for="select-${svc.id}" class="container-checkbox-label" title="Выбрать для мультипросмотра"></label>
</div>
`;
// Устанавливаем состояние 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 = `
<div class="section-header collapsible-section" data-section="remote">
<div class="section-header-left">
<i class="fas fa-globe"></i>
<span>Удаленные контейнеры</span>
<span class="section-count">${remoteContainers.length}</span>
</div>
<button class="section-toggle-btn" data-target="remote" title="Свернуть/развернуть секцию">
<i class="fas fa-chevron-down"></i>
</button>
</div>
<div class="section-content" id="remote-content">
`;
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 = `
<div class="host-header collapsible-section" data-section="host-${hostname}">
<div class="host-header-left">
<i class="fas fa-server"></i>
<span class="host-name">${escapeHtml(hostname)}</span>
<span class="host-count">${containers.length}</span>
</div>
<button class="section-toggle-btn" data-target="host-${hostname}" title="Свернуть/развернуть секцию">
<i class="fas fa-chevron-down"></i>
</button>
</div>
<div class="section-content" id="host-${hostname}-content">
`;
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 = `
<div class="container-name">
<i class="fas fa-cube"></i>
${escapeHtml(svc.name)}
<span class="remote-badge" title="Удаленный контейнер">
<i class="fas fa-globe"></i>
</span>
</div>
<div class="container-service">
${escapeHtml(svc.service || svc.name)}
${escapeHtml(svc.project || 'remote')}
<span class="host-info"> ${escapeHtml(svc.hostname)}</span>
</div>
<div class="container-status">
<span class="status-indicator ${statusClass}"></span>
${escapeHtml(svc.status)}
${svc.last_modified ? `<span class="last-update">• Обновлено: ${new Date(svc.last_modified * 1000).toLocaleString()}</span>` : ''}
</div>
<div class="container-select">
<input type="checkbox" id="select-${svc.id}" class="container-checkbox" data-container-id="${svc.id}" ${state.selectedContainers.includes(svc.id) ? 'checked' : ''}>
<label for="select-${svc.id}" class="container-checkbox-label" title="Выбрать для мультипросмотра"></label>
</div>
`;
// Устанавливаем состояние 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 = `
<div class="mini-container-icon">
<i class="fas fa-cube"></i>
${svc.is_remote ? '<i class="fas fa-globe remote-icon"></i>' : ''}
</div>
<div class="mini-container-name">${escapeHtml(shortName)}</div>
<div class="mini-container-status ${statusClass}"></div>
@ -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;
}
});
}
})();

View File

@ -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 <repository-url>
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 <api_key>
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 <token>
```
**Ответ:**
```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 <token>
```
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 <token>
```
## Мониторинг и отладка
### Логи клиента
```bash
docker-compose logs -f logboard-client
```
### Проверка здоровья
```bash
curl http://localhost:8080/health
```
### Тестирование API
```bash
python3 test_interface.py
```
## Безопасность
### Аутентификация
- Все запросы от клиентов должны содержать валидный API ключ
- API ключи передаются в заголовке `Authorization: Bearer <key>`
- Сервер проверяет ключи против списка разрешенных ключей
### Настройка 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*

106
test_collapse.py Normal file
View File

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

129
test_container_update.py Normal file
View File

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

147
test_interface.py Normal file
View File

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