refactor: extract all routes to app/api/v1/endpoints/ with proper structure

This commit is contained in:
Сергей Антропов
2025-08-20 16:48:06 +03:00
parent 1e6149107d
commit 40f614304b
11 changed files with 1193 additions and 1153 deletions

View File

@@ -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}

View File

@@ -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"
}
)

View File

@@ -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}

View File

@@ -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("Тестовая общая ошибка")

View File

@@ -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

26
app/api/v1/router.py Normal file
View File

@@ -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)