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

Автор: Сергей Антропов
Сайт: https://devops.org.ru
2025-08-20 20:06:33 +03:00

450 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
LogBoard+ - Логи API
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import re
import json
import os
from datetime import datetime
from typing import Optional, List, Dict
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, 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):
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
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,
headers={
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
"Expires": "0"
}
)
except Exception as e:
api_logger.error(f"Error getting log stats for {container_id}: {e}")
return JSONResponse({"error": str(e)}, status_code=500)
@router.get("/{container_id}")
def api_logs(
container_id: str,
tail: str = Query(str(DEFAULT_TAIL), description="Количество последних строк логов или 'all' для всех логов"),
since: Optional[str] = Query(None, description="Время начала в формате ISO или относительное время (например, '10m', '1h')"),
current_user: str = Depends(get_current_user)
):
"""
Получить логи контейнера через AJAX (локального или удаленного)
Args:
container_id: ID контейнера (локального или удаленного в формате 'remote-hostname-container')
tail: Количество последних строк или 'all' для всех логов (по умолчанию 500)
since: Время начала для фильтрации логов
Returns:
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):
container = c
break
if container is None:
return JSONResponse({"error": "Container not found"}, status_code=404)
# Формируем параметры для получения логов
log_params = {
'timestamps': True
}
# Обрабатываем параметр tail
if tail.lower() == 'all':
# Для всех логов не указываем параметр tail
pass
else:
try:
tail_lines = int(tail)
log_params['tail'] = tail_lines
except ValueError:
# Если не удалось преобразовать в число, используем значение по умолчанию
log_params['tail'] = DEFAULT_TAIL
# Добавляем фильтр по времени, если указан (используем Unix timestamp секундной точности)
if since:
def _parse_since(value: str) -> Optional[int]:
try:
# Числовое значение (unix timestamp)
if re.fullmatch(r"\d+", value or ""):
return int(value)
# ISO 8601 с Z
if value.endswith('Z'):
dt = datetime.fromisoformat(value.replace('Z', '+00:00'))
return int(dt.timestamp())
# Пытаемся распарсить как ISO без Z
try:
dt2 = datetime.fromisoformat(value)
if dt2.tzinfo is None:
# Считаем UTC, если таймзона не указана
from datetime import timezone
dt2 = dt2.replace(tzinfo=timezone.utc)
return int(dt2.timestamp())
except Exception:
pass
except Exception:
return None
return None
parsed_since = _parse_since(since)
if parsed_since is not None:
log_params['since'] = parsed_since
# Получаем логи
logs = container.logs(**log_params).decode(errors="ignore")
# Разбиваем на строки и обрабатываем
log_lines = []
for line in logs.split('\n'):
if line.strip():
# Извлекаем временную метку и сообщение
timestamp_match = re.match(r'^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)\s+(.+)$', line)
if timestamp_match:
timestamp, message = timestamp_match.groups()
log_lines.append({
'timestamp': timestamp,
'message': message,
'raw': line
})
else:
# Если временная метка не найдена, используем всю строку как сообщение
log_lines.append({
'timestamp': None,
'message': line,
'raw': line
})
# Получаем информацию о контейнере
container_info = {
'id': container.id,
'name': container.name,
'status': container.status,
'image': container.image.tags[0] if container.image.tags else container.image.id,
'created': container.attrs['Created'],
'state': container.attrs['State']
}
return JSONResponse(
content={
'container': container_info,
'logs': log_lines,
'total_lines': len(log_lines),
'tail': tail,
'since': since,
'timestamp': datetime.now().isoformat()
},
headers={
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
"Expires": "0"
}
)
except Exception as e:
api_logger.error(f"Error getting logs for {container_id}: {e}")
return JSONResponse({"error": str(e)}, status_code=500)
@router.post("/snapshot")
def api_snapshot(
current_user: str = Depends(get_current_user),
container_id: str = Body(..., embed=True),
service: str = Body("", embed=True),
content: str = Body("", embed=True),
):
"""Сохранить снимок логов"""
import os
from core.config import SNAP_DIR
# Save posted content as a snapshot file
safe_service = re.sub(r"[^a-zA-Z0-9_.-]+", "_", service or container_id[:12])
ts = os.getenv("TZ_TS") or ""
from datetime import datetime
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
fname = f"{safe_service}-{stamp}.log"
fpath = os.path.join(SNAP_DIR, fname)
with open(fpath, "w", encoding="utf-8") as f:
f.write(content)
url = f"/snapshots/{fname}"
return {"file": fname, "url": url}
@router.post("/remote")
async def api_remote_logs(
request_data: Dict = Body(...),
authorization: str = Header(None)
):
"""
Прием логов от удаленных клиентов
Args:
request_data: Данные запроса с логами
authorization: Заголовок авторизации
Returns:
JSON с результатом обработки
"""
try:
# Проверяем авторизацию
if not authorization or not authorization.startswith('Bearer '):
api_logger.warning("Unauthorized remote log request - missing or invalid authorization header")
raise HTTPException(status_code=401, detail="Unauthorized")
api_key = authorization.replace('Bearer ', '')
if not verify_api_key(api_key):
api_logger.warning("Unauthorized remote log request - invalid API key")
raise HTTPException(status_code=401, detail="Invalid API key")
# Извлекаем данные из запроса
hostname = request_data.get('hostname')
container_name = request_data.get('container_name')
logs = request_data.get('logs', [])
timestamp = request_data.get('timestamp')
if not all([hostname, container_name, logs]):
api_logger.error("Invalid remote log request - missing required fields")
raise HTTPException(status_code=400, detail="Missing required fields")
# Создаем директорию для удаленных логов, если не существует
remote_logs_dir = os.path.join(os.getcwd(), 'logs', 'remote', hostname)
os.makedirs(remote_logs_dir, exist_ok=True)
# Формируем имя файла для логов
safe_container_name = re.sub(r"[^a-zA-Z0-9_.-]+", "_", container_name)
log_filename = f"{safe_container_name}-{datetime.now().strftime('%Y%m%d')}.log"
log_filepath = os.path.join(remote_logs_dir, log_filename)
# Записываем логи в файл
with open(log_filepath, 'a', encoding='utf-8') as f:
for log_line in logs:
f.write(f"{log_line}\n")
# Логируем информацию о полученных логах
api_logger.info(
f"Received {len(logs)} log lines from host '{hostname}' "
f"container '{container_name}' at {timestamp}"
)
return JSONResponse(
content={
"status": "success",
"message": f"Received {len(logs)} log lines",
"hostname": hostname,
"container_name": container_name,
"timestamp": timestamp,
"log_file": log_filename
},
status_code=200
)
except HTTPException:
raise
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