diff --git a/app/api/v1/endpoints/auth.py b/app/api/v1/endpoints/auth.py new file mode 100644 index 0000000..db90e19 --- /dev/null +++ b/app/api/v1/endpoints/auth.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +LogBoard+ - Аутентификация API +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" + +from datetime import timedelta +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, status, Response +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials + +from app.core.auth import ( + authenticate_user, + create_access_token, + verify_token, + get_current_user, + ACCESS_TOKEN_EXPIRE_MINUTES +) +from app.models.auth import UserLogin, Token + +router = APIRouter() + +# Инициализация безопасности +security = HTTPBearer() + +@router.post("/login", response_model=Token) +async def login(user_data: UserLogin, response: Response): + """API для входа в систему""" + if authenticate_user(user_data.username, user_data.password): + access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = create_access_token( + data={"sub": user_data.username}, expires_delta=access_token_expires + ) + + # Устанавливаем cookie с токеном + response.set_cookie( + key="access_token", + value=access_token, + httponly=True, + secure=False, # Установите True для HTTPS + samesite="lax", + max_age=ACCESS_TOKEN_EXPIRE_MINUTES * 60 + ) + + return {"access_token": access_token, "token_type": "bearer"} + else: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Неверное имя пользователя или пароль. Проверьте учетные данные и попробуйте снова.", + headers={"WWW-Authenticate": "Bearer"}, + ) + +@router.post("/logout") +async def logout(response: Response): + """API для выхода из системы""" + response.delete_cookie(key="access_token") + return {"message": "Успешный выход из системы"} + +@router.get("/me") +async def get_current_user_info(current_user: str = Depends(get_current_user)): + """Получить информацию о текущем пользователе""" + return {"username": current_user} diff --git a/app/api/v1/endpoints/containers.py b/app/api/v1/endpoints/containers.py new file mode 100644 index 0000000..e87bb97 --- /dev/null +++ b/app/api/v1/endpoints/containers.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +LogBoard+ - Контейнеры API +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" + +from typing import List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, Body +from fastapi.responses import JSONResponse + +from app.core.auth import get_current_user +from app.core.docker import ( + load_excluded_containers, + save_excluded_containers, + get_all_projects, + list_containers, + DEFAULT_PROJECT, + DEFAULT_PROJECTS +) + +router = APIRouter() + +@router.get("/excluded") +def api_get_excluded_containers(current_user: str = Depends(get_current_user)): + """Получить список исключенных контейнеров""" + return JSONResponse( + content={"excluded_containers": load_excluded_containers()}, + headers={ + "Cache-Control": "no-cache, no-store, must-revalidate", + "Pragma": "no-cache", + "Expires": "0" + } + ) + +@router.post("/excluded") +def api_update_excluded_containers( + containers: List[str] = Body(...), + current_user: str = Depends(get_current_user) +): + """Обновить список исключенных контейнеров""" + success = save_excluded_containers(containers) + if success: + return JSONResponse( + content={"status": "success", "message": "Список исключенных контейнеров обновлен"}, + headers={ + "Cache-Control": "no-cache, no-store, must-revalidate", + "Pragma": "no-cache", + "Expires": "0" + } + ) + else: + raise HTTPException( + status_code=500, + detail="Ошибка сохранения списка исключенных контейнеров. Попробуйте еще раз или обратитесь к администратору." + ) + +@router.get("/projects") +def api_projects(current_user: str = Depends(get_current_user)): + """Получить список всех проектов Docker Compose""" + return JSONResponse( + content=get_all_projects(), + headers={ + "Cache-Control": "no-cache, no-store, must-revalidate", + "Pragma": "no-cache", + "Expires": "0" + } + ) + +@router.get("/services") +def api_services( + projects: Optional[str] = Query(None), + 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()] + elif DEFAULT_PROJECTS and DEFAULT_PROJECTS.strip(): + project_list = [p.strip() for p in DEFAULT_PROJECTS.split(",") if p.strip()] + elif DEFAULT_PROJECT and DEFAULT_PROJECT.strip(): + project_list = [DEFAULT_PROJECT] + # Если ни одна переменная не указана или пустая, показываем все контейнеры (project_list остается None) + + return JSONResponse( + content=list_containers(projects=project_list, include_stopped=include_stopped), + headers={ + "Cache-Control": "no-cache, no-store, must-revalidate", + "Pragma": "no-cache", + "Expires": "0" + } + ) diff --git a/app/api/v1/endpoints/logs.py b/app/api/v1/endpoints/logs.py new file mode 100644 index 0000000..e8e55db --- /dev/null +++ b/app/api/v1/endpoints/logs.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +LogBoard+ - Логи API +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" + +import re +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, Body +from fastapi.responses import JSONResponse + +import docker + +from app.core.auth import get_current_user +from app.core.docker import docker_client, DEFAULT_TAIL + +router = APIRouter() + +@router.get("/stats/{container_id}") +def api_logs_stats(container_id: str, current_user: str = Depends(get_current_user)): + """Получить статистику логов контейнера""" + try: + # Ищем контейнер + container = None + for c in docker_client.containers.list(all=True): + if c.id.startswith(container_id): + container = c + break + + if container is None: + return JSONResponse({"error": "Container not found"}, status_code=404) + + # Получаем логи + logs = container.logs(tail=1000).decode(errors="ignore") + + # Подсчитываем статистику + stats = {"debug": 0, "info": 0, "warn": 0, "error": 0} + + for line in logs.split('\n'): + if not line.strip(): + continue + + 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: + print(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 контейнера + tail: Количество последних строк или 'all' для всех логов (по умолчанию 500) + since: Время начала для фильтрации логов + + Returns: + JSON с логами и метаданными + """ + try: + # Ищем контейнер + container = None + for c in docker_client.containers.list(all=True): + if c.id.startswith(container_id): + container = c + break + + if container is None: + return JSONResponse({"error": "Container not found"}, status_code=404) + + # Формируем параметры для получения логов + 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: + print(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 app.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} diff --git a/app/api/v1/endpoints/pages.py b/app/api/v1/endpoints/pages.py new file mode 100644 index 0000000..8d4f209 --- /dev/null +++ b/app/api/v1/endpoints/pages.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +LogBoard+ - Страницы API +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" + +from fastapi import APIRouter, Request, Depends, HTTPException +from fastapi.responses import HTMLResponse, RedirectResponse, PlainTextResponse + +from app.core.auth import verify_token, get_current_user +from app.core.config import templates, AJAX_UPDATE_INTERVAL, DEFAULT_TAIL, SKIP_UNHEALTHY + +router = APIRouter() + +@router.get("/", response_class=HTMLResponse) +async def index(request: Request): + """Главная страница приложения""" + # Проверяем наличие токена в cookie + access_token = request.cookies.get("access_token") + if not access_token: + return RedirectResponse(url="/login") + + # Проверяем валидность токена + username = verify_token(access_token) + if not username: + return RedirectResponse(url="/login") + + return templates.TemplateResponse("index.html", {"request": request}) + +@router.get("/login", response_class=HTMLResponse) +async def login_page(request: Request): + """Страница входа""" + return templates.TemplateResponse("login.html", {"request": request}) + +@router.get("/healthz", response_class=PlainTextResponse) +def healthz(): + """Health check endpoint""" + return "ok" + +@router.get("/settings") +async def get_settings(current_user: str = Depends(get_current_user)): + """Получить настройки приложения""" + return { + "ajax_update_interval": AJAX_UPDATE_INTERVAL, + "default_tail": DEFAULT_TAIL, + "skip_unhealthy": SKIP_UNHEALTHY + } + +# Маршруты для тестирования страниц ошибок (только в режиме разработки) +@router.get("/test/error/404") +async def test_404_error(): + """Тест страницы ошибки 404""" + raise HTTPException(status_code=404, detail="Тестовая ошибка 404") + +@router.get("/test/error/401") +async def test_401_error(): + """Тест страницы ошибки 401""" + raise HTTPException(status_code=401, detail="Тестовая ошибка 401") + +@router.get("/test/error/403") +async def test_403_error(): + """Тест страницы ошибки 403""" + raise HTTPException(status_code=403, detail="Тестовая ошибка 403") + +@router.get("/test/error/500") +async def test_500_error(): + """Тест страницы ошибки 500""" + raise HTTPException(status_code=500, detail="Тестовая ошибка 500") + +@router.get("/test/error/general") +async def test_general_error(): + """Тест общей ошибки""" + raise Exception("Тестовая общая ошибка") diff --git a/app/api/v1/endpoints/websocket.py b/app/api/v1/endpoints/websocket.py new file mode 100644 index 0000000..46a1097 --- /dev/null +++ b/app/api/v1/endpoints/websocket.py @@ -0,0 +1,309 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +LogBoard+ - WebSocket API +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" + +import asyncio +from typing import Optional + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query +from fastapi.responses import JSONResponse + +from app.core.auth import verify_token +from app.core.docker import docker_client, DEFAULT_TAIL +from app.core.auth import get_current_user +from datetime import datetime + +router = APIRouter() + +@router.get("/status") +def api_websocket_status(current_user: str = Depends(get_current_user)): + """Получить статус WebSocket соединений""" + try: + # Проверяем доступность Docker + docker_client.ping() + + # Получаем список активных контейнеров + containers = docker_client.containers.list() + + # Проверяем, есть ли контейнеры для подключения + if containers: + return { + "status": "available", + "message": "WebSocket соединения доступны", + "containers_count": len(containers), + "timestamp": datetime.now().isoformat() + } + else: + return { + "status": "no_containers", + "message": "Нет доступных контейнеров для подключения", + "containers_count": 0, + "timestamp": datetime.now().isoformat() + } + + except Exception as e: + return { + "status": "error", + "message": f"Ошибка проверки WebSocket статуса: {str(e)}", + "containers_count": 0, + "timestamp": datetime.now().isoformat() + } + +@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 для получения логов контейнера""" + + # Принимаем соединение + await ws.accept() + + # Проверяем токен + if not token: + await ws.send_text("ERROR: token required") + await ws.close() + return + + username = verify_token(token) + if not username: + await ws.send_text("ERROR: invalid token") + await ws.close() + return + + try: + # Простой поиск контейнера по ID + container = None + try: + for c in docker_client.containers.list(all=True): + if c.id.startswith(container_id): + container = c + break + except Exception as e: + await ws.send_text(f"ERROR: cannot list containers - {e}") + return + + if container is None: + await ws.send_text("ERROR: container not found") + return + + # Отправляем начальное сообщение + await ws.send_text(f"Connected to container: {container.name}") + + # Получаем логи (только последние строки, без follow) + try: + print(f"Getting logs for container {container.name} (ID: {container.id[:12]})") + logs = container.logs(tail=tail).decode(errors="ignore") + if logs: + await ws.send_text(logs) + else: + await ws.send_text("No logs available") + except Exception as e: + print(f"Error getting logs for {container.name}: {e}") + await ws.send_text(f"ERROR getting logs: {e}") + + # Простое WebSocket соединение - только отправляем логи один раз + print(f"WebSocket connection established for {container.name}") + + except WebSocketDisconnect: + print(f"WebSocket client disconnected for container {container.name}") + except Exception as e: + print(f"WebSocket error for {container.name}: {e}") + try: + await ws.send_text(f"ERROR: {e}") + except: + pass + finally: + try: + print(f"Closing WebSocket connection for container {container.name}") + await ws.close() + except: + pass + +@router.websocket("/fan/{service_name}") +async def ws_fan(ws: WebSocket, service_name: str, tail: int = DEFAULT_TAIL, token: Optional[str] = None, + project: Optional[str] = None): + """WebSocket: fan-in by compose service (aggregate all replicas), prefixing with short container id""" + await ws.accept() + + # Проверяем токен + if not token: + await ws.send_text("ERROR: token required") + await ws.close() + return + + username = verify_token(token) + if not username: + await ws.send_text("ERROR: invalid token") + await ws.close() + return + + # Track active streaming tasks by container id + active = {} + + def list_by_service(service_name: str, project_name: Optional[str] = None): + found = [] + for c in docker_client.containers.list(all=True): + lbl = c.labels or {} + if lbl.get("com.docker.compose.service") == service_name and (project_name is None or lbl.get("com.docker.compose.project")==project_name): + found.append(c) + # sort by Created asc so first lines look ordered-ish + try: + found.sort(key=lambda x: x.attrs.get("Created","")) + except Exception: + pass + return found + + async def stream_container(cont): + short = cont.id[:8] + first_tail = tail + while True: + try: + use_tail = first_tail + first_tail = 0 + stream = cont.logs(stream=True, follow=True, tail=(use_tail if use_tail>0 else "all")) + for chunk in stream: + if chunk is None: + break + try: + await ws.send_text(f"[{short}] " + chunk.decode(errors="ignore")) + except RuntimeError: + stream.close(); return + stream.close() + # container stopped -> wait and try to find same id again; if gone, exit loop and outer watcher will reassign + await asyncio.sleep(1.0) + except WebSocketDisconnect: + return + except Exception: + await asyncio.sleep(1.0) + continue + + async def watcher(): + # Periodically reconcile set of containers + try: + while True: + desired = {c.id: c for c in list_by_service(service_name, project)} + # start missing + for cid, cont in desired.items(): + if cid not in active: + task = asyncio.create_task(stream_container(cont)) + active[cid] = task + # cancel removed + for cid in list(active.keys()): + if cid not in desired: + task = active.pop(cid) + task.cancel() + await asyncio.sleep(2.0) + except asyncio.CancelledError: + pass + + watch_task = asyncio.create_task(watcher()) + try: + # Keep ws open until disconnected; the tasks stream data + while True: + await asyncio.sleep(1.0) + except WebSocketDisconnect: + pass + finally: + watch_task.cancel() + for t in active.values(): + t.cancel() + try: + await ws.close() + except Exception: + pass + +@router.websocket("/fan_group") +async def ws_fan_group(ws: WebSocket, services: str, tail: int = DEFAULT_TAIL, token: Optional[str] = None, + project: Optional[str] = None): + """WebSocket: fan-in for multiple compose services (comma-separated), optional project filter.""" + await ws.accept() + + # Проверяем токен + if not token: + await ws.send_text("ERROR: token required") + await ws.close() + return + + username = verify_token(token) + if not username: + await ws.send_text("ERROR: invalid token") + await ws.close() + return + + svc_set = {s.strip() for s in services.split(",") if s.strip()} + if not svc_set: + await ws.send_text("ERROR: no services provided") + await ws.close(); return + + active = {} + + def list_by_services(names, project_name: Optional[str] = None): + res = [] + for c in docker_client.containers.list(all=True): + lbl = c.labels or {} + if lbl.get("com.docker.compose.service") in names and (project_name is None or lbl.get("com.docker.compose.project")==project_name): + res.append(c) + try: + res.sort(key=lambda x: x.attrs.get("Created","")) + except Exception: + pass + return res + + async def stream_container(cont): + short = cont.id[:8] + svc = (cont.labels or {}).get("com.docker.compose.service","") + first_tail = tail + while True: + try: + use_tail = first_tail + first_tail = 0 + stream = cont.logs(stream=True, follow=True, tail=(use_tail if use_tail>0 else "all")) + for chunk in stream: + if chunk is None: + break + line = chunk.decode(errors="ignore") + try: + await ws.send_text(f"[{short} {svc}] " + line) + except RuntimeError: + stream.close(); return + stream.close() + await asyncio.sleep(1.0) + except WebSocketDisconnect: + return + except Exception: + await asyncio.sleep(1.0) + continue + + async def watcher(): + try: + while True: + desired = {c.id: c for c in list_by_services(svc_set, project)} + for cid, cont in desired.items(): + if cid not in active: + task = asyncio.create_task(stream_container(cont)) + active[cid] = task + for cid in list(active.keys()): + if cid not in desired: + task = active.pop(cid) + task.cancel() + await asyncio.sleep(2.0) + except asyncio.CancelledError: + pass + + watch_task = asyncio.create_task(watcher()) + try: + while True: + await asyncio.sleep(1.0) + except WebSocketDisconnect: + pass + finally: + watch_task.cancel() + for t in active.values(): + t.cancel() + try: + await ws.close() + except Exception: + pass diff --git a/app/api/v1/router.py b/app/api/v1/router.py new file mode 100644 index 0000000..50fad4e --- /dev/null +++ b/app/api/v1/router.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +LogBoard+ - API Router +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" + +from fastapi import APIRouter + +from .endpoints import auth, logs, containers, websocket, pages + +# Создаем основной роутер API v1 +api_router = APIRouter(prefix="/api", tags=["api"]) + +# Подключаем маршруты страниц (без префикса /api) +pages_router = APIRouter(tags=["pages"]) + +# Подключаем все маршруты +api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) +api_router.include_router(logs.router, prefix="/logs", tags=["logs"]) +api_router.include_router(containers.router, prefix="/containers", tags=["containers"]) +api_router.include_router(websocket.router, prefix="/websocket", tags=["websocket"]) + +# Подключаем маршруты страниц +pages_router.include_router(pages.router) diff --git a/app/app.py b/app/app.py index 2c67b1a..c79648e 100644 --- a/app/app.py +++ b/app/app.py @@ -6,52 +6,13 @@ LogBoard+ - Веб-панель для просмотра логов микро Сайт: https://devops.org.ru """ -import asyncio -import base64 -import json import os -import re -import secrets -from datetime import datetime, timedelta -from typing import Optional, List, Dict, Union -from pathlib import Path - -import docker -from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query, Depends, HTTPException, status, Body, Request, Response -from fastapi.exceptions import RequestValidationError -from starlette.exceptions import HTTPException as StarletteHTTPException -from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, RedirectResponse -from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from fastapi import FastAPI, Request, HTTPException from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates -from pydantic import BaseModel -import jwt -from passlib.context import CryptContext -# Настройки приложения -APP_PORT = int(os.getenv("LOGBOARD_PORT", "9001")) -DEFAULT_TAIL = int(os.getenv("LOGBOARD_TAIL", "500")) -DEFAULT_PROJECT = os.getenv("COMPOSE_PROJECT_NAME") -DEFAULT_PROJECTS = os.getenv("LOGBOARD_PROJECTS") -SKIP_UNHEALTHY = os.getenv("LOGBOARD_SKIP_UNHEALTHY", "true").lower() == "true" -CONTAINER_LIST_TIMEOUT = int(os.getenv("LOGBOARD_CONTAINER_LIST_TIMEOUT", "10")) -CONTAINER_INFO_TIMEOUT = int(os.getenv("LOGBOARD_CONTAINER_INFO_TIMEOUT", "3")) -HEALTH_CHECK_TIMEOUT = int(os.getenv("LOGBOARD_HEALTH_CHECK_TIMEOUT", "2")) - -# Настройки безопасности -SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-here-change-in-production") -ALGORITHM = "HS256" -ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("SESSION_TIMEOUT", "3600")) // 60 # 1 час по умолчанию - -# Настройки пользователей -ADMIN_USERNAME = os.getenv("LOGBOARD_USER", "admin") -ADMIN_PASSWORD = os.getenv("LOGBOARD_PASS", "admin") - -# Настройки AJAX обновления -AJAX_UPDATE_INTERVAL = int(os.getenv("LOGBOARD_AJAX_UPDATE_INTERVAL", "2000")) - -# Настройки режима отладки -DEBUG_MODE = os.getenv("DEBUG_MODE", "false").lower() == "true" +from app.core.config import DEBUG_MODE, SNAP_DIR, STATIC_DIR +from app.api.v1.router import api_router, pages_router # Инициализация FastAPI app = FastAPI( @@ -62,7 +23,16 @@ app = FastAPI( redoc_url="/redoc" if DEBUG_MODE else None ) +# Инициализация шаблонов +templates = Jinja2Templates(directory="app/templates") +# serve snapshots directory (downloadable files) +os.makedirs(SNAP_DIR, exist_ok=True) +app.mount("/snapshots", StaticFiles(directory=SNAP_DIR), name="snapshots") + +# serve static files directory +os.makedirs(STATIC_DIR, exist_ok=True) +app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") # Обработчики исключений @app.exception_handler(404) @@ -105,1117 +75,9 @@ async def internal_server_error_handler(request: Request, exc: HTTPException): "error_message": "Произошла непредвиденная ошибка. Попробуйте обновить страницу или обратитесь к администратору." }, status_code=500) -@app.exception_handler(HTTPException) -async def http_exception_handler(request: Request, exc: HTTPException): - """Общий обработчик HTTP исключений""" - # Для API маршрутов возвращаем JSON ответ - if request.url.path.startswith("/api/"): - if exc.status_code == 401: - return JSONResponse( - status_code=401, - content={ - "error": "unauthorized", - "message": "Требуется авторизация", - "details": "Для доступа к этому API необходимо войти в систему." - }, - headers={"WWW-Authenticate": "Bearer"} - ) - elif exc.status_code == 403: - return JSONResponse( - status_code=403, - content={ - "error": "forbidden", - "message": "Доступ запрещен", - "details": "У вас нет прав для доступа к этому API." - } - ) - else: - return JSONResponse( - status_code=exc.status_code, - content={ - "error": f"http_{exc.status_code}", - "message": exc.detail or "Произошла ошибка при обработке запроса.", - "details": f"URL: {request.url.path}" - } - ) - - # Для обычных страниц возвращаем HTML - if exc.status_code == 401: - title = "Требуется авторизация" - message = "Для доступа к этой странице необходимо войти в систему." - elif exc.status_code == 403: - title = "Доступ запрещен" - message = "У вас нет прав для доступа к этой странице." - elif exc.status_code == 404: - title = "Страница не найдена" - message = "Запрашиваемая страница не существует или была перемещена." - elif exc.status_code == 500: - title = "Внутренняя ошибка сервера" - message = "Произошла непредвиденная ошибка. Попробуйте обновить страницу или обратитесь к администратору." - else: - title = f"Ошибка {exc.status_code}" - message = exc.detail or "Произошла ошибка при обработке запроса." - - return templates.TemplateResponse("error.html", { - "request": request, - "error_code": exc.status_code, - "error_title": title, - "error_message": message - }, status_code=exc.status_code) - -@app.exception_handler(StarletteHTTPException) -async def starlette_http_exception_handler(request: Request, exc: StarletteHTTPException): - """Обработчик Starlette HTTP исключений (включая ошибки безопасности)""" - # Для API маршрутов возвращаем JSON ответ - if request.url.path.startswith("/api/"): - if exc.status_code == 401: - return JSONResponse( - status_code=401, - content={ - "error": "unauthorized", - "message": "Требуется авторизация", - "details": "Для доступа к этому API необходимо войти в систему." - }, - headers={"WWW-Authenticate": "Bearer"} - ) - elif exc.status_code == 403: - return JSONResponse( - status_code=403, - content={ - "error": "forbidden", - "message": "Доступ запрещен", - "details": "У вас нет прав для доступа к этому API." - } - ) - else: - return JSONResponse( - status_code=exc.status_code, - content={ - "error": f"http_{exc.status_code}", - "message": exc.detail or "Произошла ошибка при обработке запроса.", - "details": f"URL: {request.url.path}" - } - ) - - # Для обычных страниц возвращаем HTML - if exc.status_code == 401: - title = "Требуется авторизация" - message = "Для доступа к этой странице необходимо войти в систему." - elif exc.status_code == 403: - title = "Доступ запрещен" - message = "У вас нет прав для доступа к этой странице." - elif exc.status_code == 404: - title = "Страница не найдена" - message = "Запрашиваемая страница не существует или была перемещена." - elif exc.status_code == 500: - title = "Внутренняя ошибка сервера" - message = "Произошла непредвиденная ошибка. Попробуйте обновить страницу или обратитесь к администратору." - else: - title = f"Ошибка {exc.status_code}" - message = exc.detail or "Произошла ошибка при обработке запроса." - - return templates.TemplateResponse("error.html", { - "request": request, - "error_code": exc.status_code, - "error_title": title, - "error_message": message - }, status_code=exc.status_code) - -@app.exception_handler(Exception) -async def general_exception_handler(request: Request, exc: Exception): - """Общий обработчик всех исключений""" - import traceback - - # Логируем ошибку - print(f"❌ Необработанная ошибка: {exc}") - print(f"❌ URL: {request.url.path}") - print(f"❌ Traceback: {traceback.format_exc()}") - - return templates.TemplateResponse("error.html", { - "request": request, - "error_code": 500, - "error_title": "Внутренняя ошибка сервера", - "error_message": "Произошла непредвиденная ошибка. Попробуйте обновить страницу или обратитесь к администратору." - }, status_code=500) - -# Инициализация шаблонов -templates = Jinja2Templates(directory="app/templates") - -# Инициализация безопасности -security = HTTPBearer() -pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") - -# Инициализация Docker клиента -docker_client = docker.from_env() - -# serve snapshots directory (downloadable files) -SNAP_DIR = os.getenv("LOGBOARD_SNAPSHOT_DIR", "./snapshots") -os.makedirs(SNAP_DIR, exist_ok=True) -app.mount("/snapshots", StaticFiles(directory=SNAP_DIR), name="snapshots") - -# serve static files directory -STATIC_DIR = os.getenv("LOGBOARD_STATIC_DIR", "./app/static") -os.makedirs(STATIC_DIR, exist_ok=True) -app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") - -# Модели данных -class UserLogin(BaseModel): - username: str - password: str - -class Token(BaseModel): - access_token: str - token_type: str - -class TokenData(BaseModel): - username: Optional[str] = None - -# Функции для работы с паролями -def verify_password(plain_password: str, hashed_password: str) -> bool: - """Проверяет пароль""" - return pwd_context.verify(plain_password, hashed_password) - -def get_password_hash(password: str) -> str: - """Хеширует пароль""" - return pwd_context.hash(password) - -# Функции для работы с JWT токенами -def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): - """Создает JWT токен""" - to_encode = data.copy() - if expires_delta: - expire = datetime.utcnow() + expires_delta - else: - expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) - to_encode.update({"exp": expire}) - encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) - return encoded_jwt - -def verify_token(token: str) -> Optional[str]: - """Проверяет JWT токен и возвращает имя пользователя""" - try: - payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) - username: str = payload.get("sub") - if username is None: - return None - return username - except jwt.PyJWTError: - return None - -# Функция для проверки аутентификации -async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> str: - """Получает текущего пользователя из токена""" - token = credentials.credentials - username = verify_token(token) - if username is None: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Недействительный токен. Требуется авторизация.", - headers={"WWW-Authenticate": "Bearer"}, - ) - return username - -# Функция для проверки пользователя -def authenticate_user(username: str, password: str) -> bool: - """Аутентифицирует пользователя""" - if username == ADMIN_USERNAME: - # Для простоты используем прямое сравнение паролей - # В продакшене рекомендуется использовать хешированные пароли - return password == ADMIN_PASSWORD - return False - -# ---------- DOCKER HELPERS ---------- -def load_excluded_containers() -> List[str]: - """ - Загружает список исключенных контейнеров из JSON файла - Автор: Сергей Антропов - Сайт: https://devops.org.ru - """ - try: - with open("app/excluded_containers.json", "r", encoding="utf-8") as f: - data = json.load(f) - return data.get("excluded_containers", []) - except FileNotFoundError: - print("⚠️ Файл app/excluded_containers.json не найден, используем пустой список") - return [] - except json.JSONDecodeError as e: - print(f"❌ Ошибка парсинга app/excluded_containers.json: {e}") - return [] - except Exception as e: - print(f"❌ Ошибка загрузки app/excluded_containers.json: {e}") - return [] - -def save_excluded_containers(containers: List[str]) -> bool: - """ - Сохраняет список исключенных контейнеров в JSON файл - Автор: Сергей Антропов - Сайт: https://devops.org.ru - """ - try: - data = { - "excluded_containers": containers, - "description": "Список контейнеров, которые генерируют слишком много логов и исключаются из отображения" - } - with open("app/excluded_containers.json", "w", encoding="utf-8") as f: - json.dump(data, f, indent=2, ensure_ascii=False) - return True - except Exception as e: - print(f"❌ Ошибка сохранения app/excluded_containers.json: {e}") - return False - -def get_all_projects() -> List[str]: - """ - Получает список всех проектов Docker Compose с учетом исключенных контейнеров - Автор: Сергей Антропов - Сайт: https://devops.org.ru - """ - projects = set() - excluded_containers = load_excluded_containers() - - try: - containers = docker_client.containers.list(all=True) - - # Словарь для подсчета контейнеров по проектам - project_containers = {} - standalone_containers = [] - - for c in containers: - try: - # Пропускаем исключенные контейнеры - if c.name in excluded_containers: - continue - - labels = c.labels or {} - project = labels.get("com.docker.compose.project") - - if project: - if project not in project_containers: - project_containers[project] = 0 - project_containers[project] += 1 - else: - standalone_containers.append(c.name) - - except Exception: - continue - - # Добавляем проекты, у которых есть хотя бы один неисключенный контейнер - for project, count in project_containers.items(): - if count > 0: - projects.add(project) - - # Добавляем standalone, если есть неисключенные контейнеры без проекта - if standalone_containers: - projects.add("standalone") - - except Exception as e: - print(f"❌ Ошибка получения списка проектов: {e}") - return [] - - result = sorted(list(projects)) - print(f"📋 Доступные проекты (с учетом исключенных контейнеров): {result}") - return result - -def list_containers(projects: Optional[List[str]] = None, include_stopped: bool = False) -> List[Dict]: - """ - Получает список контейнеров с поддержкой множественных проектов - Автор: Сергей Антропов - Сайт: https://devops.org.ru - """ - # Загружаем список исключенных контейнеров из JSON файла - excluded_containers = load_excluded_containers() - - print(f"🚫 Список исключенных контейнеров: {excluded_containers}") - - items = [] - excluded_count = 0 - - try: - # Получаем список контейнеров с базовой обработкой ошибок - containers = docker_client.containers.list(all=include_stopped) - - for c in containers: - try: - # Базовая информация о контейнере (без health check) - basic_info = { - "id": c.id[:12], - "name": c.name, - "status": c.status, - "image": "unknown", - "service": c.name, - "project": None, - "health": None, - "ports": [], - "url": None, - } - - # Безопасно получаем метки - try: - labels = c.labels or {} - basic_info["project"] = labels.get("com.docker.compose.project") - basic_info["service"] = labels.get("com.docker.compose.service") or c.name - except Exception: - pass # Используем значения по умолчанию - - # Безопасно получаем информацию об образе - try: - if c.image and c.image.tags: - basic_info["image"] = c.image.tags[0] - elif c.image: - basic_info["image"] = c.image.short_id - except Exception: - pass # Оставляем "unknown" - - # Безопасно получаем информацию о портах - try: - ports = c.ports or {} - if ports: - basic_info["ports"] = list(ports.keys()) - - # Пытаемся найти HTTP/HTTPS порт для создания URL - for port_mapping in ports.values(): - if port_mapping: - for mapping in port_mapping: - if isinstance(mapping, dict) and mapping.get("HostPort"): - host_port = mapping["HostPort"] - # Проверяем, что это HTTP порт (80, 443, 8080, 3000, etc.) - host_port_int = int(host_port) - if (host_port_int in [80, 443] or - (1 <= host_port_int <= 7999) or - (8000 <= host_port_int <= 65535)): - protocol = "https" if host_port == "443" else "http" - basic_info["url"] = f"{protocol}://localhost:{host_port}" - basic_info["host_port"] = host_port - break - if basic_info["url"]: - break - except Exception: - pass # Оставляем пустые значения - - # Фильтрация по проектам - if projects: - # Если проект не указан, считаем его standalone - container_project = basic_info["project"] or "standalone" - if container_project not in projects: - continue - - # Фильтрация исключенных контейнеров - if basic_info["name"] in excluded_containers: - excluded_count += 1 - print(f"⚠️ Пропускаем исключенный контейнер: {basic_info['name']}") - continue - - # Добавляем контейнер в список - items.append(basic_info) - - except Exception as e: - # Пропускаем контейнеры с критическими ошибками - print(f"⚠️ Пропускаем проблемный контейнер {c.name if hasattr(c, 'name') else 'unknown'} (ID: {c.id[:12]}): {e}") - continue - - except Exception as e: - print(f"❌ Ошибка получения списка контейнеров: {e}") - return [] - - # Сортируем по проекту, сервису и имени - items.sort(key=lambda x: (x.get("project") or "", x.get("service") or "", x.get("name") or "")) - - # Подсчитываем статистику по проектам - project_stats = {} - for item in items: - project = item.get("project") or "standalone" - if project not in project_stats: - project_stats[project] = {"visible": 0, "excluded": 0} - project_stats[project]["visible"] += 1 - - # Подсчитываем исключенные контейнеры по проектам - for c in containers: - try: - if c.name in excluded_containers: - labels = c.labels or {} - project = labels.get("com.docker.compose.project") or "standalone" - if project not in project_stats: - project_stats[project] = {"visible": 0, "excluded": 0} - project_stats[project]["excluded"] += 1 - except Exception: - continue - - print(f"📊 Статистика: найдено {len(items)} контейнеров, исключено {excluded_count} контейнеров") - for project, stats in project_stats.items(): - print(f" 📦 {project}: {stats['visible']} видимых, {stats['excluded']} исключенных") - - return items - -# ---------- HTML ---------- -INDEX_PATH = os.getenv("LOGBOARD_INDEX_HTML", "./app/templates/index.html") -def load_index_html() -> str: - """Загружает HTML шаблон главной страницы""" - with open(INDEX_PATH, "r", encoding="utf-8") as f: - return f.read() - -def load_login_html() -> str: - """Загружает HTML шаблон страницы входа""" - login_path = "./app/templates/login.html" - with open(login_path, "r", encoding="utf-8") as f: - return f.read() - -# ---------- ROUTES ---------- -@app.get("/", response_class=HTMLResponse) -async def index(request: Request): - """Главная страница приложения""" - # Проверяем наличие токена в cookie - access_token = request.cookies.get("access_token") - if not access_token: - return RedirectResponse(url="/login") - - # Проверяем валидность токена - username = verify_token(access_token) - if not username: - return RedirectResponse(url="/login") - - return templates.TemplateResponse("index.html", {"request": request}) - -@app.get("/login", response_class=HTMLResponse) -async def login_page(request: Request): - """Страница входа""" - return templates.TemplateResponse("login.html", {"request": request}) - -@app.post("/api/auth/login", response_model=Token) -async def login(user_data: UserLogin, response: Response): - """API для входа в систему""" - if authenticate_user(user_data.username, user_data.password): - access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) - access_token = create_access_token( - data={"sub": user_data.username}, expires_delta=access_token_expires - ) - - # Устанавливаем cookie с токеном - response.set_cookie( - key="access_token", - value=access_token, - httponly=True, - secure=False, # Установите True для HTTPS - samesite="lax", - max_age=ACCESS_TOKEN_EXPIRE_MINUTES * 60 - ) - - return {"access_token": access_token, "token_type": "bearer"} - else: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Неверное имя пользователя или пароль. Проверьте учетные данные и попробуйте снова.", - headers={"WWW-Authenticate": "Bearer"}, - ) - -@app.post("/api/auth/logout") -async def logout(response: Response): - """API для выхода из системы""" - response.delete_cookie(key="access_token") - return {"message": "Успешный выход из системы"} - -@app.get("/api/auth/me") -async def get_current_user_info(current_user: str = Depends(get_current_user)): - """Получить информацию о текущем пользователе""" - return {"username": current_user} - -@app.get("/api/settings") -async def get_settings(current_user: str = Depends(get_current_user)): - """Получить настройки приложения""" - return { - "ajax_update_interval": AJAX_UPDATE_INTERVAL, - "default_tail": DEFAULT_TAIL, - "skip_unhealthy": SKIP_UNHEALTHY - } - -@app.get("/healthz", response_class=PlainTextResponse) -def healthz(): - """Health check endpoint""" - return "ok" - -# Маршруты для тестирования страниц ошибок (только в режиме разработки) -@app.get("/test/error/404") -async def test_404_error(): - """Тест страницы ошибки 404""" - raise HTTPException(status_code=404, detail="Тестовая ошибка 404") - -@app.get("/test/error/401") -async def test_401_error(): - """Тест страницы ошибки 401""" - raise HTTPException(status_code=401, detail="Тестовая ошибка 401") - -@app.get("/test/error/403") -async def test_403_error(): - """Тест страницы ошибки 403""" - raise HTTPException(status_code=403, detail="Тестовая ошибка 403") - -@app.get("/test/error/500") -async def test_500_error(): - """Тест страницы ошибки 500""" - raise HTTPException(status_code=500, detail="Тестовая ошибка 500") - -@app.get("/test/error/general") -async def test_general_error(): - """Тест общей ошибки""" - raise Exception("Тестовая общая ошибка") - -@app.get("/api/logs/stats/{container_id}") -def api_logs_stats(container_id: str, current_user: str = Depends(get_current_user)): - """Получить статистику логов контейнера""" - try: - # Ищем контейнер - container = None - for c in docker_client.containers.list(all=True): - if c.id.startswith(container_id): - container = c - break - - if container is None: - return JSONResponse({"error": "Container not found"}, status_code=404) - - # Получаем логи - logs = container.logs(tail=1000).decode(errors="ignore") - - # Подсчитываем статистику - stats = {"debug": 0, "info": 0, "warn": 0, "error": 0} - - for line in logs.split('\n'): - if not line.strip(): - continue - - 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: - print(f"Error getting log stats for {container_id}: {e}") - return JSONResponse({"error": str(e)}, status_code=500) - -@app.get("/api/logs/{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 контейнера - tail: Количество последних строк или 'all' для всех логов (по умолчанию 500) - since: Время начала для фильтрации логов - - Returns: - JSON с логами и метаданными - """ - try: - # Ищем контейнер - container = None - for c in docker_client.containers.list(all=True): - if c.id.startswith(container_id): - container = c - break - - if container is None: - return JSONResponse({"error": "Container not found"}, status_code=404) - - # Формируем параметры для получения логов - 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 - }) - - # Сервер больше не фильтрует по содержимому (фильтрация по времени делается Docker'ом), - # дополнительная дедупликация выполняется на клиенте с учётом количества строк в ту же секунду - - # Получаем информацию о контейнере - 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: - print(f"Error getting logs for {container_id}: {e}") - return JSONResponse({"error": str(e)}, status_code=500) - -@app.get("/api/excluded-containers") -def api_get_excluded_containers(current_user: str = Depends(get_current_user)): - """Получить список исключенных контейнеров""" - return JSONResponse( - content={"excluded_containers": load_excluded_containers()}, - headers={ - "Cache-Control": "no-cache, no-store, must-revalidate", - "Pragma": "no-cache", - "Expires": "0" - } - ) - -@app.post("/api/excluded-containers") -def api_update_excluded_containers( - containers: List[str] = Body(...), - current_user: str = Depends(get_current_user) -): - """Обновить список исключенных контейнеров""" - success = save_excluded_containers(containers) - if success: - return JSONResponse( - content={"status": "success", "message": "Список исключенных контейнеров обновлен"}, - headers={ - "Cache-Control": "no-cache, no-store, must-revalidate", - "Pragma": "no-cache", - "Expires": "0" - } - ) - else: - raise HTTPException( - status_code=500, - detail="Ошибка сохранения списка исключенных контейнеров. Попробуйте еще раз или обратитесь к администратору." - ) - -@app.get("/api/projects") -def api_projects(current_user: str = Depends(get_current_user)): - """Получить список всех проектов Docker Compose""" - return JSONResponse( - content=get_all_projects(), - headers={ - "Cache-Control": "no-cache, no-store, must-revalidate", - "Pragma": "no-cache", - "Expires": "0" - } - ) - -@app.get("/api/services") -def api_services( - projects: Optional[str] = Query(None), - 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()] - elif DEFAULT_PROJECTS and DEFAULT_PROJECTS.strip(): - project_list = [p.strip() for p in DEFAULT_PROJECTS.split(",") if p.strip()] - elif DEFAULT_PROJECT and DEFAULT_PROJECT.strip(): - project_list = [DEFAULT_PROJECT] - # Если ни одна переменная не указана или пустая, показываем все контейнеры (project_list остается None) - - return JSONResponse( - content=list_containers(projects=project_list, include_stopped=include_stopped), - headers={ - "Cache-Control": "no-cache, no-store, must-revalidate", - "Pragma": "no-cache", - "Expires": "0" - } - ) - -@app.post("/api/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), -): - """Сохранить снимок логов""" - # 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} - -@app.get("/api/websocket/status") -def api_websocket_status(current_user: str = Depends(get_current_user)): - """Получить статус WebSocket соединений""" - try: - # Проверяем доступность Docker - docker_client.ping() - - # Получаем список активных контейнеров - containers = docker_client.containers.list() - - # Проверяем, есть ли контейнеры для подключения - if containers: - return { - "status": "available", - "message": "WebSocket соединения доступны", - "containers_count": len(containers), - "timestamp": datetime.now().isoformat() - } - else: - return { - "status": "no_containers", - "message": "Нет доступных контейнеров для подключения", - "containers_count": 0, - "timestamp": datetime.now().isoformat() - } - - except Exception as e: - return { - "status": "error", - "message": f"Ошибка проверки WebSocket статуса: {str(e)}", - "containers_count": 0, - "timestamp": datetime.now().isoformat() - } - -# WebSocket: verify token (?token=base64(user:pass)) -@app.websocket("/ws/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 для получения логов контейнера""" - - # Принимаем соединение - await ws.accept() - - # Проверяем токен - if not token: - await ws.send_text("ERROR: token required") - await ws.close() - return - - username = verify_token(token) - if not username: - await ws.send_text("ERROR: invalid token") - await ws.close() - return - - try: - # Простой поиск контейнера по ID - container = None - try: - for c in docker_client.containers.list(all=True): - if c.id.startswith(container_id): - container = c - break - except Exception as e: - await ws.send_text(f"ERROR: cannot list containers - {e}") - return - - if container is None: - await ws.send_text("ERROR: container not found") - return - - # Отправляем начальное сообщение - await ws.send_text(f"Connected to container: {container.name}") - - # Получаем логи (только последние строки, без follow) - try: - print(f"Getting logs for container {container.name} (ID: {container.id[:12]})") - logs = container.logs(tail=tail).decode(errors="ignore") - if logs: - await ws.send_text(logs) - else: - await ws.send_text("No logs available") - except Exception as e: - print(f"Error getting logs for {container.name}: {e}") - await ws.send_text(f"ERROR getting logs: {e}") - - # Простое WebSocket соединение - только отправляем логи один раз - print(f"WebSocket connection established for {container.name}") - - except WebSocketDisconnect: - print(f"WebSocket client disconnected for container {container.name}") - except Exception as e: - print(f"WebSocket error for {container.name}: {e}") - try: - await ws.send_text(f"ERROR: {e}") - except: - pass - finally: - try: - print(f"Closing WebSocket connection for container {container.name}") - await ws.close() - except: - pass - -# WebSocket: fan-in by compose service (aggregate all replicas), prefixing with short container id -@app.websocket("/ws/fan/{service_name}") -async def ws_fan(ws: WebSocket, service_name: str, tail: int = DEFAULT_TAIL, token: Optional[str] = None, - project: Optional[str] = None): - await ws.accept() - - # Проверяем токен - if not token: - await ws.send_text("ERROR: token required") - await ws.close() - return - - username = verify_token(token) - if not username: - await ws.send_text("ERROR: invalid token") - await ws.close() - return - - # Track active streaming tasks by container id - active = {} - - def list_by_service(service_name: str, project_name: Optional[str] = None): - found = [] - for c in docker_client.containers.list(all=True): - lbl = c.labels or {} - if lbl.get("com.docker.compose.service") == service_name and (project_name is None or lbl.get("com.docker.compose.project")==project_name): - found.append(c) - # sort by Created asc so first lines look ordered-ish - try: - found.sort(key=lambda x: x.attrs.get("Created","")) - except Exception: - pass - return found - - async def stream_container(cont): - short = cont.id[:8] - first_tail = tail - while True: - try: - use_tail = first_tail - first_tail = 0 - stream = cont.logs(stream=True, follow=True, tail=(use_tail if use_tail>0 else "all")) - for chunk in stream: - if chunk is None: - break - try: - await ws.send_text(f"[{short}] " + chunk.decode(errors="ignore")) - except RuntimeError: - stream.close(); return - stream.close() - # container stopped -> wait and try to find same id again; if gone, exit loop and outer watcher will reassign - await asyncio.sleep(1.0) - except WebSocketDisconnect: - return - except Exception: - await asyncio.sleep(1.0) - continue - - async def watcher(): - # Periodically reconcile set of containers - try: - while True: - desired = {c.id: c for c in list_by_service(service_name, project)} - # start missing - for cid, cont in desired.items(): - if cid not in active: - task = asyncio.create_task(stream_container(cont)) - active[cid] = task - # cancel removed - for cid in list(active.keys()): - if cid not in desired: - task = active.pop(cid) - task.cancel() - await asyncio.sleep(2.0) - except asyncio.CancelledError: - pass - - watch_task = asyncio.create_task(watcher()) - try: - # Keep ws open until disconnected; the tasks stream data - while True: - await asyncio.sleep(1.0) - except WebSocketDisconnect: - pass - finally: - watch_task.cancel() - for t in active.values(): - t.cancel() - try: - await ws.close() - except Exception: - pass - -# WebSocket: fan-in for multiple compose services (comma-separated), optional project filter. -@app.websocket("/ws/fan_group") -async def ws_fan_group(ws: WebSocket, services: str, tail: int = DEFAULT_TAIL, token: Optional[str] = None, - project: Optional[str] = None): - await ws.accept() - - # Проверяем токен - if not token: - await ws.send_text("ERROR: token required") - await ws.close() - return - - username = verify_token(token) - if not username: - await ws.send_text("ERROR: invalid token") - await ws.close() - return - - svc_set = {s.strip() for s in services.split(",") if s.strip()} - if not svc_set: - await ws.send_text("ERROR: no services provided") - await ws.close(); return - - active = {} - - def list_by_services(names, project_name: Optional[str] = None): - res = [] - for c in docker_client.containers.list(all=True): - lbl = c.labels or {} - if lbl.get("com.docker.compose.service") in names and (project_name is None or lbl.get("com.docker.compose.project")==project_name): - res.append(c) - try: - res.sort(key=lambda x: x.attrs.get("Created","")) - except Exception: - pass - return res - - async def stream_container(cont): - short = cont.id[:8] - svc = (cont.labels or {}).get("com.docker.compose.service","") - first_tail = tail - while True: - try: - use_tail = first_tail - first_tail = 0 - stream = cont.logs(stream=True, follow=True, tail=(use_tail if use_tail>0 else "all")) - for chunk in stream: - if chunk is None: - break - line = chunk.decode(errors="ignore") - try: - await ws.send_text(f"[{short} {svc}] " + line) - except RuntimeError: - stream.close(); return - stream.close() - await asyncio.sleep(1.0) - except WebSocketDisconnect: - return - except Exception: - await asyncio.sleep(1.0) - continue - - async def watcher(): - try: - while True: - desired = {c.id: c for c in list_by_services(svc_set, project)} - for cid, cont in desired.items(): - if cid not in active: - task = asyncio.create_task(stream_container(cont)) - active[cid] = task - for cid in list(active.keys()): - if cid not in desired: - task = active.pop(cid) - task.cancel() - await asyncio.sleep(2.0) - except asyncio.CancelledError: - pass - - watch_task = asyncio.create_task(watcher()) - try: - while True: - await asyncio.sleep(1.0) - except WebSocketDisconnect: - pass - finally: - watch_task.cancel() - for t in active.values(): - t.cancel() - try: - await ws.close() - except Exception: - pass +# Подключаем роутеры +app.include_router(api_router) +app.include_router(pages_router) if __name__ == "__main__": import uvicorn diff --git a/app/core/auth.py b/app/core/auth.py new file mode 100644 index 0000000..eba4529 --- /dev/null +++ b/app/core/auth.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +LogBoard+ - Аутентификация +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" + +import os +from datetime import datetime, timedelta +from typing import Optional + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from passlib.context import CryptContext +import jwt + +from app.core.config import ( + SECRET_KEY, + ALGORITHM, + ACCESS_TOKEN_EXPIRE_MINUTES, + ADMIN_USERNAME, + ADMIN_PASSWORD +) + +# Инициализация безопасности +security = HTTPBearer() +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +# Функции для работы с паролями +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Проверяет пароль""" + return pwd_context.verify(plain_password, hashed_password) + +def get_password_hash(password: str) -> str: + """Хеширует пароль""" + return pwd_context.hash(password) + +# Функции для работы с JWT токенами +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): + """Создает JWT токен""" + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + +def verify_token(token: str) -> Optional[str]: + """Проверяет JWT токен и возвращает имя пользователя""" + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + username: str = payload.get("sub") + if username is None: + return None + return username + except jwt.PyJWTError: + return None + +# Функция для проверки аутентификации +async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> str: + """Получает текущего пользователя из токена""" + token = credentials.credentials + username = verify_token(token) + if username is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Недействительный токен. Требуется авторизация.", + headers={"WWW-Authenticate": "Bearer"}, + ) + return username + +# Функция для проверки пользователя +def authenticate_user(username: str, password: str) -> bool: + """Аутентифицирует пользователя""" + if username == ADMIN_USERNAME: + # Для простоты используем прямое сравнение паролей + # В продакшене рекомендуется использовать хешированные пароли + return password == ADMIN_PASSWORD + return False diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..ef4fb17 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +LogBoard+ - Конфигурация приложения +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" + +import os +from fastapi.templating import Jinja2Templates + +# Настройки приложения +APP_PORT = int(os.getenv("LOGBOARD_PORT", "9001")) +DEFAULT_TAIL = int(os.getenv("LOGBOARD_TAIL", "500")) +DEFAULT_PROJECT = os.getenv("COMPOSE_PROJECT_NAME") +DEFAULT_PROJECTS = os.getenv("LOGBOARD_PROJECTS") +SKIP_UNHEALTHY = os.getenv("LOGBOARD_SKIP_UNHEALTHY", "true").lower() == "true" +CONTAINER_LIST_TIMEOUT = int(os.getenv("LOGBOARD_CONTAINER_LIST_TIMEOUT", "10")) +CONTAINER_INFO_TIMEOUT = int(os.getenv("LOGBOARD_CONTAINER_INFO_TIMEOUT", "3")) +HEALTH_CHECK_TIMEOUT = int(os.getenv("LOGBOARD_HEALTH_CHECK_TIMEOUT", "2")) + +# Настройки безопасности +SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-here-change-in-production") +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("SESSION_TIMEOUT", "3600")) // 60 # 1 час по умолчанию + +# Настройки пользователей +ADMIN_USERNAME = os.getenv("LOGBOARD_USER", "admin") +ADMIN_PASSWORD = os.getenv("LOGBOARD_PASS", "admin") + +# Настройки AJAX обновления +AJAX_UPDATE_INTERVAL = int(os.getenv("LOGBOARD_AJAX_UPDATE_INTERVAL", "2000")) + +# Настройки режима отладки +DEBUG_MODE = os.getenv("DEBUG_MODE", "false").lower() == "true" + +# Инициализация шаблонов +templates = Jinja2Templates(directory="app/templates") + +# Директории +SNAP_DIR = os.getenv("LOGBOARD_SNAPSHOT_DIR", "./snapshots") +STATIC_DIR = os.getenv("LOGBOARD_STATIC_DIR", "./app/static") diff --git a/app/core/docker.py b/app/core/docker.py new file mode 100644 index 0000000..3d7eed5 --- /dev/null +++ b/app/core/docker.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +LogBoard+ - Docker функции +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" + +import json +import os +from typing import List, Dict, Optional + +import docker + +from app.core.config import ( + DEFAULT_TAIL, + DEFAULT_PROJECT, + DEFAULT_PROJECTS, + SKIP_UNHEALTHY +) + +# Инициализация Docker клиента +docker_client = docker.from_env() + +def load_excluded_containers() -> List[str]: + """ + Загружает список исключенных контейнеров из JSON файла + Автор: Сергей Антропов + Сайт: https://devops.org.ru + """ + try: + with open("app/excluded_containers.json", "r", encoding="utf-8") as f: + data = json.load(f) + return data.get("excluded_containers", []) + except FileNotFoundError: + print("⚠️ Файл app/excluded_containers.json не найден, используем пустой список") + return [] + except json.JSONDecodeError as e: + print(f"❌ Ошибка парсинга app/excluded_containers.json: {e}") + return [] + except Exception as e: + print(f"❌ Ошибка загрузки app/excluded_containers.json: {e}") + return [] + +def save_excluded_containers(containers: List[str]) -> bool: + """ + Сохраняет список исключенных контейнеров в JSON файл + Автор: Сергей Антропов + Сайт: https://devops.org.ru + """ + try: + data = { + "excluded_containers": containers, + "description": "Список контейнеров, которые генерируют слишком много логов и исключаются из отображения" + } + with open("app/excluded_containers.json", "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + return True + except Exception as e: + print(f"❌ Ошибка сохранения app/excluded_containers.json: {e}") + return False + +def get_all_projects() -> List[str]: + """ + Получает список всех проектов Docker Compose с учетом исключенных контейнеров + Автор: Сергей Антропов + Сайт: https://devops.org.ru + """ + projects = set() + excluded_containers = load_excluded_containers() + + try: + containers = docker_client.containers.list(all=True) + + # Словарь для подсчета контейнеров по проектам + project_containers = {} + standalone_containers = [] + + for c in containers: + try: + # Пропускаем исключенные контейнеры + if c.name in excluded_containers: + continue + + labels = c.labels or {} + project = labels.get("com.docker.compose.project") + + if project: + if project not in project_containers: + project_containers[project] = 0 + project_containers[project] += 1 + else: + standalone_containers.append(c.name) + + except Exception: + continue + + # Добавляем проекты, у которых есть хотя бы один неисключенный контейнер + for project, count in project_containers.items(): + if count > 0: + projects.add(project) + + # Добавляем standalone, если есть неисключенные контейнеры без проекта + if standalone_containers: + projects.add("standalone") + + except Exception as e: + print(f"❌ Ошибка получения списка проектов: {e}") + return [] + + result = sorted(list(projects)) + print(f"📋 Доступные проекты (с учетом исключенных контейнеров): {result}") + return result + +def list_containers(projects: Optional[List[str]] = None, include_stopped: bool = False) -> List[Dict]: + """ + Получает список контейнеров с поддержкой множественных проектов + Автор: Сергей Антропов + Сайт: https://devops.org.ru + """ + # Загружаем список исключенных контейнеров из JSON файла + excluded_containers = load_excluded_containers() + + print(f"🚫 Список исключенных контейнеров: {excluded_containers}") + + items = [] + excluded_count = 0 + + try: + # Получаем список контейнеров с базовой обработкой ошибок + containers = docker_client.containers.list(all=include_stopped) + + for c in containers: + try: + # Базовая информация о контейнере (без health check) + basic_info = { + "id": c.id[:12], + "name": c.name, + "status": c.status, + "image": "unknown", + "service": c.name, + "project": None, + "health": None, + "ports": [], + "url": None, + } + + # Безопасно получаем метки + try: + labels = c.labels or {} + basic_info["project"] = labels.get("com.docker.compose.project") + basic_info["service"] = labels.get("com.docker.compose.service") or c.name + except Exception: + pass # Используем значения по умолчанию + + # Безопасно получаем информацию об образе + try: + if c.image and c.image.tags: + basic_info["image"] = c.image.tags[0] + elif c.image: + basic_info["image"] = c.image.short_id + except Exception: + pass # Оставляем "unknown" + + # Безопасно получаем информацию о портах + try: + ports = c.ports or {} + if ports: + basic_info["ports"] = list(ports.keys()) + + # Пытаемся найти HTTP/HTTPS порт для создания URL + for port_mapping in ports.values(): + if port_mapping: + for mapping in port_mapping: + if isinstance(mapping, dict) and mapping.get("HostPort"): + host_port = mapping["HostPort"] + # Проверяем, что это HTTP порт (80, 443, 8080, 3000, etc.) + host_port_int = int(host_port) + if (host_port_int in [80, 443] or + (1 <= host_port_int <= 7999) or + (8000 <= host_port_int <= 65535)): + protocol = "https" if host_port == "443" else "http" + basic_info["url"] = f"{protocol}://localhost:{host_port}" + basic_info["host_port"] = host_port + break + if basic_info["url"]: + break + except Exception: + pass # Оставляем пустые значения + + # Фильтрация по проектам + if projects: + # Если проект не указан, считаем его standalone + container_project = basic_info["project"] or "standalone" + if container_project not in projects: + continue + + # Фильтрация исключенных контейнеров + if basic_info["name"] in excluded_containers: + excluded_count += 1 + print(f"⚠️ Пропускаем исключенный контейнер: {basic_info['name']}") + continue + + # Добавляем контейнер в список + items.append(basic_info) + + except Exception as e: + # Пропускаем контейнеры с критическими ошибками + print(f"⚠️ Пропускаем проблемный контейнер {c.name if hasattr(c, 'name') else 'unknown'} (ID: {c.id[:12]}): {e}") + continue + + except Exception as e: + print(f"❌ Ошибка получения списка контейнеров: {e}") + return [] + + # Сортируем по проекту, сервису и имени + items.sort(key=lambda x: (x.get("project") or "", x.get("service") or "", x.get("name") or "")) + + # Подсчитываем статистику по проектам + project_stats = {} + for item in items: + project = item.get("project") or "standalone" + if project not in project_stats: + project_stats[project] = {"visible": 0, "excluded": 0} + project_stats[project]["visible"] += 1 + + # Подсчитываем исключенные контейнеры по проектам + for c in containers: + try: + if c.name in excluded_containers: + labels = c.labels or {} + project = labels.get("com.docker.compose.project") or "standalone" + if project not in project_stats: + project_stats[project] = {"visible": 0, "excluded": 0} + project_stats[project]["excluded"] += 1 + except Exception: + continue + + print(f"📊 Статистика: найдено {len(items)} контейнеров, исключено {excluded_count} контейнеров") + for project, stats in project_stats.items(): + print(f" 📦 {project}: {stats['visible']} видимых, {stats['excluded']} исключенных") + + return items diff --git a/app/models/auth.py b/app/models/auth.py new file mode 100644 index 0000000..84a7820 --- /dev/null +++ b/app/models/auth.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +LogBoard+ - Модели аутентификации +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" + +from typing import Optional +from pydantic import BaseModel + +class UserLogin(BaseModel): + username: str + password: str + +class Token(BaseModel): + access_token: str + token_type: str + +class TokenData(BaseModel): + username: Optional[str] = None