"""Turnstone REST API — serves REST API and Vue SPA under the /turnstone prefix. All routes (API + static files) are mounted at /turnstone so the app works identically whether accessed directly (http://host:8534/turnstone/) or through Caddy (menagerie.circuitforge.tech/turnstone) without prefix stripping. """ from __future__ import annotations import dataclasses import os from pathlib import Path from typing import Annotated from fastapi import APIRouter, FastAPI, Query from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from app.services.search import search as _search, list_sources as _list_sources, format_results DB_PATH = Path(os.environ.get("TURNSTONE_DB", Path(__file__).parent.parent / "data" / "turnstone.db")) DIST_DIR = Path(__file__).parent.parent / "web" / "dist" app = FastAPI(title="Turnstone API", version="0.1.0", docs_url="/turnstone/docs", redoc_url=None) app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["GET", "POST"], allow_headers=["*"], ) # Serve built Vue assets at the path Vite embeds in index.html. if (DIST_DIR / "assets").exists(): app.mount("/turnstone/assets", StaticFiles(directory=str(DIST_DIR / "assets")), name="assets") # API router — all routes accessible at /turnstone/api/* and /turnstone/health. router = APIRouter(prefix="/turnstone") @router.get("/health") def health() -> dict: return {"status": "ok", "db": str(DB_PATH)} @router.get("/api/search") def search_logs( q: Annotated[str, Query(description="Search query")] = "", source: Annotated[str | None, Query(description="Filter by log source ID (partial match)")] = None, severity: Annotated[str | None, Query(description="Filter by severity (DEBUG/INFO/WARN/ERROR/CRITICAL)")] = None, since: Annotated[str | None, Query(description="ISO timestamp lower bound")] = None, until: Annotated[str | None, Query(description="ISO timestamp upper bound")] = None, limit: Annotated[int, Query(ge=1, le=500)] = 50, ) -> dict: if not q: return {"count": 0, "results": []} results = _search( DB_PATH, query=q, source_filter=source, severity=severity, since=since, until=until, limit=limit, ) return {"count": len(results), "results": [dataclasses.asdict(r) for r in results]} @router.get("/api/diagnose") def diagnose( q: Annotated[str, Query(description="Service name or problem description")] = "", source: Annotated[str | None, Query(description="Limit to a specific source ID (partial match)")] = None, since: Annotated[str | None, Query(description="ISO timestamp lower bound")] = None, until: Annotated[str | None, Query(description="ISO timestamp upper bound")] = None, ) -> dict: if not q: return {"count": 0, "results": [], "formatted": ""} common: dict = dict(source_filter=source, since=since, until=until, include_repeats=False) broad = _search(DB_PATH, query=q, limit=15, **common) critical = _search(DB_PATH, query=q, severity="CRITICAL", limit=5, **common) errors = _search(DB_PATH, query=q, severity="ERROR", limit=10, **common) seen: set[str] = set() combined = [] for r in broad + critical + errors: if r.entry_id not in seen: seen.add(r.entry_id) combined.append(r) combined.sort(key=lambda r: (r.timestamp_iso or "\xff", r.sequence)) combined = combined[:20] return { "count": len(combined), "results": [dataclasses.asdict(r) for r in combined], "formatted": format_results(combined), } @router.get("/api/sources") def list_sources() -> dict: return {"sources": _list_sources(DB_PATH)} app.include_router(router) # Root redirect → /turnstone/ @app.get("/") def root_redirect() -> RedirectResponse: return RedirectResponse(url="/turnstone/") # SPA catch-all — serves index.html for any /turnstone/* path that isn't a # static asset or API route. Must be registered after include_router. @app.get("/turnstone/{path:path}") def spa_fallback(path: str) -> FileResponse: if DIST_DIR.exists(): candidate = DIST_DIR / path if candidate.is_file(): return FileResponse(str(candidate)) return FileResponse(str(DIST_DIR / "index.html")) return FileResponse("/dev/null", status_code=503)