"""Turnstone REST API — thin HTTP wrapper around the search and ingest services. Serves the Vue SPA from web/dist/ as static files so a single uvicorn process handles both API routes and the frontend. Caddy strips the /turnstone prefix before forwarding, so FastAPI always sees paths starting with /api/, /health, etc. """ from __future__ import annotations import dataclasses import os from pathlib import Path from typing import Annotated from fastapi import FastAPI, Query from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse 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") app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["GET", "POST"], allow_headers=["*"], ) # Serve built Vue assets if the dist directory exists. # Must be mounted before the SPA catch-all route below. if DIST_DIR.exists(): app.mount("/assets", StaticFiles(directory=str(DIST_DIR / "assets")), name="assets") @app.get("/health") def health() -> dict: return {"status": "ok", "db": str(DB_PATH)} @app.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], } @app.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), } @app.get("/api/sources") def list_sources() -> dict: sources = _list_sources(DB_PATH) return {"sources": sources} # SPA catch-all — must be last. Serves index.html for any path that doesn't # match an API route, enabling Vue Router's client-side navigation. @app.get("/{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)