feat: добавлено сворачивание секций и периодическое обновление контейнеров
- Добавлена функция сворачивания/разворачивания секций локальных и удаленных контейнеров - Реализовано периодическое обновление списка контейнеров каждые 30 секунд - Добавлена автоматическая фильтрация остановленных контейнеров - Обновлены обработчики событий для корректной работы в свернутом sidebar - Добавлены функции обновления счетчиков контейнеров - Обновлена документация с описанием новых функций - Добавлены тестовые скрипты для проверки функциональности Автор: Сергей Антропов Сайт: https://devops.org.ru
This commit is contained in:
parent
04dfe30d58
commit
011d460a38
72
CHANGELOG.md
Normal file
72
CHANGELOG.md
Normal 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
|
21
README.md
21
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 секунд
|
||||
- **🚫 Фильтрация остановленных** - Остановленные контейнеры автоматически скрываются из интерфейса
|
||||
|
||||
### Архитектура
|
||||
|
||||
|
@ -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",
|
||||
|
@ -16,18 +16,25 @@ 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:
|
||||
# Ищем контейнер
|
||||
# Проверяем, является ли это удаленным контейнером
|
||||
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):
|
||||
@ -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,7 +96,49 @@ def api_logs(
|
||||
JSON с логами и метаданными
|
||||
"""
|
||||
try:
|
||||
# Ищем контейнер
|
||||
# Проверяем, является ли это удаленным контейнером
|
||||
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):
|
||||
@ -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
|
||||
|
@ -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,7 +74,34 @@ async def ws_logs(ws: WebSocket, container_id: str, tail: int = DEFAULT_TAIL, to
|
||||
return
|
||||
|
||||
try:
|
||||
# Простой поиск контейнера по ID
|
||||
# Проверяем, является ли это удаленным контейнером
|
||||
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):
|
||||
@ -108,16 +135,16 @@ async def ws_logs(ws: WebSocket, container_id: str, tail: int = DEFAULT_TAIL, to
|
||||
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
|
||||
|
@ -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
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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,10 +843,32 @@ function buildTabs(){
|
||||
miniContainerList.innerHTML = '';
|
||||
}
|
||||
|
||||
state.services.forEach(svc => {
|
||||
// Создаем обычную карточку контейнера
|
||||
// Группируем контейнеры по типу (локальные и удаленные)
|
||||
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);
|
||||
|
||||
localContainers.forEach(svc => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'container-item';
|
||||
item.className = 'container-item local-container';
|
||||
if (state.current && svc.id === state.current.id) {
|
||||
item.classList.add('active');
|
||||
}
|
||||
@ -878,22 +912,152 @@ function buildTabs(){
|
||||
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';
|
||||
}
|
||||
|
||||
// Закрываем секцию локальных контейнеров
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
})();
|
||||
|
@ -1,300 +1,367 @@
|
||||
# Удаленные клиенты LogBoard
|
||||
|
||||
**Автор:** Сергей Антропов
|
||||
**Сайт:** https://devops.org.ru
|
||||
# 🌐 Удаленные клиенты LogBoard+
|
||||
|
||||
## Обзор
|
||||
|
||||
LogBoard поддерживает работу с удаленными клиентами, которые могут отправлять логи с других серверов в центральный LogBoard сервер. Это позволяет централизованно собирать и анализировать логи с множества серверов.
|
||||
LogBoard+ поддерживает сбор логов с удаленных серверов через специальные клиенты. Это позволяет централизованно мониторить логи контейнеров с нескольких машин.
|
||||
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
┌─────────────────┐ HTTP/JSON ┌─────────────────┐
|
||||
│ Server A │ ──────────────► │ LogBoard │
|
||||
│ (Client) │ │ Server │
|
||||
┌─────────────────┐ HTTP API ┌─────────────────┐
|
||||
│ Удаленный │ ──────────────► │ LogBoard+ │
|
||||
│ сервер │ │ Сервер │
|
||||
│ │ │ │
|
||||
│ ┌─────────────┐ │ │ ┌─────────────┐ │
|
||||
│ │LogBoard │ │ │ │API │ │
|
||||
│ │Client │ │ │ │Endpoint │ │
|
||||
│ │Container │ │ │ │/logs/remote │ │
|
||||
│ └─────────────┘ │ │ └─────────────┘ │
|
||||
│ ▲ │ │ │ │
|
||||
│ │ │ │ ▼ │
|
||||
│ ┌─────────────┐ │ │ ┌─────────────┐ │
|
||||
│ │Docker │ │ │ │File │ │
|
||||
│ │Socket │ │ │ │Storage │ │
|
||||
│ │ Клиент │ │ │ │ Web UI │ │
|
||||
│ │ LogBoard+ │ │ │ │ │ │
|
||||
│ └─────────────┘ │ │ └─────────────┘ │
|
||||
└─────────────────┘ └─────────────────┘
|
||||
│ ▲
|
||||
│ │
|
||||
▼ │
|
||||
┌─────────────────┐ │
|
||||
│ Server B │ ──────────────────────────┘
|
||||
│ (Client) │
|
||||
│ │
|
||||
│ ┌─────────────┐ │
|
||||
│ │LogBoard │ │
|
||||
│ │Client │ │
|
||||
│ │Container │ │
|
||||
│ └─────────────┘ │
|
||||
│ ▲ │
|
||||
│ │ │
|
||||
│ ┌─────────────┐ │
|
||||
│ │Docker │ │
|
||||
│ │Socket │ │
|
||||
│ └─────────────┘ │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
## Установка клиента
|
||||
## Компоненты
|
||||
|
||||
### 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
106
test_collapse.py
Normal 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
129
test_container_update.py
Normal 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
147
test_interface.py
Normal 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()
|
Loading…
x
Reference in New Issue
Block a user