Python http.server can't do SPA routing and Caddy was forwarding /turnstone/* paths that the static server couldn't resolve. - app/rest.py: mount web/dist/assets as StaticFiles; add SPA catch-all route that serves index.html for any unmatched path - manage.sh: start/stop/status simplified to single process on :8534; remove UI_PORT / UI_PID_FILE; drop http.server invocation - Caddyfile: replace split API/:8534 + SPA/:8535 block with a single strip_prefix + reverse_proxy to :8534
115 lines
4.1 KiB
Python
115 lines
4.1 KiB
Python
"""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)
|