refactor: extract all routes to app/api/v1/endpoints/ with proper structure
This commit is contained in:
65
app/api/v1/endpoints/auth.py
Normal file
65
app/api/v1/endpoints/auth.py
Normal 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}
|
||||
95
app/api/v1/endpoints/containers.py
Normal file
95
app/api/v1/endpoints/containers.py
Normal 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"
|
||||
}
|
||||
)
|
||||
220
app/api/v1/endpoints/logs.py
Normal file
220
app/api/v1/endpoints/logs.py
Normal 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}
|
||||
75
app/api/v1/endpoints/pages.py
Normal file
75
app/api/v1/endpoints/pages.py
Normal 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("Тестовая общая ошибка")
|
||||
309
app/api/v1/endpoints/websocket.py
Normal file
309
app/api/v1/endpoints/websocket.py
Normal 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
26
app/api/v1/router.py
Normal 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)
|
||||
Reference in New Issue
Block a user