diff --git a/.gitignore b/.gitignore index 91893285..7650c958 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .direnv result* pkgs/clan-cli/clan_cli/nixpkgs +pkgs/clan-cli/clan_cli/webui/assets # python __pycache__ diff --git a/pkgs/clan-cli/clan_cli/webui/__init__.py b/pkgs/clan-cli/clan_cli/webui/__init__.py index 013468bc..fc1d8ca5 100644 --- a/pkgs/clan-cli/clan_cli/webui/__init__.py +++ b/pkgs/clan-cli/clan_cli/webui/__init__.py @@ -23,6 +23,18 @@ def register_parser(parser: argparse.ArgumentParser) -> None: parser.add_argument( "--no-open", action="store_true", help="Don't open the browser", default=False ) + parser.add_argument( + "--dev", action="store_true", help="Run in development mode", default=False + ) + parser.add_argument( + "--dev-port", + type=int, + default=3000, + help="Port to listen on for the dev server", + ) + parser.add_argument( + "--dev-host", type=str, default="localhost", help="Host to listen on" + ) parser.add_argument( "--reload", action="store_true", help="Don't reload on changes", default=False ) diff --git a/pkgs/clan-cli/clan_cli/webui/app.py b/pkgs/clan-cli/clan_cli/webui/app.py index 57f4e105..f1742d8c 100644 --- a/pkgs/clan-cli/clan_cli/webui/app.py +++ b/pkgs/clan-cli/clan_cli/webui/app.py @@ -1,7 +1,10 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.routing import APIRoute +from fastapi.staticfiles import StaticFiles +from .assets import asset_path +from .config import settings from .routers import health, machines, root @@ -10,14 +13,18 @@ def setup_app() -> FastAPI: app.include_router(health.router) app.include_router(machines.router) app.include_router(root.router) - # TODO make this configurable - app.add_middleware( - CORSMiddleware, - allow_origins="http://localhost:3000", - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], - ) + + if settings.env.is_development(): + # TODO make this configurable + app.add_middleware( + CORSMiddleware, + allow_origins="http://${settings.dev_host}:${settings.dev_port}", + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + else: + app.mount("/static", StaticFiles(directory=asset_path()), name="static") for route in app.routes: if isinstance(route, APIRoute): diff --git a/pkgs/clan-cli/clan_cli/webui/assets.py b/pkgs/clan-cli/clan_cli/webui/assets.py new file mode 100644 index 00000000..b6a027c4 --- /dev/null +++ b/pkgs/clan-cli/clan_cli/webui/assets.py @@ -0,0 +1,7 @@ +import functools +from pathlib import Path + + +@functools.cache +def asset_path() -> Path: + return Path(__file__).parent / "assets" diff --git a/pkgs/clan-cli/clan_cli/webui/config.py b/pkgs/clan-cli/clan_cli/webui/config.py new file mode 100644 index 00000000..d64c23b3 --- /dev/null +++ b/pkgs/clan-cli/clan_cli/webui/config.py @@ -0,0 +1,38 @@ +# config.py +import logging +import os +from enum import Enum + +from pydantic import BaseSettings + +logger = logging.getLogger(__name__) + + +class EnvType(Enum): + production = "production" + development = "development" + + @staticmethod + def from_environment() -> "EnvType": + t = os.environ.get("CLAN_WEBUI_ENV", "production") + try: + return EnvType[t] + except KeyError: + logger.warning(f"Invalid environment type: {t}, fallback to production") + return EnvType.production + + def is_production(self) -> bool: + return self == EnvType.production + + def is_development(self) -> bool: + return self == EnvType.development + + +class Settings(BaseSettings): + env: EnvType = EnvType.from_environment() + dev_port: int = int(os.environ.get("CLAN_WEBUI_DEV_PORT", 3000)) + dev_host: str = os.environ.get("CLAN_WEBUI_DEV_HOST", "localhost") + + +# global instance +settings = Settings() diff --git a/pkgs/clan-cli/clan_cli/webui/routers/root.py b/pkgs/clan-cli/clan_cli/webui/routers/root.py index 752b6e72..60a83590 100644 --- a/pkgs/clan-cli/clan_cli/webui/routers/root.py +++ b/pkgs/clan-cli/clan_cli/webui/routers/root.py @@ -1,9 +1,28 @@ +import os +from mimetypes import guess_type +from pathlib import Path + from fastapi import APIRouter, Response +from ..assets import asset_path + router = APIRouter() -@router.get("/") -async def root() -> Response: - body = "

Welcome

