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 eaa9291a5c
commit e579396eb8

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
handles both API routes and the frontend. Caddy strips the /turnstone prefix
before forwarding, so FastAPI always sees paths starting with /api/, /health, etc.
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
@ -11,9 +11,9 @@ import os
from pathlib import Path
from typing import Annotated
from fastapi import FastAPI, Query
from fastapi import APIRouter, FastAPI, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
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
@ -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"))
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(
CORSMiddleware,
@ -30,18 +30,20 @@ app.add_middleware(
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")
# 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")
@app.get("/health")
@router.get("/health")
def health() -> dict:
return {"status": "ok", "db": str(DB_PATH)}
@app.get("/api/search")
@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,
@ -61,13 +63,10 @@ def search_logs(
until=until,
limit=limit,
)
return {
"count": len(results),
"results": [dataclasses.asdict(r) for r in results],
}
return {"count": len(results), "results": [dataclasses.asdict(r) for r in results]}
@app.get("/api/diagnose")
@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,
@ -97,15 +96,23 @@ def diagnose(
}
@app.get("/api/sources")
@router.get("/api/sources")
def list_sources() -> dict:
sources = _list_sources(DB_PATH)
return {"sources": sources}
return {"sources": _list_sources(DB_PATH)}
# 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}")
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