fix: mount all routes at /turnstone prefix for direct LAN access

Vite builds with base='/turnstone/' so asset paths in index.html are
/turnstone/assets/*. Serving FastAPI at root / meant direct hits to
port 8534 got index.html for asset requests (blank page).

- All routes now under /turnstone (APIRouter prefix + StaticFiles mount
  at /turnstone/assets + SPA catch-all at /turnstone/{path})
- Root / redirects to /turnstone/
- Caddy block reverted to no-strip: both direct LAN and Caddy access
  hit the same paths, no per-host routing differences
This commit is contained in:
pyr0ball 2026-05-08 17:45:34 -07:00
parent 518fc926ce
commit cbbe5eaac1

View file

@ -1,8 +1,8 @@
"""Turnstone REST API — thin HTTP wrapper around the search and ingest services. """Turnstone REST API — serves REST API and Vue SPA under the /turnstone prefix.
Serves the Vue SPA from web/dist/ as static files so a single uvicorn process All routes (API + static files) are mounted at /turnstone so the app works
handles both API routes and the frontend. Caddy strips the /turnstone prefix identically whether accessed directly (http://host:8534/turnstone/) or through
before forwarding, so FastAPI always sees paths starting with /api/, /health, etc. Caddy (menagerie.circuitforge.tech/turnstone) without prefix stripping.
""" """
from __future__ import annotations from __future__ import annotations
@ -11,9 +11,9 @@ import os
from pathlib import Path from pathlib import Path
from typing import Annotated from typing import Annotated
from fastapi import FastAPI, Query from fastapi import APIRouter, FastAPI, Query
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse from fastapi.responses import FileResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from app.services.search import search as _search, list_sources as _list_sources, format_results from app.services.search import search as _search, list_sources as _list_sources, format_results
@ -21,7 +21,7 @@ from app.services.search import search as _search, list_sources as _list_sources
DB_PATH = Path(os.environ.get("TURNSTONE_DB", Path(__file__).parent.parent / "data" / "turnstone.db")) DB_PATH = Path(os.environ.get("TURNSTONE_DB", Path(__file__).parent.parent / "data" / "turnstone.db"))
DIST_DIR = Path(__file__).parent.parent / "web" / "dist" DIST_DIR = Path(__file__).parent.parent / "web" / "dist"
app = FastAPI(title="Turnstone API", version="0.1.0") app = FastAPI(title="Turnstone API", version="0.1.0", docs_url="/turnstone/docs", redoc_url=None)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
@ -30,18 +30,20 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
# Serve built Vue assets if the dist directory exists. # Serve built Vue assets at the path Vite embeds in index.html.
# Must be mounted before the SPA catch-all route below. if (DIST_DIR / "assets").exists():
if DIST_DIR.exists(): app.mount("/turnstone/assets", StaticFiles(directory=str(DIST_DIR / "assets")), name="assets")
app.mount("/assets", StaticFiles(directory=str(DIST_DIR / "assets")), name="assets")
# API router — all routes accessible at /turnstone/api/* and /turnstone/health.
router = APIRouter(prefix="/turnstone")
@app.get("/health") @router.get("/health")
def health() -> dict: def health() -> dict:
return {"status": "ok", "db": str(DB_PATH)} return {"status": "ok", "db": str(DB_PATH)}
@app.get("/api/search") @router.get("/api/search")
def search_logs( def search_logs(
q: Annotated[str, Query(description="Search query")] = "", q: Annotated[str, Query(description="Search query")] = "",
source: Annotated[str | None, Query(description="Filter by log source ID (partial match)")] = None, source: Annotated[str | None, Query(description="Filter by log source ID (partial match)")] = None,
@ -61,13 +63,10 @@ def search_logs(
until=until, until=until,
limit=limit, limit=limit,
) )
return { return {"count": len(results), "results": [dataclasses.asdict(r) for r in results]}
"count": len(results),
"results": [dataclasses.asdict(r) for r in results],
}
@app.get("/api/diagnose") @router.get("/api/diagnose")
def diagnose( def diagnose(
q: Annotated[str, Query(description="Service name or problem description")] = "", 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, source: Annotated[str | None, Query(description="Limit to a specific source ID (partial match)")] = None,
@ -97,15 +96,23 @@ def diagnose(
} }
@app.get("/api/sources") @router.get("/api/sources")
def list_sources() -> dict: def list_sources() -> dict:
sources = _list_sources(DB_PATH) return {"sources": _list_sources(DB_PATH)}
return {"sources": sources}
# SPA catch-all — must be last. Serves index.html for any path that doesn't app.include_router(router)
# match an API route, enabling Vue Router's client-side navigation.
@app.get("/{path:path}")
# 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: def spa_fallback(path: str) -> FileResponse:
if DIST_DIR.exists(): if DIST_DIR.exists():
candidate = DIST_DIR / path candidate = DIST_DIR / path