import asyncio
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.routing import APIRoute
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from app.security import require_admin, require_mcp
from app.api import auth_routes, events, ingest, mcp_tokens, servers, services, shares
from app.db import close_db, init_db
from app.events import offline_loop
from app.mcp import mcp, mcp_endpoint
from app.services_probe import loop as services_probe_loop
from app.shares_probe import loop as shares_probe_loop
# Собранный Vue-фронт (panel/frontend/dist) попадает сюда в docker-образе;
# в dev фронт крутится отдельно (vite, proxy /api → localhost:8000)
STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
@asynccontextmanager
async def lifespan(_: FastAPI):
await init_db()
# фоновый пробер сетевых хранилищ (share_samples)
probe_task = asyncio.create_task(shares_probe_loop())
# фоновый пробер health-эндпоинтов сервисов (service_samples)
health_task = asyncio.create_task(services_probe_loop())
# подчистка expired-сессий и oauth state (каждые 10 минут)
gc_task = asyncio.create_task(auth_gc_loop())
# watchdog offline (нет пакетов дольше interval * multiplier) + ретеншн ивентов
watchdog_task = asyncio.create_task(offline_loop())
# lifespan смонтированных sub-app не вызывается — MCP session manager
# стартуем здесь, иначе /mcp отвечает 500 ("Task group is not initialized")
async with mcp.session_manager.run():
yield
probe_task.cancel()
health_task.cancel()
gc_task.cancel()
watchdog_task.cancel()
await close_db()
async def auth_gc_loop() -> None:
from app.auth import purge_expired
while True:
try:
await purge_expired()
except Exception as exc: # не роняем цикл из-за одной ошибки
print(f"auth gc error: {exc}", flush=True)
await asyncio.sleep(600)
app = FastAPI(title="GHard Monitor API", version="0.1.0", lifespan=lifespan)
# В проде фронт раздаётся тем же origin; CORS нужен для vite dev-сервера (этап 3)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
# OAuth/session-роуты gnexus-auth: должны быть зарегистрированы ДО SPA catch-all
app.include_router(auth_routes.router)
app.include_router(auth_routes.me_router)
app.include_router(auth_routes.webhook_router)
app.include_router(ingest.router)
app.include_router(servers.router)
app.include_router(shares.router)
app.include_router(services.router)
app.include_router(events.router)
app.include_router(mcp_tokens.router)
app.include_router(mcp_tokens.admin_router)
# MCP для ИИ-агентов (streamable HTTP, stateless) — POST/GET/DELETE /mcp.
# Гард require_mcp: персональный Bearer mcp_* (страница «MCP-ключи»),
# статический GHARD_ADMIN_TOKEN (супер-токен), браузерная cookie сессии.
app.router.routes.append(
APIRoute("/mcp", mcp_endpoint, methods=["GET", "POST", "DELETE"],
dependencies=[Depends(require_mcp)])
)
@app.get("/api/v1/health")
async def health() -> dict:
return {"status": "ok"}
# --- Статика фронтенда: SPA с fallback на index.html -------------------------
if STATIC_DIR.is_dir():
assets = STATIC_DIR / "assets"
if assets.is_dir():
app.mount("/assets", StaticFiles(directory=assets), name="assets")
@app.get("/{full_path:path}", include_in_schema=False)
async def spa(full_path: str) -> FileResponse:
"""Роуты Vue отдаём из dist, всё непонятное — index.html (SPA history).
Без Cache-Control браузер эвристически кэширует index.html/лого — и после
деплоя продолжает жить в старом bundle: no-store защищает от этого."""
candidate = STATIC_DIR / full_path
headers = {"Cache-Control": "no-store"}
if full_path and candidate.is_file():
return FileResponse(candidate, headers=headers)
return FileResponse(STATIC_DIR / "index.html", headers=headers)