From 73315a939e1172e3e841a6bfb98d5ea72a33b148 Mon Sep 17 00:00:00 2001 From: Archibold Date: Wed, 28 Oct 2026 21:44:52 +1100 Subject: [PATCH] Add service CRUD API, app factory and CLI entrypoint --- src/uwudash_mk3/__init__.py | 7 ++++ src/uwudash_mk3/api.py | 70 +++++++++++++++++++++++++++++++++++++ src/uwudash_mk3/app.py | 56 +++++++++++++++++++++++++++++ src/uwudash_mk3/main.py | 44 +++++++++++++++++++++++ 4 files changed, 177 insertions(+) create mode 100644 src/uwudash_mk3/__init__.py create mode 100644 src/uwudash_mk3/api.py create mode 100644 src/uwudash_mk3/app.py create mode 100644 src/uwudash_mk3/main.py diff --git a/src/uwudash_mk3/__init__.py b/src/uwudash_mk3/__init__.py new file mode 100644 index 0000000..ccaf29a --- /dev/null +++ b/src/uwudash_mk3/__init__.py @@ -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" diff --git a/src/uwudash_mk3/api.py b/src/uwudash_mk3/api.py new file mode 100644 index 0000000..7da69c8 --- /dev/null +++ b/src/uwudash_mk3/api.py @@ -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 diff --git a/src/uwudash_mk3/app.py b/src/uwudash_mk3/app.py new file mode 100644 index 0000000..d843203 --- /dev/null +++ b/src/uwudash_mk3/app.py @@ -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 diff --git a/src/uwudash_mk3/main.py b/src/uwudash_mk3/main.py new file mode 100644 index 0000000..14a5c9f --- /dev/null +++ b/src/uwudash_mk3/main.py @@ -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)