diff --git a/README.md b/README.md index 9953933..1540d8d 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,20 @@ Get-Content env.dev.example | ForEach-Object { Start-Process "http://localhost:8080" ``` +### Plain HTML frontend (no Node needed) + +`web/index.html` is a single-file frontend with no build step. The API serves +it at `/app`, so it needs no CORS setup and no second terminal. After step 5: + +```powershell +Start-Process "http://localhost:8000/app" +``` + +It covers import (preview, column types, new table or T&E table, skip or +replace duplicates), migrated files with a row viewer and delete, T&E table +row counts, and the deletion log. If the API runs with `API_KEY` set, enter +the same key with the key button in the page header. + --- ## API Surface diff --git a/api/main.py b/api/main.py index 2281578..1e6ecfb 100644 --- a/api/main.py +++ b/api/main.py @@ -9,11 +9,13 @@ import logging from contextlib import asynccontextmanager +from pathlib import Path import psycopg2.pool from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse +from fastapi.staticfiles import StaticFiles from api import db from api.config import settings @@ -72,6 +74,12 @@ async def lifespan(app: FastAPI): app.include_router(te_routes.router) app.include_router(audit_routes.router) +# Plain HTML frontend (web/index.html), served same-origin at /app so it needs +# no CORS setup. Skipped when web/ isn't shipped (e.g. an API-only image). +WEB_DIR = Path(__file__).resolve().parent.parent / "web" +if WEB_DIR.is_dir(): + app.mount("/app", StaticFiles(directory=WEB_DIR, html=True), name="web") + @app.exception_handler(psycopg2.pool.PoolError) def pool_exhausted_handler(request: Request, exc: psycopg2.pool.PoolError) -> JSONResponse: diff --git a/tests/test_api_unit.py b/tests/test_api_unit.py index 79083f0..bc02801 100644 --- a/tests/test_api_unit.py +++ b/tests/test_api_unit.py @@ -153,3 +153,14 @@ def test_identifiers_are_never_string_formatted(self): offenders.append(f"{path.name}:{node.lineno}") self.assertEqual(offenders, [], f"execute() called with an f-string at {offenders}") + + +@pytest.mark.unit +class WebFrontend(unittest.TestCase): + """The plain HTML frontend in web/ is served same-origin at /app.""" + + def test_app_serves_index_html(self): + r = client.get("/app/") + self.assertEqual(r.status_code, 200) + self.assertIn("text/html", r.headers["content-type"]) + self.assertIn("