Newer
Older
hard-panel / panel / backend / app / main.py
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)