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)"