" - return Response(content=body, media_type="text/html") +@router.get("/{path_name:path}") +async def root(path_name: str) -> Response: + if path_name == "": + path_name = "index.html" + filename = Path(os.path.normpath((asset_path() / path_name))) + + if not filename.is_relative_to(asset_path()): + # prevent directory traversal + return Response(status_code=403) + + if not filename.is_file(): + print(filename) + print(asset_path()) + return Response(status_code=404) + + content_type, _ = guess_type(filename) + return Response(filename.read_bytes(), media_type=content_type) diff --git a/pkgs/clan-cli/clan_cli/webui/server.py b/pkgs/clan-cli/clan_cli/webui/server.py index cbea5a21..1e1881fa 100644 --- a/pkgs/clan-cli/clan_cli/webui/server.py +++ b/pkgs/clan-cli/clan_cli/webui/server.py @@ -1,12 +1,20 @@ import argparse +import logging +import os +import subprocess import time import urllib.request import webbrowser +from contextlib import ExitStack, contextmanager +from pathlib import Path from threading import Thread +from typing import Iterator # XXX: can we dynamically load this using nix develop? from uvicorn import run +logger = logging.getLogger(__name__) + def defer_open_browser(base_url: str) -> None: for i in range(5): @@ -18,15 +26,41 @@ def defer_open_browser(base_url: str) -> None: webbrowser.open(base_url) +@contextmanager +def spawn_node_dev_server() -> Iterator[None]: + logger.info("Starting node dev server...") + path = Path(__file__).parent.parent.parent.parent / "ui" + with subprocess.Popen( + ["direnv", "exec", path, "npm", "run", "dev"], + cwd=path, + ) as proc: + try: + yield + finally: + proc.terminate() + + def start_server(args: argparse.Namespace) -> None: - if not args.no_open: - Thread( - target=defer_open_browser, args=(f"http://[{args.host}]:{args.port}",) - ).start() - run( - "clan_cli.webui.app:app", - host=args.host, - port=args.port, - log_level=args.log_level, - reload=args.reload, - ) + with ExitStack() as stack: + if args.dev: + os.environ["CLAN_WEBUI_ENV"] = "development" + os.environ["CLAN_WEBUI_DEV_PORT"] = str(args.dev_port) + os.environ["CLAN_WEBUI_DEV_HOST"] = args.dev_host + + stack.enter_context(spawn_node_dev_server()) + + open_url = f"http://{args.dev_host}:{args.dev_port}" + else: + os.environ["CLAN_WEBUI_ENV"] = "production" + open_url = f"http://[{args.host}]:{args.port}" + + if not args.no_open: + Thread(target=defer_open_browser, args=(open_url,)).start() + + run( + "clan_cli.webui.app:app", + host=args.host, + port=args.port, + log_level=args.log_level, + reload=args.reload, + ) diff --git a/pkgs/clan-cli/default.nix b/pkgs/clan-cli/default.nix index 7636b15c..feee2d82 100644 --- a/pkgs/clan-cli/default.nix +++ b/pkgs/clan-cli/default.nix @@ -19,6 +19,7 @@ , zerotierone , rsync , pkgs +, ui-assets }: let # This provides dummy options for testing clan config and prevents it from @@ -49,6 +50,7 @@ let rm $out/clan_cli/config/jsonschema cp -r ${self + /lib/jsonschema} $out/clan_cli/config/jsonschema ln -s ${nixpkgs} $out/clan_cli/nixpkgs + ln -s ${ui-assets} $out/clan_cli/webui/assets ''; nixpkgs = runCommand "nixpkgs" { } '' mkdir -p $out/unfree diff --git a/pkgs/clan-cli/flake-module.nix b/pkgs/clan-cli/flake-module.nix index 2ffba47d..921b7778 100644 --- a/pkgs/clan-cli/flake-module.nix +++ b/pkgs/clan-cli/flake-module.nix @@ -2,12 +2,12 @@ perSystem = { self', pkgs, ... }: { devShells.clan-cli = pkgs.callPackage ./shell.nix { inherit self; - inherit (self'.packages) clan-cli; + inherit (self'.packages) clan-cli ui-assets; }; packages = { clan-cli = pkgs.python3.pkgs.callPackage ./default.nix { inherit self; - zerotierone = self'.packages.zerotierone; + inherit (self'.packages) ui-assets zerotierone; }; clan-openapi = self'.packages.clan-cli.clan-openapi; default = self'.packages.clan-cli; diff --git a/pkgs/clan-cli/pyproject.toml b/pkgs/clan-cli/pyproject.toml index 95b8ea32..1892ad69 100644 --- a/pkgs/clan-cli/pyproject.toml +++ b/pkgs/clan-cli/pyproject.toml @@ -12,7 +12,7 @@ scripts = { clan = "clan_cli:main" } exclude = ["clan_cli.nixpkgs*"] [tool.setuptools.package-data] -clan_cli = ["config/jsonschema/*"] +clan_cli = [ "config/jsonschema/*", "webui/assets/**/*"] [tool.pytest.ini_options] addopts = "--cov . --cov-report term --cov-report html:.reports/html --no-cov-on-fail" diff --git a/pkgs/clan-cli/shell.nix b/pkgs/clan-cli/shell.nix index 2ba4b73d..600f5a34 100644 --- a/pkgs/clan-cli/shell.nix +++ b/pkgs/clan-cli/shell.nix @@ -1,4 +1,4 @@ -{ self, clan-cli, pkgs }: +{ self, clan-cli, pkgs, ui-assets }: let pythonWithDeps = pkgs.python3.withPackages ( ps: @@ -26,8 +26,9 @@ pkgs.mkShell { shellHook = '' tmp_path=$(realpath ./.direnv) - rm -f clan_cli/nixpkgs + rm -f clan_cli/nixpkgs clan_cli/assets ln -sf ${clan-cli.nixpkgs} clan_cli/nixpkgs + ln -sf ${ui-assets} clan_cli/webui/assets export PATH="$tmp_path/bin:${checkScript}/bin:$PATH" export PYTHONPATH="$PYTHONPATH:$(pwd)"