#!/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