- Добавлена функция сворачивания/разворачивания секций локальных и удаленных контейнеров - Реализовано периодическое обновление списка контейнеров каждые 30 секунд - Добавлена автоматическая фильтрация остановленных контейнеров - Обновлены обработчики событий для корректной работы в свернутом sidebar - Добавлены функции обновления счетчиков контейнеров - Обновлена документация с описанием новых функций - Добавлены тестовые скрипты для проверки функциональности Автор: Сергей Антропов Сайт: https://devops.org.ru
450 lines
18 KiB
Python
450 lines
18 KiB
Python
#!/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
|