Add service CRUD API, app factory and CLI entrypoint

This commit is contained in:
Archibold committed 2026-10-28 21:44:52 +11:00
1 parent 9e6e7745d0
commit 73315a939e
4 files changed
+177

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
"""UwuDash MK3 - a self-hosted dashboard for internal services."""
from .app import create_app
from .main import main
__all__ = ["create_app", "main"]
__version__ = "0.1.0"
+70
View File
@@ -0,0 +1,70 @@
"""JSON API used by the dashboard UI to manage services."""
from __future__ import annotations
from typing import List
from fastapi import APIRouter, HTTPException
from .config import Config
from .models import Service, ServiceInput, ServicePatch
from .proxy import Proxy
from .store import ServiceStore
def create_api_router(store: ServiceStore, proxy: Proxy, config: Config) -> APIRouter:
router = APIRouter(prefix="/api")
@router.get("/config")
def get_config() -> dict:
return {
"host": config.host,
"port": config.port,
"services_file": str(config.services_file),
"strip_frame_options": config.strip_frame_options,
"proxy_timeout": config.proxy_timeout,
}
@router.get("/services")
def list_services() -> List[Service]:
return store.list()
@router.get("/services/{service_id}")
def get_service(service_id: str) -> Service:
service = store.get(service_id)
if service is None:
raise HTTPException(status_code=404, detail=f"unknown service: {service_id}")
return service
@router.post("/services", status_code=201)
def create_service(payload: ServiceInput) -> Service:
return store.create(payload)
@router.patch("/services/{service_id}")
def update_service(service_id: str, payload: ServicePatch) -> Service:
service = store.update(service_id, payload)
if service is None:
raise HTTPException(status_code=404, detail=f"unknown service: {service_id}")
return service
@router.put("/services/{service_id}")
def replace_service(service_id: str, payload: ServiceInput) -> Service:
patch = ServicePatch(**payload.model_dump())
service = store.update(service_id, patch)
if service is None:
raise HTTPException(status_code=404, detail=f"unknown service: {service_id}")
return service
@router.delete("/services/{service_id}", status_code=204)
def delete_service(service_id: str) -> None:
if not store.delete(service_id):
raise HTTPException(status_code=404, detail=f"unknown service: {service_id}")
@router.get("/services/{service_id}/health")
async def service_health(service_id: str) -> dict:
service = store.get(service_id)
if service is None:
raise HTTPException(status_code=404, detail=f"unknown service: {service_id}")
return await proxy.check(service)
return router
+56
View File
@@ -0,0 +1,56 @@
"""FastAPI application factory: static frontend + API + reverse proxy."""
from __future__ import annotations
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from .api import create_api_router
from .config import Config
from .proxy import Proxy
from .store import ServiceStore
PACKAGE_DIR = Path(__file__).parent
ALL_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]
def create_app(config: Optional[Config] = None) -> FastAPI:
config = config or Config.from_env()
store = ServiceStore(config.services_file)
proxy = Proxy(store, config)
@asynccontextmanager
async def lifespan(app: FastAPI):
yield
proxy.close()
app = FastAPI(title="UwuDash MK3", version="0.1.0", lifespan=lifespan)
app.state.config = config
app.state.store = store
app.state.proxy = proxy
app.mount("/static", StaticFiles(directory=PACKAGE_DIR / "static"), name="static")
templates = Jinja2Templates(directory=PACKAGE_DIR / "templates")
app.include_router(create_api_router(store, proxy, config))
@app.get("/", response_class=None)
def index(request: Request):
return templates.TemplateResponse(request, "index.html", {"services": store.list()})
@app.get("/health")
def health() -> dict:
return {"status": "ok", "services": len(store.list())}
# Reverse proxy: the only route that can reach an upstream service.
@app.api_route("/proxy/{service_id}", methods=ALL_METHODS)
@app.api_route("/proxy/{service_id}/{path:path}", methods=ALL_METHODS)
async def proxy_route(service_id: str, request: Request, path: str = ""):
return await proxy.handle(service_id, path, request)
return app
+44
View File
@@ -0,0 +1,44 @@
"""Command line entrypoint: ``uv run uwudash-mk3``."""
from __future__ import annotations
import argparse
from dataclasses import replace
from pathlib import Path
import uvicorn
from .app import create_app
from .config import Config
def parse_args(argv=None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="UwuDash MK3 - internal service dashboard")
parser.add_argument("--host", help="bind address (default 127.0.0.1)")
parser.add_argument("--port", type=int, help="bind port (default 8000)")
parser.add_argument("--data-dir", help="directory holding services.json")
parser.add_argument("--services-file", help="path to the services JSON file")
parser.add_argument("--reload", action="store_true", help="restart on source changes")
return parser.parse_args(argv)
def build_config(args: argparse.Namespace) -> Config:
config = Config.from_env()
if args.host:
config = replace(config, host=args.host)
if args.port:
config = replace(config, port=args.port)
if args.data_dir:
config = replace(config, data_dir=Path(args.data_dir))
if args.services_file:
config = replace(config, services_file=Path(args.services_file))
elif args.data_dir:
config = replace(config, services_file=Path(args.data_dir) / "services.json")
return config
def main(argv=None) -> None:
args = parse_args(argv)
config = build_config(args)
print(f"UwuDash MK3 listening on http://{config.host}:{config.port}")
uvicorn.run(create_app(config), host=config.host, port=config.port, reload=args.reload)