Add service CRUD API, app factory and CLI entrypoint
This commit is contained in:
1 parent
9e6e7745d0
commit
73315a939e
4 files changed
+177
No files matched your search
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user